hunk_review_changes 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE.txt +27 -0
- data/README.md +126 -0
- data/exe/hunk-review-changes +6 -0
- data/lib/hunk_review_changes/app.rb +138 -0
- data/lib/hunk_review_changes/assets.rb +34 -0
- data/lib/hunk_review_changes/bundle.rb +122 -0
- data/lib/hunk_review_changes/cli.rb +106 -0
- data/lib/hunk_review_changes/diff.rb +205 -0
- data/lib/hunk_review_changes/export.rb +84 -0
- data/lib/hunk_review_changes/installer/base.rb +39 -0
- data/lib/hunk_review_changes/installer/claude_code.rb +44 -0
- data/lib/hunk_review_changes/installer/codex.rb +21 -0
- data/lib/hunk_review_changes/installer/cursor.rb +22 -0
- data/lib/hunk_review_changes/installer/directory_installer.rb +36 -0
- data/lib/hunk_review_changes/installer/opencode.rb +27 -0
- data/lib/hunk_review_changes/installer/runner.rb +122 -0
- data/lib/hunk_review_changes/installer/skill_source.rb +53 -0
- data/lib/hunk_review_changes/lifecycle.rb +42 -0
- data/lib/hunk_review_changes/markdown.rb +69 -0
- data/lib/hunk_review_changes/public/app.css +237 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleMono.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleNext-Italic.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleNext.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/OFL.txt +98 -0
- data/lib/hunk_review_changes/server.rb +99 -0
- data/lib/hunk_review_changes/state.rb +99 -0
- data/lib/hunk_review_changes/version.rb +5 -0
- data/lib/hunk_review_changes/views/index.erb +395 -0
- data/lib/hunk_review_changes.rb +35 -0
- metadata +185 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f6091eebcab16183733cb278161b9d3087150f938521ab7b281e7418cf2a96bc
|
|
4
|
+
data.tar.gz: 707a53f52a4cb6b0aa347d2526a3fc87ea525eedd61a54ce67b533e50078530e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 125e911c129ac684d690d6a44e1f9de6bd0c9355cbb8a188cef9e93204cc1d66ecad2cc1dbd13cdb449442dd0baeed3a21cc74748846f92f8334c0de563144b2
|
|
7
|
+
data.tar.gz: 139a39da05e7972c9c32f63cbd6cc50bf65b4746e57ce6ded335b113a068dac557e188d8f7316b045da841f5a85425eeb4fda2ccddd9d52b34d70c76e9278361
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-08-03)
|
|
4
|
+
|
|
5
|
+
- Battle review: a bundle piece may now carry a `challenge` — an adversary's argument
|
|
6
|
+
against the change — and the bundle a top-level `adversary` naming who made it. The
|
|
7
|
+
viewer renders the challenge as its own voice, distinct from the author's what/why,
|
|
8
|
+
with a stance badge (`nitpick`, `concern`, `blocking`); a `concede` stance renders as
|
|
9
|
+
a quiet "no objection" line so you know the adversary looked.
|
|
10
|
+
- `export.md` carries the challenge for every piece you comment on or flag, so the agent
|
|
11
|
+
implementing your comment also sees the argument against it.
|
|
12
|
+
- Both fields are optional. A bundle written without them renders and exports exactly as
|
|
13
|
+
before.
|
|
14
|
+
|
|
15
|
+
## 0.1.0 (2026-07-20)
|
|
16
|
+
|
|
17
|
+
- First release as a gem. Packages the browser review UI, all Ruby code, prebuilt CSS,
|
|
18
|
+
and the bundled Atkinson Hyperlegible fonts.
|
|
19
|
+
- `hunk-review-changes <bundle.json>` serves the review UI; `--port` and `--no-open`
|
|
20
|
+
flags supported.
|
|
21
|
+
- `hunk-review-changes install` installs the companion skill into Claude Code (via its
|
|
22
|
+
plugin marketplace CLI) and Codex, Cursor, and OpenCode (by copying the skill into
|
|
23
|
+
the directory each scans).
|
|
24
|
+
- UI overhaul: Atkinson Hyperlegible Next / Mono fonts, wider layout with 120-column
|
|
25
|
+
diffs, dark mode that follows the OS setting, word-level diff highlighting, an
|
|
26
|
+
explicit per-hunk "Looks good" reviewed state, and `j`/`k`/`c`/`g`/`?` keyboard
|
|
27
|
+
navigation.
|
|
28
|
+
- Bundles are validated on launch with actionable error messages.
|
|
29
|
+
- Fix comment loss on exit: a comment typed within the 500ms autosave window is now
|
|
30
|
+
flushed before Done writes the export, and persisted with `navigator.sendBeacon`
|
|
31
|
+
when the tab is closed.
|
|
32
|
+
- Open the browser on Linux and Windows too (`xdg-open` / `start`), not just macOS,
|
|
33
|
+
and warn with the review URL instead of failing silently when no browser launches.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucian Ghinda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The bundled fonts in lib/hunk_review_changes/public/fonts (Atkinson Hyperlegible
|
|
26
|
+
Next and Atkinson Hyperlegible Mono) are licensed under the SIL Open Font License,
|
|
27
|
+
Version 1.1. See lib/hunk_review_changes/public/fonts/OFL.txt for the full text.
|
data/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# hunk_review_changes
|
|
2
|
+
|
|
3
|
+
Review a diff hunk-by-hunk in your browser, then hand the comments back to your AI coding agent.
|
|
4
|
+
|
|
5
|
+
Your agent groups a diff into pieces — each a hunk with a plain-language what/why — and launches a local web app. You read the highlighted hunks and comment on each at your own pace. No model tokens are spent while you review. Click **Done** and the agent picks up your comments and implements them.
|
|
6
|
+
|
|
7
|
+
Works with Claude Code, Codex, Cursor, and OpenCode.
|
|
8
|
+
|
|
9
|
+
[](https://github.com/lucianghinda/hunk-review-changes/actions/workflows/ci.yml)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install the gem:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
gem install hunk_review_changes
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then install the companion skill into your agents:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
hunk-review-changes install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It asks which agents to set up and installs the skill for each.
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
1. You ask your agent to review a change — a branch, PR, commit, or your working tree.
|
|
30
|
+
2. The skill resolves the target, groups it into hunks, and writes a `bundle.json`.
|
|
31
|
+
3. It runs `hunk-review-changes bundle.json`, which opens the review UI in your browser.
|
|
32
|
+
4. For each hunk you leave a comment, mark it **Looks good**, or **Flag for discussion**.
|
|
33
|
+
5. You click **Done**. The app writes `export.md` and exits.
|
|
34
|
+
6. Your agent reads the export and implements every requested change.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
Serve a bundle:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
hunk-review-changes bundle.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Options:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
hunk-review-changes bundle.json --port 4321 # bind a specific port
|
|
48
|
+
hunk-review-changes bundle.json --no-open # do not open the browser
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Install the skill for specific agents without the prompt:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
hunk-review-changes install --agent claude,codex,cursor,opencode
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## The review UI
|
|
58
|
+
|
|
59
|
+
- **120-column diffs** with syntax highlighting, in [Atkinson Hyperlegible](https://www.brailleinstitute.org/freefont/) fonts.
|
|
60
|
+
- **Word-level highlighting** shows exactly what changed within a modified line.
|
|
61
|
+
- **Dark mode** follows your operating system setting.
|
|
62
|
+
- **Keyboard navigation** — `j`/`k` to move between hunks, `c` to comment, `g` to mark Looks good, `?` for help.
|
|
63
|
+
- **Reviewed state** — every hunk tracks whether you have seen it, so you know what is left.
|
|
64
|
+
|
|
65
|
+
Your review persists as you go, so closing the tab loses nothing. Re-running the same bundle resumes it.
|
|
66
|
+
|
|
67
|
+
## Battle review
|
|
68
|
+
|
|
69
|
+
Every explanation on a hunk is written by the agent that wrote the change, so it argues
|
|
70
|
+
its own case. Ask for a **battle review** and a second agent argues the other side.
|
|
71
|
+
|
|
72
|
+
Say "battle review this branch" (or "challenge these changes") instead of "review this
|
|
73
|
+
branch". The skill runs one pass of Codex over the whole diff, asking it to make the
|
|
74
|
+
strongest case for why each hunk should not be changed at all — or not this way. Each
|
|
75
|
+
piece then shows two voices: the author's what/why, and the adversary's pushback marked
|
|
76
|
+
`nitpick`, `concern`, or `blocking`. Where the adversary has nothing to say, it concedes,
|
|
77
|
+
and the hunk says so in one line — so you always know it looked.
|
|
78
|
+
|
|
79
|
+
The pushback travels into `export.md` for the pieces you comment on, so the agent
|
|
80
|
+
implementing your comment sees the argument against it too.
|
|
81
|
+
|
|
82
|
+
Battle review is opt-in and needs the [Codex CLI](https://github.com/openai/codex)
|
|
83
|
+
installed. If Codex is missing or fails, you get the plain review and an explanation —
|
|
84
|
+
it never blocks. Since the adversary has to be independent of the author, the mode is
|
|
85
|
+
unavailable when Codex is the agent running the review.
|
|
86
|
+
|
|
87
|
+
The gem itself never calls Codex: your agent does the adversarial pass before launching,
|
|
88
|
+
so no model tokens are spent while you read.
|
|
89
|
+
|
|
90
|
+
## Skills marketplace
|
|
91
|
+
|
|
92
|
+
The skill lives in its own repo, [hunk-review-changes-skills](https://github.com/lucianghinda/hunk-review-changes-skills), a marketplace that serves every supported agent. `hunk-review-changes install` uses it: it calls the Claude Code plugin CLI, and copies the skill into the directory Codex, Cursor, and OpenCode scan.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
Get the dependencies:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
bundle install
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Run the tests and linter:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
bundle exec rake
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Rebuild the stylesheet after changing `assets/tailwind.css` or the fonts:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
bundle exec rake css
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Release a new version with:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
bundle exec rake release
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Contributing
|
|
121
|
+
|
|
122
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/lucianghinda/hunk-review-changes).
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
The gem is available as open source under the [MIT License](LICENSE.txt). The bundled Atkinson Hyperlegible fonts are licensed under the [SIL Open Font License](lib/hunk_review_changes/public/fonts/OFL.txt).
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "json"
|
|
6
|
+
require "cgi"
|
|
7
|
+
|
|
8
|
+
require_relative "markdown"
|
|
9
|
+
require_relative "diff"
|
|
10
|
+
require_relative "state"
|
|
11
|
+
require_relative "export"
|
|
12
|
+
require_relative "lifecycle"
|
|
13
|
+
|
|
14
|
+
module HunkReviewChanges
|
|
15
|
+
# The Sinatra app that serves the review UI and collects per-piece comments. It is
|
|
16
|
+
# configured per run through Sinatra settings so tests can drive it with rack-test
|
|
17
|
+
# against a fixture bundle and a shutdown stub, instead of a real port and exit!.
|
|
18
|
+
class App < Sinatra::Base
|
|
19
|
+
set :views, File.expand_path("views", __dir__)
|
|
20
|
+
set :public_folder, File.expand_path("public", __dir__)
|
|
21
|
+
set :bind, "127.0.0.1"
|
|
22
|
+
set :logging, false
|
|
23
|
+
enable :static
|
|
24
|
+
|
|
25
|
+
# The server only ever binds to loopback, so restrict host authorization to
|
|
26
|
+
# loopback hosts (guards against DNS-rebinding without depending on the Sinatra
|
|
27
|
+
# environment, whose default only applies the check in development).
|
|
28
|
+
set :host_authorization, {
|
|
29
|
+
permitted_hosts: ["localhost", ".localhost", IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Configured by the server (or a test) before the app handles a request.
|
|
33
|
+
set :bundle, nil
|
|
34
|
+
set :state_path, nil
|
|
35
|
+
set :export_path, nil
|
|
36
|
+
set :lifecycle, nil
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# The shutdown callback is stored outside Sinatra settings on purpose: a setting
|
|
40
|
+
# whose value responds to #call is auto-invoked when read, which would fire the
|
|
41
|
+
# shutdown the moment a route touched it. A plain accessor holds the callable.
|
|
42
|
+
attr_accessor :on_done
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
helpers do
|
|
46
|
+
def bundle = settings.bundle
|
|
47
|
+
def state = State.new(settings.state_path, bundle_id: bundle.fingerprint)
|
|
48
|
+
# Full HTML-entity escaping (incl. " and ') so values stay safe inside
|
|
49
|
+
# double-quoted attributes like title="..." and data-nav="...", not only
|
|
50
|
+
# in text nodes.
|
|
51
|
+
def esc(str) = CGI.escapeHTML(str.to_s)
|
|
52
|
+
|
|
53
|
+
# The adversary's rebuttal, ready for the view, or nil when the piece was never
|
|
54
|
+
# challenged. Its argument goes through the same Markdown pipeline as what/why,
|
|
55
|
+
# so it gets the same URL sanitizing — the adversary's prose is bundle input like
|
|
56
|
+
# any other, not a trusted source.
|
|
57
|
+
def challenge_view(challenge)
|
|
58
|
+
return nil unless challenge.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
{ stance: challenge["stance"].to_s, argument: Markdown.inline(challenge["argument"]) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
get "/" do
|
|
65
|
+
review_state = state
|
|
66
|
+
total = bundle.pieces.size
|
|
67
|
+
@target = bundle.target
|
|
68
|
+
@resolved_by = bundle.resolved_by
|
|
69
|
+
@framing = Markdown.inline(bundle.framing)
|
|
70
|
+
@adversary = bundle.adversary
|
|
71
|
+
@pieces = bundle.pieces.map do |piece|
|
|
72
|
+
entry = review_state[piece["id"]]
|
|
73
|
+
{
|
|
74
|
+
id: piece["id"],
|
|
75
|
+
n: "#{piece["id"]} of #{total}",
|
|
76
|
+
file: piece["file"],
|
|
77
|
+
label: piece["label"],
|
|
78
|
+
kind: piece["kind"] || "code",
|
|
79
|
+
what: Markdown.inline(piece["what"]),
|
|
80
|
+
why: Markdown.inline(piece["why"]),
|
|
81
|
+
flags: Array(piece["flags"]).map { |flag| Markdown.inline(flag) },
|
|
82
|
+
comment: entry["comment"].to_s,
|
|
83
|
+
flagged: !!entry["flag"],
|
|
84
|
+
reviewed: !!entry["reviewed"],
|
|
85
|
+
status: State.status_for(entry),
|
|
86
|
+
challenge: challenge_view(piece["challenge"]),
|
|
87
|
+
diff: Diff.to_html(piece["diff"], piece["file"])
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
# Provenance only earns its line when a rebuttal actually survived to the page:
|
|
91
|
+
# naming the adversary above a page with no challenges would overstate what ran.
|
|
92
|
+
@adversary = nil unless @pieces.any? { |piece| piece[:challenge] }
|
|
93
|
+
erb :index
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
post "/pieces/:id" do
|
|
97
|
+
content_type :json
|
|
98
|
+
body = parse_body
|
|
99
|
+
status = state.update(
|
|
100
|
+
params["id"],
|
|
101
|
+
comment: body["comment"],
|
|
102
|
+
flag: body["flag"],
|
|
103
|
+
reviewed: body["reviewed"]
|
|
104
|
+
)
|
|
105
|
+
{ ok: true, status: status }.to_json
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
get "/export.md" do
|
|
109
|
+
content_type "text/plain"
|
|
110
|
+
Export.new(bundle, state).to_markdown
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
post "/done" do
|
|
114
|
+
content_type :json
|
|
115
|
+
File.write(settings.export_path, Export.new(bundle, state).to_markdown)
|
|
116
|
+
self.class.on_done&.call
|
|
117
|
+
{ ok: true, export: settings.export_path }.to_json
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
post "/heartbeat" do
|
|
121
|
+
settings.lifecycle&.heartbeat!
|
|
122
|
+
204
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
post "/bye" do
|
|
126
|
+
settings.lifecycle&.bye!
|
|
127
|
+
204
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def parse_body
|
|
133
|
+
JSON.parse(request.body.read)
|
|
134
|
+
rescue StandardError
|
|
135
|
+
{}
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rouge"
|
|
4
|
+
|
|
5
|
+
module HunkReviewChanges
|
|
6
|
+
# Generates the syntax-highlighting CSS shipped in app.css. Kept out of the request
|
|
7
|
+
# path: `rake css` renders it once at build time so the running server only reads a
|
|
8
|
+
# static file. Two themes are emitted under the same `.highlight` scope; the dark one
|
|
9
|
+
# is wrapped in a prefers-color-scheme media query so the page follows the OS setting
|
|
10
|
+
# with no toggle and no JavaScript.
|
|
11
|
+
module Assets
|
|
12
|
+
LIGHT_THEME = "github"
|
|
13
|
+
DARK_THEME = "github.dark"
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def rouge_css
|
|
18
|
+
light = Rouge::Theme.find(LIGHT_THEME).new(scope: ".highlight").render
|
|
19
|
+
dark = Rouge::Theme.find(DARK_THEME).new(scope: ".highlight").render
|
|
20
|
+
<<~CSS
|
|
21
|
+
/* Syntax highlighting — generated by `rake css` from Rouge themes. */
|
|
22
|
+
#{light}
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
#{indent(dark)}
|
|
25
|
+
}
|
|
26
|
+
CSS
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def indent(text, spaces = 2)
|
|
30
|
+
pad = " " * spaces
|
|
31
|
+
text.each_line.map { |line| line.strip.empty? ? line : "#{pad}#{line}" }.join
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module HunkReviewChanges
|
|
7
|
+
# Loads and validates a bundle.json written by the companion skill. Validation
|
|
8
|
+
# exists to catch the exact failure modes the skill warns about (a malformed diff,
|
|
9
|
+
# missing pieces, non-sequential ids) and report them with a message the agent that
|
|
10
|
+
# wrote the bundle can act on, instead of a blank or broken page.
|
|
11
|
+
class Bundle
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The adversarial stances a piece's challenge may take, weakest to strongest.
|
|
16
|
+
# Shared so the validator, the viewer's badge map, and the export all agree on
|
|
17
|
+
# the same four strings instead of each carrying its own copy.
|
|
18
|
+
STANCES = %w[concede nitpick concern blocking].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :path, :data
|
|
21
|
+
|
|
22
|
+
def self.load(path)
|
|
23
|
+
new(path).tap(&:validate!)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(path)
|
|
27
|
+
@path = path
|
|
28
|
+
raise Error, "no such bundle: #{path}" unless File.exist?(path)
|
|
29
|
+
|
|
30
|
+
@data = JSON.parse(File.read(path))
|
|
31
|
+
rescue JSON::ParserError => e
|
|
32
|
+
raise Error, "bundle is not valid JSON (#{e.message}). " \
|
|
33
|
+
"Write it with real JSON — no trailing commas or comments."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def target = data["target"]
|
|
37
|
+
def resolved_by = data["resolved_by"]
|
|
38
|
+
def framing = data["framing"]
|
|
39
|
+
def pieces = data["pieces"]
|
|
40
|
+
def adversary = data["adversary"]
|
|
41
|
+
def dir = File.dirname(File.expand_path(path))
|
|
42
|
+
|
|
43
|
+
# Stable identity for this bundle's content, used to scope persisted review
|
|
44
|
+
# state so a reused directory never replays another bundle's comments.
|
|
45
|
+
def fingerprint = Digest::SHA256.hexdigest(JSON.generate(data))
|
|
46
|
+
|
|
47
|
+
def validate!
|
|
48
|
+
raise Error, "bundle must be a JSON object with a \"pieces\" array" unless data.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
list = data["pieces"]
|
|
51
|
+
raise Error, "bundle has no \"pieces\" array" unless list.is_a?(Array)
|
|
52
|
+
raise Error, "bundle \"pieces\" array is empty — nothing to review" if list.empty?
|
|
53
|
+
|
|
54
|
+
list.each_with_index { |piece, index| validate_piece!(piece, index) }
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_piece!(piece, index)
|
|
61
|
+
position = index + 1
|
|
62
|
+
raise Error, "piece ##{position} must be a JSON object" unless piece.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
expected_id = index + 1
|
|
65
|
+
unless piece["id"] == expected_id
|
|
66
|
+
raise Error, "piece ##{position} has id #{piece["id"].inspect}; ids must be " \
|
|
67
|
+
"1-based and sequential (expected #{expected_id})"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
%w[file label].each do |field|
|
|
71
|
+
if piece[field].to_s.strip.empty?
|
|
72
|
+
raise Error, "piece #{expected_id} is missing a non-empty \"#{field}\""
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
validate_diff!(piece, expected_id)
|
|
77
|
+
validate_challenge!(piece, expected_id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A challenge is optional: a bundle written without an adversarial pass has none,
|
|
81
|
+
# and must stay valid. When one is present it has to be usable by the viewer, so
|
|
82
|
+
# the stance must be a known one (it selects a badge) and a piece that claims an
|
|
83
|
+
# objection must actually state it.
|
|
84
|
+
def validate_challenge!(piece, id)
|
|
85
|
+
challenge = piece["challenge"]
|
|
86
|
+
return if challenge.nil?
|
|
87
|
+
|
|
88
|
+
unless challenge.is_a?(Hash)
|
|
89
|
+
raise Error, "piece #{id} \"challenge\" must be a JSON object with \"stance\" and " \
|
|
90
|
+
"\"argument\" keys"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
stance = challenge["stance"]
|
|
94
|
+
unless STANCES.include?(stance)
|
|
95
|
+
raise Error, "piece #{id} \"challenge\" has stance #{stance.inspect}; expected one of " \
|
|
96
|
+
"#{STANCES.join(", ")}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return if stance == "concede" || !challenge["argument"].to_s.strip.empty?
|
|
100
|
+
|
|
101
|
+
raise Error, "piece #{id} \"challenge\" has stance \"#{stance}\" but an empty " \
|
|
102
|
+
"\"argument\" — state the objection, or use stance \"concede\""
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_diff!(piece, id)
|
|
106
|
+
diff = piece["diff"].to_s
|
|
107
|
+
if diff.strip.empty?
|
|
108
|
+
raise Error, "piece #{id} has an empty \"diff\" — copy a real unified-diff hunk from git"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unless diff.include?("@@")
|
|
112
|
+
raise Error, "piece #{id} \"diff\" has no @@ hunk header — copy the whole hunk " \
|
|
113
|
+
"including the @@ ... @@ line so line numbers can render"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return unless diff.include?('\n') && !diff.include?("\n")
|
|
117
|
+
|
|
118
|
+
raise Error, "piece #{id} \"diff\" looks escaped (literal \\n, no real newlines) — " \
|
|
119
|
+
"the diff must contain actual newlines, not \\n sequences"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "bundle"
|
|
7
|
+
|
|
8
|
+
module HunkReviewChanges
|
|
9
|
+
# Command-line entry point. Two commands:
|
|
10
|
+
#
|
|
11
|
+
# hunk-review-changes <bundle.json> [--port N] [--no-open] serve the review UI
|
|
12
|
+
# hunk-review-changes install [--agent ...] [--marketplace-repo ...]
|
|
13
|
+
#
|
|
14
|
+
# `serve` is the default, so a bare bundle path just works (that is how the skill
|
|
15
|
+
# launches it).
|
|
16
|
+
class CLI
|
|
17
|
+
class Error < StandardError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.start(argv)
|
|
21
|
+
new.run(argv)
|
|
22
|
+
rescue Bundle::Error, Error, OptionParser::ParseError => e
|
|
23
|
+
warn "hunk-review-changes: #{e.message}"
|
|
24
|
+
1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(argv)
|
|
28
|
+
argv = argv.dup
|
|
29
|
+
case argv.first
|
|
30
|
+
when "install" then install(argv.drop(1))
|
|
31
|
+
when "-v", "--version" then print_version
|
|
32
|
+
when "-h", "--help", nil then print_help
|
|
33
|
+
else serve(argv)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def serve(argv)
|
|
40
|
+
options = { open: open_by_default? }
|
|
41
|
+
parser = OptionParser.new do |o|
|
|
42
|
+
o.banner = "Usage: hunk-review-changes <bundle.json> [--port N] [--no-open]"
|
|
43
|
+
o.on("--port N", Integer, "Port to bind (default: a free port)") { |n| options[:port] = n }
|
|
44
|
+
o.on("--no-open", "Do not open the browser automatically") { options[:open] = false }
|
|
45
|
+
end
|
|
46
|
+
rest = parser.parse(argv)
|
|
47
|
+
path = rest.first
|
|
48
|
+
raise Error, "no bundle given\n#{parser}" unless path
|
|
49
|
+
|
|
50
|
+
require_relative "server"
|
|
51
|
+
bundle = Bundle.load(path)
|
|
52
|
+
Server.new(bundle, port: options[:port], open: options[:open]).run
|
|
53
|
+
0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def install(argv)
|
|
57
|
+
options = { repo: MARKETPLACE_REPO }
|
|
58
|
+
parser = OptionParser.new do |o|
|
|
59
|
+
o.banner = "Usage: hunk-review-changes install [--agent claude,codex,...] " \
|
|
60
|
+
"[--marketplace-repo URL_OR_PATH]"
|
|
61
|
+
o.on("--agent LIST", Array, "Agents to install for (skips the prompt); or 'all'") do |list|
|
|
62
|
+
options[:only] = list
|
|
63
|
+
end
|
|
64
|
+
o.on("--marketplace-repo REPO", "Marketplace repo URL or local path") do |repo|
|
|
65
|
+
options[:repo] = repo
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
parser.parse(argv)
|
|
69
|
+
|
|
70
|
+
require_relative "installer/runner"
|
|
71
|
+
results = Installer::Runner.new(repo: options[:repo], only: options[:only]).run
|
|
72
|
+
results.all?(&:ok) ? 0 : 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def open_by_default?
|
|
76
|
+
ENV["HUNK_REVIEW_CHANGES_NO_OPEN"] != "1"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def print_version
|
|
80
|
+
puts "hunk-review-changes #{VERSION}"
|
|
81
|
+
0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def print_help
|
|
85
|
+
puts <<~HELP
|
|
86
|
+
hunk-review-changes #{VERSION}
|
|
87
|
+
|
|
88
|
+
Review a diff hunk-by-hunk in your browser, then hand the comments back to
|
|
89
|
+
your AI coding agent.
|
|
90
|
+
|
|
91
|
+
Usage:
|
|
92
|
+
hunk-review-changes <bundle.json> [--port N] [--no-open]
|
|
93
|
+
Serve the review UI for a bundle written by the companion skill.
|
|
94
|
+
|
|
95
|
+
hunk-review-changes install [--agent claude,codex,cursor,opencode]
|
|
96
|
+
[--marketplace-repo URL_OR_PATH]
|
|
97
|
+
Install the companion skill into your AI agents. With no --agent flag it
|
|
98
|
+
asks which agents to install for.
|
|
99
|
+
|
|
100
|
+
hunk-review-changes --version
|
|
101
|
+
hunk-review-changes --help
|
|
102
|
+
HELP
|
|
103
|
+
0
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|