# hunk-review-skill
[](https://github.com/vendis-ai/hunk-review-skill/actions/workflows/ci.yml)
Turn a large diff or PR into a **grouped, prioritised review plan** you open before reading any
code. It also produces a skimmable HTML writeup of what the branch actually does.
Two agent skills, a CLI, and two [Hunk](https://github.com/modem-dev/hunk) extensions. An agent
reads the changeset, decides what matters, and writes a plan; Hunk then presents the diff in that
order, grouped by topic, with a handful of annotations on the hunks that carry a real decision.
The point is what it *doesn't* show you. A branch with a genuine auth fix buried inside a
2,000-file rename reads as 2,000 files. With a plan it reads as "security boundary, 4 files", and
everything else sinks.
Run `/hunk-review` in an agent session sitting on the branch. You get one plan in two views: the
diff in the terminal, and the reasoning on a page next to it.
| The plan in Hunk | The same plan as a page |
|---|---|
| [<img src="docs/img/review-plan-tui.png" height="290" alt="The review plan in Hunk: groups ordered by importance, with an annotation open on the hunk that carries the decision">](docs/img/review-plan-tui.png) | [<img src="docs/img/writeup.png" height="290" alt="The HTML writeup: TL;DR, a table of contents with per-group summaries, and bullets carrying ref tags back to the annotations">](docs/img/writeup.png) |
Now read it. Where you want something changed, leave a note on the line in Hunk, and run
`/hunk-handle-notes` when you are done. It makes the concrete fixes, answers the questions, and
brings anything debatable back to you instead of deciding on its own.
## Requirements
- [Hunk](https://github.com/modem-dev/hunk). Install it with `mise use -g aqua:modem-dev/hunk`.
- `git`, `bash` and `jq`. `hunk-plan` validates, converts and renders with them.
- An agent that reads `~/.agents/skills/` or `~/.claude/skills/` (Claude Code, Codex, OpenCode,
Cursor, and most others).
- `~/.local/bin` on your `PATH`.
Nothing else. The writeup is built by the same `bash` and `jq` the plan already needs, and its
Markdown and diagram rendering come from the two libraries vendored in `assets/`. The page opens
over `file://` and never makes a network call.
## Install
```sh
git clone https://github.com/vendis-ai/hunk-review-skill.git
cd hunk-review-skill
./install.sh # --dry-run to see what it would do first
```
Everything is symlinked back into the checkout, so updating is mostly just `git pull`. See
[Updating](#updating).
The installer never clobbers a real file, and it refuses to run if a destination directory is
itself a symlink back into this repo. Re-running it is safe.
### What it installs, and where
| What | Where | Why there |
|---|---|---|
| Both skills | `~/.agents/skills/<name>` | The cross-agent location. Codex, OpenCode, Cursor, Cline, Warp, Zed, Gemini CLI and Copilot read it directly, with no per-agent setup. |
| Both skills | `~/.claude/skills/<name>` | Claude Code is the one common harness that reads only its own directory. |
| `hunk-plan` | `~/.local/bin/hunk-plan` | The skill invokes it as a bare command, so it has to be on your real PATH. |
| Both extensions | *nowhere*, see below | Hunk is pointed at the checkout by absolute path. |
If you already have an `[extensions]` section in `~/.config/hunk/config.toml`, the installer
**will not edit it**. It prints the exact line to add instead. TOML forbids a second `[extensions]`
table, and a shell script rewriting an existing `paths` array is precisely how the silent failure
below gets introduced.
## Updating
```sh
cd hunk-review-skill && git pull && ./install.sh
```
`git pull` on its own covers the common case. The skills, `hunk-plan` and the extension are all
read straight out of this checkout, so changed content is live the next time you start an agent or
launch Hunk. There is nothing to reinstall.
Re-run `install.sh` as well whenever a release **adds a skill or an extension**. Nothing links a
new skill on its own, and nothing adds a new extension to Hunk's `paths`; both failures are silent,
the thing just never shows up. The installer is idempotent and prints `(already linked)` for
everything unchanged, so running both every time is the safe habit.
⚠ **Don't move or rename the checkout.** The installed symlinks and the absolute path in
`~/.config/hunk/config.toml` both point here. If you do move it, re-run `install.sh` from the new
location *and* fix that `paths` entry by hand, since the installer won't touch an existing
`[extensions]` section. Hunk says nothing when that path is wrong; you just get a flat file list,
the failure described in [the one thing to know](#the-one-thing-to-know-if-it-looks-broken).
## Usage
Start your agent **in the worktree holding the branch you want reviewed**. The skill plans the
changeset that session is sitting in, so the directory is the argument. Then invoke it:
```
/hunk-review # Claude Code
$hunk-review # Codex
```
Or ask in your own words. The skill's description is written to be matched, so any agent reading
`~/.agents/skills/` picks it up without the explicit name:
> build me a hunk review plan for this branch
It reports the changeset size, groups the files by topic, orders the groups by what you're most
likely to get wrong, annotates only the hunks that carry a decision, and hands you back two things:
a command to open the plan in Hunk, and a path to the HTML writeup.
The writeup is a self-contained local file. It opens with an overview of what the branch does
and why, written so a CTO could read it without the code. A TL;DR for the reviewer follows: the
biggest risk and where to look first. When the review runs in the session where the change was
asked for, a decisions block comes next. It walks through how the change runs, step by step,
and marks in bold anything that departs from your last request. It lists the assumptions made,
and what a fresh reader who never saw the conversation challenged. Any other session leaves the
block out, and the page says so. Below that sits a glossary of the page's terms, closed until
you need it. Every group then reads in the order a reviewer needs it: what the issue is and what
solves it, one concrete example, then the bullets. Around that, a verdict badge per group, a
collapsed block for the trade-offs, a Mermaid diagram where the substance is a sequence or a race,
and a table of contents. `Skim`-tier groups start collapsed, so the page opens showing only what
needs a decision.
Next to each verdict badge sits a door badge: `One-way` when reverting the commit would not undo
the change, `Two-way` when it would. A line under the group's summary names who is hit if it goes
wrong. The agent makes that call from the diff and the spec before it reads any merge-danger
verdict, so it is a second opinion. `hunk-plan write` warns when a one-way group is ranked `Skim`.
In a repo that keeps a merge-danger verdict (Vendis's `bin/merge-danger`), `hunk-plan danger` then
sets those calls next to the verdict. The TL;DR opens with the verdict and every place the review
disagrees. Each row shows both ratings in plain words, the agent's reason, and what you can do.
To keep merge-danger's rating, do nothing. To accept hunk-review's, say so in a note in Hunk; only
you can lower a rating. A disagreement you settle with a note becomes one
`bin/merge-danger --finalize` command, which whoever runs the review loop records. The TL;DR then
says whether a review record covers this content. When the repo's review gate left one, the TL;DR
also lists what it held for you, and a closed block lists what it waived or accepted as known. All
of it is advice: nothing here blocks a review, a write or an approval.
`bin/merge-danger` comes with the branch under review, so `hunk-plan` runs it only when you allowed
its exact content for that repo. Read the script, then allow it with
`git config --local --add hunk.mergeDanger <blob id>`; `hunk-plan danger` prints the id. A branch
that changes the script needs a new allow. A global setting does not count, and neither does `true`.
hunk-review checks this when it starts and tells you the steps, so you can allow it during the review.
The prose follows short-sentence rules taken from ASD-STE100: one fact per sentence, the small
words kept, the effect before the mechanism. `hunk-plan render` names every sentence over 25
words on stderr, so the agent rewrites them before you read the page.
Any ADR, RFC or runbook the branch leans on becomes **its own page inside that same file**,
converted from its copy on disk and reachable from a nav bar across the top. GitHub `#123`
references become links on their own, and every off-machine link opens in a new tab.
Those pages are not limited to markdown in this repo:
| The doc is | What you get |
|---|---|
| `.md` / `.markdown` | Converted, with YAML frontmatter lifted out as a field table |
| `.html` | Its `<body>` inlined and scrubbed. The `<style>` and `<script>` it carries are dropped, not applied to your report |
| anything else (`.rst`, `.adoc`, `.txt`) | Shown as its own text, never as guessed-at markup |
| outside the repo | `"path"` may be absolute or `~`-prefixed, so a sibling docs checkout or a shared vault works |
| not on this machine at all | `"url"` instead of `"path"` makes it an external nav link; Notion, Confluence and the rest link out rather than being fetched |
A citation can aim at a heading, not just a file: `#doc-adr-0001/consistency-model` opens the doc
*and* lands on that section, briefly marked so your eye finds it. A doc with enough headings gets
its own contents list, so a 600-line PRD stays navigable when the citation aimed elsewhere. A
heading that has since been renamed degrades to the top of the doc rather than going nowhere.
<p align="center">
<a href="docs/img/doc-page.png"><img src="docs/img/doc-page.png" height="360"
alt="A doc page inside the writeup: its own contents list built from its headings, and YAML frontmatter lifted out as a field table"></a>
</p>
Follow a citation, hit Back, and you land on the bullet you left rather than at the top of the
page. Position is remembered as *which block was at the top of the viewport*, not as a pixel
offset, so it survives resizing the window, zooming, or rotating a phone while the doc was open.
The agent writes only prose. The frame, nav, table of contents, section ids, badges and link rules
are all produced by `hunk-plan render` from the plan itself. The page therefore cannot disagree
with what Hunk shows you, and the agent cannot quietly reinvent the layout on its next run.
Then open Hunk on the same range the plan was built from. On a feature branch that's the base
branch, three dots, `HEAD`:
```sh
hunk diff origin/dev...HEAD # or origin/main, whatever you forked from
```
Three dots, not two. `..` also drags in everything that landed on the base branch since you forked,
which can multiply the diff several times over and fills the pane with files the plan says nothing
about.
### Keys the extensions add
`}` and `{` are Hunk's own, and jump between annotated hunks. Everything below is added by the two
extensions in this repo, so these keys exist only once `install.sh` has pointed Hunk at the
checkout. If they do nothing, the extensions did not load; see
[the one thing to know](#the-one-thing-to-know-if-it-looks-broken).
From [`review-plan`](extension/review-plan/README.md), which draws the grouped file list:
| Key | What it does |
|---|---|
| `V` | Mark the selected file reviewed, or unmark it. `alt+v` does the same |
| `Z` | Collapse or expand the whole group |
| `X` | Collapse or expand the selected file's diff |
| `>` | Jump to the next group |
| `<` | Jump to the previous group |
| `S` | Send the review back to the agent: pick how much it may change, add an optional instruction |
| `A` | Approve the review, which ends the agent's wait |
It also registers **Collapse fully reviewed groups**, which has no default key and runs from the
Extensions menu.
From [`copy-path`](extension/copy-path/README.md), for getting a path back out to an agent:
| Key | Copies |
|---|---|
| `Y` | The file path, repo-relative, with no dialog |
| `ctrl+y` | An `@path#L42` reference, ready to paste into an agent |
It also registers **Copy file path (choose form)**, which offers the same path as relative,
absolute, CWD-relative, filename, or reference. Like **Collapse fully reviewed groups**, it has no
default key and runs from the Extensions menu.
All of these sit in Hunk's shifted and punctuation key space on purpose. Hunk's own commands have
claimed 21 of the 26 lowercase letters, and 0.22.0 took three more in one release -- `v`, `y` and
`n`, which is precisely where these bindings used to live. A built-in wins a chord conflict and the
extension command is left unbound with only a startup notice to say so, which makes a lowercase
default a binding with a shelf life. Marking a file reviewed also ships a second chord, `alt+v`,
because Hunk drops only the conflicting chord and keeps the rest: the one command the extension
exists for stays reachable even if `V` is claimed later.
### Acting on the notes you left
Reviewing is a conversation, not a one-way read. Leave notes on lines in the live Hunk session as
you go, then hand them back to an agent in the same worktree:
```
/hunk-handle-notes # Claude Code
$hunk-handle-notes # Codex
```
Or ask in your own words:
> work through my hunk notes
The second skill reads your notes out of the running session and treats them by kind, rather than
as one undifferentiated queue:
| The note you left | What happens |
|---|---|
| A concrete change: "fix this", "handle the nil case" | It makes the change |
| A question: "why does this exist" | It answers it |
| A judgment call, or something ambiguous | It holds the note and brings the question back to you, rather than deciding for you |
| Out of scope: unrelated files, shell commands, credentials | It declines and says why |
Every note gets a reply written back into the session at the line you left it, so the answer sits
where you are already looking instead of only in the chat. Notes survive your own fixes moving the
line, because Hunk re-anchors them to the nearest surviving hunk. They do not survive quitting
Hunk, and one kind of reload loses the whole session; see
[keeping your notes](#keeping-your-notes).
If a writeup already exists, each outcome is appended to that group's notes file and the page is
re-rendered. The generated HTML is never edited in place, since the next render would overwrite it.
You don't have to start that by hand. `/hunk-review` ends by waiting for you (`hunk-plan wait`):
press `S` in Hunk when your notes are in, choose how much the agent may change (fix what's clear
and ask about the rest, explain only, or fix everything), and it works through them, re-renders
the writeup and waits again. Press `r` to see its changes, and `A` when you're done. Without the
extension, a note reading `GO` or `APPROVE` does the same. Once a new plan is written, a window
still showing the earlier one can't send or approve; it asks you to press `r` first, so an old
approval never lands on a new review.
When another skill runs `/hunk-review` as a sub-agent, the sub-agent only writes the plan and the
writeup and hands back the Hunk command. The session that started it does the waiting, because
nothing wakes a sub-agent once it has returned.
### Keeping your notes
Hunk keeps notes only in the running window
([modem-dev/hunk#113](https://github.com/modem-dev/hunk/issues/113)). Quit it and the window
loses them, your notes and the agent's replies alike. The `review-plan` extension keeps a copy:
it writes each note to a journal on disk the moment you save it, and records the agent's replies
and your deletions whenever you press one of its keys (`S`, `A`, `V`, `X`, `<`, `>`).
`hunk-plan notes` prints them back after Hunk is gone (`--json` for an agent, `--all` for earlier
plans too). Putting them back into a new Hunk window is not built yet.
A reload keeps the notes in the window, with one exception that costs you
the session: a reload that drops a file carrying one of the agent's replies disconnects the window
from Hunk's daemon ([modem-dev/hunk#1138](https://github.com/modem-dev/hunk/issues/1138); an open
fix is [#1131](https://github.com/modem-dev/hunk/pull/1131)). Your own notes don't trigger it. The
window looks fine and still shows every note, but the agent's next `hunk session` command fails
with `protocol-validation-failed`.
Three habits keep you clear of it while notes are open:
- **Open Hunk on the fork point.** The three-dot range above shows commits only, so the agent's
uncommitted fixes never appear in it. `hunk diff $(git merge-base origin/dev HEAD)` shows the
branch's commits *and* the uncommitted fixes, and a file stays in it after it is committed.
Plain `hunk diff` is the risky one: it shows only uncommitted changes, so the commit that lands
a fix drops the file the agent just replied on.
- **Leave `--watch` off and press `r` yourself** once the agent says it is done. `--watch`
reloads in the middle of the agent's edits, while a file it is reverting may still carry its
reply.
- **Read the replies before you quit.** The journal holds a reply only from the last time you
pressed one of the extension's keys, so the window is the only full copy.
A fix that reverts a whole file to its base content drops that file on any target. The notes
skill removes its own replies on such a file first and answers in the chat instead, which keeps
the session. If the session drops anyway, nothing reconnects that window. Hunk's status line
suggests `hunk daemon restart`, but the window stays detached afterwards (checked on 0.23.0).
Read what is on screen, quit, and open Hunk again. The agent keeps the notes it already read and
answers in the chat and the writeup.
## What's in here
| Path | What it is |
|---|---|
| `skills/hunk-review/` | Builds the plan and the HTML writeup. The main event. |
| `skills/hunk-handle-notes/` | Reads and acts on review notes you left in a live Hunk session. |
| `bin/hunk-plan` | CLI to write, read, convert, render, and clean up the plan file. |
| `assets/` | The writeup's frame: CSS, runtime JS, vendored `marked` and `mermaid`, and the two logo lockups. |
| `extension/review-plan/` | The Hunk extension that renders the plan as grouped, ordered review. |
| `extension/copy-path/` | A Hunk extension that copies the selected file's path, or an `@path#Lx` reference. |
| `test/render_test.sh` | Smoke test for the renderer and the state-directory cleanup. |
| `test/wait_test.sh` | Tests for the review loop's `hunk-plan wait`. |
| `test/notes_test.sh` | Tests for reading the notes journal back with `hunk-plan notes`. |
| `test/list_test.sh` | Tests for `hunk-plan list` and the head and branch `write` stamps. |
| `test/keymap_check.sh` | Fails when a new Hunk release claims a key one of the extensions binds. |
| `docs/img/` | The screenshots above. |
## Where the files go, and how they leave
Plans live outside your repos, in `${XDG_STATE_HOME:-~/.local/state}/hunk/review-plan/`, keyed by
a hash of the repo path. Each review leaves a `<digest>.json` plan, a `<digest>.report/` directory
of the agent's prose, the rendered `<digest>.html`, a `<digest>.loop/` directory for the
hand-off between Hunk and a waiting agent, and the `<digest>.notes.jsonl` notes journal, plus a
shared copy of the two vendored libraries. A new plan moves the earlier review's prose to
`<digest>.report.prev/`, so none of it shows up in the new writeup. `gc` removes a notes journal with no plan only once it
is as old as `--older-than`, since notes saved without a plan are still your notes.
`hunk-plan list` reads them back as one row per review, newest first, for the plan Hunk loads (a
repo's in-repo plan when it has one). A row shows the branch the plan was written on, how many
commits it moved since the plan, whether a Hunk window or session is open on it, your open notes,
the files you marked viewed whose diff has not changed since, and what you sent to the agent.
`--json` adds the door calls, the merge-danger verdict and the writeup, and `--here` keeps to the
current repo. A review whose worktree is gone still lists.
```sh
hunk-plan list # every review on this machine
hunk-plan clear # this repo's plan, report, rendered page and notes journal
hunk-plan gc --dry-run # everything collectable, machine-wide
hunk-plan gc # ...and remove it
```
`gc` collects three things: artifacts whose plan file is gone, plans whose **repo** is gone, and
plans older than 30 days (`--older-than <days>`). That second one needs a name, not a hash, so
`write` keeps a `repos.json` mapping each digest back to its repo root. Without it nothing could
ever tell whether `0e8a342967784894.viewed.json` still belonged to anything, which is exactly how
these directories used to silently accumulate. Neither command ever deletes as a side effect of a
write; a crowded directory only earns a one-line hint.
## The one thing to know if it looks broken
**A flat, ungrouped file list means the extension didn't load.** Hunk does not tell you this. It
quarantines a failed pane and silently restores the built-in files pane, while the extension's
non-React half keeps working. The plan file is written, the notes are injected, `V` still marks
files viewed, and it all looks healthy.
The cause is almost always the extension being reached **through a symlink**. Hunk serves `react`,
`@opentui/*` and `hunkdiff/extension` to an extension via a Bun `onLoad` plugin whose filter is
anchored at `dirname(entryPath)`, but Bun matches it against the file's **realpath**. Through a
symlink the two differ, the hook never fires, and the imports resolve as ordinary npm ones.
So: `[extensions] paths` must be an absolute path to the real checkout, with no symlinked
component. That is why `install.sh` points Hunk here instead of copying or linking the extension
anywhere.
Hunk 0.21.0 fixes this upstream (it registers both the literal directory and its canonical path).
Pointing at the real path works on every version, so the installer does that unconditionally.
## Contributing
Neither extension's dev dependencies are installed by `install.sh`. Hunk injects its own React and
OpenTUI at load time, so the runtime never needs them. For `review-plan` they are about 450 MB.
```sh
cd extension/review-plan # or extension/copy-path
bun install
bun test
bun run typecheck
```
The CLI and the renderer have no such dependency. Their tests are plain bash against a throwaway
repo and a throwaway `XDG_STATE_HOME`, so they never touch your real plan directory:
```sh
test/render_test.sh # frame, ordering, slugs, doc entries, gc, clear
CHROME=google-chrome-stable test/render_test.sh # ...plus the DOM behaviour
TEST_BASH=/bin/bash test/render_test.sh # ...under a pinned shell
test/keymap_check.sh # extension chords vs the newest Hunk release
```
The `CHROME` pass is the one that matters when touching `assets/report.js`: it asserts that
Markdown converts, that a `#123` inside a code fence is *not* linkified, that external links get
`target="_blank"`, that a `<script>` or a `<style>` in a converted doc is stripped, that hash
routing swaps panes, that doc headings get slugged ids and deep links land on them, and that
leaving a pane and returning **after a reflow** puts the same block back at the top of the
viewport.
`TEST_BASH` pins the interpreter `hunk-plan` runs under. It matters on macOS, where `/bin/bash` is
3.2 while `#!/usr/bin/env bash` finds Homebrew's bash 5 whenever one is installed. Without it, a
bash-4-ism passes locally and breaks for everyone on a stock Mac. CI runs the suite both ways on
`macos-latest` for exactly that reason.
`extension/review-plan/README.md` documents the plan JSON schema, the annotation model, key
bindings, and where plan and viewed state live on disk.
`extension/copy-path/README.md` documents its three commands, the path forms it offers, and how it
reaches the clipboard over SSH.
## Built by Vendis
<p align="center">
<a href="https://vendis.ai">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/img/vendis-logo-dark.png">
<img src="docs/img/vendis-logo.png" height="46" alt="Vendis">
</picture>
</a>
</p>
`hunk-review-skill` is built and maintained by [Vendis](https://vendis.ai), where it reviews our
own branches every day.
## License
MIT. See [LICENSE](LICENSE).
The Vendis name, logo and wordmark are trademarks of Vendis and are **not** covered by that
licence. Fork the code freely; swap `assets/vendis-logo*.svg` and the footer above for your own
mark if you redistribute a modified version.