agent-feedbacker 0.1.0 → 0.3.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +32 -12
- package/bin/feedbacker.mjs +20 -3
- package/hooks/hooks.json +13 -0
- package/package.json +2 -1
- package/skills/review/SKILL.md +6 -6
- package/src/baseline.mjs +122 -0
- package/src/core.mjs +112 -15
- package/src/dist/app.css +1 -1
- package/src/dist/app.js +15 -14
- package/src/mcp.mjs +25 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedbacker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Review an agent's changes across several git repos in a local MR-style page and send line comments back to it.",
|
|
5
5
|
"author": { "name": "Pavel Polivka" },
|
|
6
6
|
"repository": "https://github.com/PavlikPolivka/feedbacker",
|
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ This gives Claude:
|
|
|
22
22
|
|
|
23
23
|
- the MCP tool `request_review` (it shows up as `mcp__plugin_feedbacker_feedbacker__request_review`)
|
|
24
24
|
- the skill `/feedbacker:review`, which tells Claude when and how to ask for a review
|
|
25
|
+
- hooks for the **session baseline**: each repo's state is recorded when the session first touches it (at session start for the current repo, and before the first edit in any other repo). When Claude asks for a review without a base, the review shows exactly what changed in this session, even if Claude committed along the way, and repos Claude changed but forgot to pass are added. Uncommitted work from before the session is left out, because the baseline is a snapshot of it (`git stash create`, which changes neither your working tree nor your stash list). New untracked files from before the session still show up.
|
|
25
26
|
|
|
26
27
|
Then just say "let me review it" or run `/feedbacker:review`. To try it from a checkout without installing: `claude --plugin-dir /path/to/feedbacker`.
|
|
27
28
|
|
|
@@ -47,8 +48,9 @@ The npm package is `agent-feedbacker`; the command is `feedbacker`. Node 22 or n
|
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
50
|
feedbacker ~/Projects/api ~/Projects/web@main \
|
|
50
|
-
-m
|
|
51
|
-
-
|
|
51
|
+
-m $'Bind the API to localhost.\n\n- New `loadConfig()` reads HOST and PORT\n- README documents the port' \
|
|
52
|
+
-f 'src/server.ts:5-12=Where the host is bound' \
|
|
53
|
+
-f 'src/config.ts=Defaults live here'
|
|
52
54
|
```
|
|
53
55
|
|
|
54
56
|
- `repo` diffs the working tree (staged, unstaged and untracked files) against `HEAD`.
|
|
@@ -64,7 +66,11 @@ feedbacker ~/Projects/api ~/Projects/web@main \
|
|
|
64
66
|
|
|
65
67
|
In the browser:
|
|
66
68
|
|
|
67
|
-
-
|
|
69
|
+
- The agent's summary is short markdown, with a lead sentence and a list. Under it, **Where to look** lists the places the agent thinks matter most; each links to the file, and the agent's note also appears in the diff above those lines.
|
|
70
|
+
- Files are sorted by kind: the agent's focus first, then **Code**, then **Tests**, **Config**, **Docs** and **Generated** (lock files, build output, minified and binary files). The kind comes from the path and works across languages (`*.spec.ts`, `*_test.go`, `test_*.py`, `FooTest.java`, `__tests__/`, `CLAUDE.md`, …). Only Code starts expanded; the chips above the diffs open or close a whole kind.
|
|
71
|
+
- In changed lines, the changed words are marked.
|
|
72
|
+
- Tick **Viewed** on a file when you're done with it: it collapses, dims in the file tree, and the top bar counts how many you've viewed.
|
|
73
|
+
- Click a line number to comment on that line (a highlighter "+" shows where); shift-click a second one to cover a range. Then **Comment** (something to change) or **Ask** (a question the agent must answer). Ctrl+Enter comments, Ctrl+Shift+Enter asks, Esc cancels.
|
|
68
74
|
- With several repos, the picker in the top bar switches between all of them and a single one (the summary, questions and your comments in other repos stay). The top bar also shows the size of the change and keeps **Approve** and **Request changes** in reach; files can be collapsed, and the file tree shows where your comments are.
|
|
69
75
|
- Comments, answers and the general comment are kept in the browser's local storage until you submit, so a refresh loses nothing.
|
|
70
76
|
- **Approve** or **Request changes** sends everything to the agent and closes the server.
|
|
@@ -75,9 +81,10 @@ In the browser:
|
|
|
75
81
|
|
|
76
82
|
| Argument | Type | |
|
|
77
83
|
|---|---|---|
|
|
78
|
-
| `repos` | `[{ path, base?, worktrees? }]` | Required. Absolute paths; `base` defaults to `HEAD
|
|
79
|
-
| `summary` | string |
|
|
80
|
-
| `
|
|
84
|
+
| `repos` | `[{ path, base?, worktrees? }]` | Required. Absolute paths; `base` defaults to `HEAD` (or, with the plugin's hooks, where the repo stood when the session started); `worktrees: true` adds all worktrees of that repo. (`exact` and `session` are set by the hook.) |
|
|
85
|
+
| `summary` | string | Markdown: paragraphs, `- ` lists, `` `code` ``, `**bold**`, `### ` subheadings. The first paragraph is the lead. |
|
|
86
|
+
| `focus` | `[{ path, note, startLine?, endLine?, repo? }]` | Where the user should look first, and why. `path` is relative to its repo or absolute. |
|
|
87
|
+
| `questions` | string[] | Only for a decision the agent can't make alone; usually left out. |
|
|
81
88
|
|
|
82
89
|
It blocks until the user submits (Claude Code keeps long MCP calls open; it sends progress notifications every 30 s for clients that need them) and returns markdown like:
|
|
83
90
|
|
|
@@ -86,15 +93,27 @@ It blocks until the user submits (Claude Code keeps long MCP calls open; it send
|
|
|
86
93
|
|
|
87
94
|
Verdict: **changes requested**
|
|
88
95
|
|
|
96
|
+
Reviewed:
|
|
97
|
+
|
|
98
|
+
- /home/me/Projects/api (feature/config): 2 files changed (src/server.ts, src/config.ts), changes since the session started
|
|
99
|
+
|
|
89
100
|
## General
|
|
90
101
|
|
|
91
102
|
Almost there.
|
|
92
103
|
|
|
93
|
-
##
|
|
104
|
+
## Questions from the reviewer
|
|
105
|
+
|
|
106
|
+
The reviewer asks about this code. Answer each one in your reply. Change code only where a question shows something is wrong, and say so.
|
|
107
|
+
|
|
108
|
+
### /home/me/Projects/api
|
|
109
|
+
|
|
110
|
+
**src/config.ts:4** (new side)
|
|
94
111
|
|
|
95
|
-
|
|
112
|
+
```diff
|
|
113
|
+
+ host: env.HOST ?? "127.0.0.1",
|
|
114
|
+
```
|
|
96
115
|
|
|
97
|
-
|
|
116
|
+
Why not 0.0.0.0 inside containers?
|
|
98
117
|
|
|
99
118
|
## Comments
|
|
100
119
|
|
|
@@ -109,7 +128,7 @@ No, env is enough.
|
|
|
109
128
|
Make the host configurable too.
|
|
110
129
|
````
|
|
111
130
|
|
|
112
|
-
Each comment carries the code it refers to, so line numbers drifting doesn't lose the context. Cancelling the tool call closes the review page.
|
|
131
|
+
The result starts with what was reviewed (each repo, its files and base), so the agent knows what the verdict covers. Each comment carries the code it refers to, so line numbers drifting doesn't lose the context. Cancelling the tool call closes the review page.
|
|
113
132
|
|
|
114
133
|
## How it works
|
|
115
134
|
|
|
@@ -126,7 +145,8 @@ feedbacker ──markdown (tool result / stdout)──▶ agent
|
|
|
126
145
|
| `src/ui/` | The review page in React 19 (JSX): diff table, inline comments, questions, submit; highlight.js for syntax, `styles.css` for the look (light and dark, colour tokens at the top). |
|
|
127
146
|
| `src/dist/` | The committed bundle (JS, CSS and fonts), built from `src/ui/` by `build.mjs` (esbuild). |
|
|
128
147
|
| `src/mcp.mjs` | Minimal MCP server over stdio (JSON-RPC), one tool. |
|
|
129
|
-
| `
|
|
148
|
+
| `src/baseline.mjs`, `hooks/hooks.json` | Session baseline: `feedbacker hook` records repos (SessionStart, PreToolUse on edits) and fills in bases for `request_review` (PreToolUse, via `updatedInput`). Stored in `$XDG_STATE_HOME/feedbacker/sessions/`, forgotten after 30 days. |
|
|
149
|
+
| `bin/feedbacker.mjs` | CLI: review, `list`, `mcp`, `hook`. |
|
|
130
150
|
| `skills/review/SKILL.md`, `.mcp.json`, `.claude-plugin/` | The Claude Code plugin and its marketplace entry. |
|
|
131
151
|
|
|
132
152
|
Open reviews write a small file (URL, repos, pid) to `$XDG_STATE_HOME/feedbacker/` (default `~/.local/state/feedbacker/`, mode 0600) and remove it when they finish; `list` reads them.
|
|
@@ -149,7 +169,7 @@ CI runs the tests on Node 22 and 24 for every push and pull request (`.github/wo
|
|
|
149
169
|
|
|
150
170
|
Publishing to npm is done by GitHub Actions (`.github/workflows/publish.yml`) with npm trusted publishing, so no npm token is stored anywhere and each version gets a provenance statement.
|
|
151
171
|
|
|
152
|
-
1. Bump `version` in `package.json`
|
|
172
|
+
1. Bump `version` in `package.json` and `.claude-plugin/plugin.json` (the MCP server reads it from `package.json`), commit, push.
|
|
153
173
|
2. Create a GitHub release with the tag `vX.Y.Z` matching that version: `gh release create v0.2.0 --generate-notes`.
|
|
154
174
|
3. The workflow checks the tag, runs the tests and publishes.
|
|
155
175
|
|
package/bin/feedbacker.mjs
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { buildReview, hasChanges, listReviews, startReview, toMarkdown } from '../src/core.mjs';
|
|
6
6
|
import { runMcp } from '../src/mcp.mjs';
|
|
7
|
+
import { runHook } from '../src/baseline.mjs';
|
|
7
8
|
|
|
8
9
|
const USAGE = `Usage: feedbacker [options] [repo[@base] ...]
|
|
9
10
|
feedbacker list
|
|
10
11
|
feedbacker mcp
|
|
12
|
+
feedbacker hook
|
|
11
13
|
|
|
12
14
|
Opens a review page for the changes in each repo (default: current directory)
|
|
13
15
|
and prints the feedback to stdout when the user submits.
|
|
@@ -17,22 +19,34 @@ and prints the feedback to stdout when the user submits.
|
|
|
17
19
|
branch as base, the diff starts where HEAD forked from it.
|
|
18
20
|
-w, --worktrees Also review every other worktree of each repo.
|
|
19
21
|
-m, --summary TXT What the agent changed, shown at the top.
|
|
20
|
-
-
|
|
22
|
+
-f, --focus SPEC Where to look first: "path[:start[-end]]=why" (repeatable).
|
|
23
|
+
-q, --question TXT A decision you need from the user (repeatable; rarely).
|
|
21
24
|
--json Print JSON instead of markdown.
|
|
22
25
|
--port N Port to listen on (default: random).
|
|
23
26
|
--no-open Don't open the browser; only print the URL to stderr.
|
|
24
27
|
-h, --help Show this help.
|
|
25
28
|
|
|
26
29
|
list Show the URLs of reviews that are waiting for you.
|
|
27
|
-
mcp Run as an MCP server on stdio (tool: request_review)
|
|
30
|
+
mcp Run as an MCP server on stdio (tool: request_review).
|
|
31
|
+
hook Claude Code hook (reads the event on stdin): records each
|
|
32
|
+
repo's state when a session first touches it, and fills
|
|
33
|
+
that in as the base of request_review.`;
|
|
28
34
|
|
|
29
35
|
function fail(msg) {
|
|
30
36
|
console.error(`feedbacker: ${msg}`);
|
|
31
37
|
process.exit(1);
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
// "src/core.mjs:120-160=Merge logic" -> { path, startLine, endLine, note }
|
|
41
|
+
function parseFocus(spec) {
|
|
42
|
+
const eq = spec.indexOf('=');
|
|
43
|
+
const where = eq < 0 ? spec : spec.slice(0, eq);
|
|
44
|
+
const m = where.match(/^(.*?)(?::(\d+)(?:-(\d+))?)?$/);
|
|
45
|
+
return { path: m[1], startLine: Number(m[2]) || 0, endLine: Number(m[3] || m[2]) || 0, note: eq < 0 ? '' : spec.slice(eq + 1) };
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
function parseArgs(argv) {
|
|
35
|
-
const opts = { repos: [], questions: [], summary: '', worktrees: false, json: false, port: 0, open: true };
|
|
49
|
+
const opts = { repos: [], questions: [], focus: [], summary: '', worktrees: false, json: false, port: 0, open: true };
|
|
36
50
|
for (let i = 0; i < argv.length; i++) {
|
|
37
51
|
const a = argv[i];
|
|
38
52
|
const next = () => {
|
|
@@ -42,6 +56,7 @@ function parseArgs(argv) {
|
|
|
42
56
|
if (a === '-h' || a === '--help') { console.log(USAGE); process.exit(0); }
|
|
43
57
|
else if (a === '-m' || a === '--summary') opts.summary = next();
|
|
44
58
|
else if (a === '-q' || a === '--question') opts.questions.push(next());
|
|
59
|
+
else if (a === '-f' || a === '--focus') opts.focus.push(parseFocus(next()));
|
|
45
60
|
else if (a === '-w' || a === '--worktrees') opts.worktrees = true;
|
|
46
61
|
else if (a === '--json') opts.json = true;
|
|
47
62
|
else if (a === '--port') opts.port = Number(next());
|
|
@@ -59,6 +74,8 @@ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => process
|
|
|
59
74
|
const argv = process.argv.slice(2);
|
|
60
75
|
if (argv[0] === 'mcp') {
|
|
61
76
|
runMcp();
|
|
77
|
+
} else if (argv[0] === 'hook') {
|
|
78
|
+
await runHook();
|
|
62
79
|
} else if (argv[0] === 'list') {
|
|
63
80
|
const reviews = listReviews();
|
|
64
81
|
if (!reviews.length) console.log('No reviews waiting.');
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/feedbacker.mjs\" hook", "timeout": 10 }] }
|
|
5
|
+
],
|
|
6
|
+
"PreToolUse": [
|
|
7
|
+
{
|
|
8
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__plugin_feedbacker_feedbacker__request_review",
|
|
9
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/feedbacker.mjs\" hook", "timeout": 10 }]
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-feedbacker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A local merge-request-style review page that coding agents open to collect your line comments and answers, across several git repos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"src/*.mjs",
|
|
12
12
|
"src/page.html",
|
|
13
13
|
"src/dist",
|
|
14
|
+
"hooks",
|
|
14
15
|
"skills",
|
|
15
16
|
".claude-plugin",
|
|
16
17
|
".mcp.json"
|
package/skills/review/SKILL.md
CHANGED
|
@@ -5,18 +5,17 @@ description: Ask the user to review your changes in a local merge-request-style
|
|
|
5
5
|
|
|
6
6
|
# Review with Feedbacker
|
|
7
7
|
|
|
8
|
-
Feedbacker opens a review page in the user's browser with the diffs of the repos you pass. The user comments on lines,
|
|
8
|
+
Feedbacker opens a review page in the user's browser with the diffs of the repos you pass. The user comments on lines, asks you questions about the code, and clicks **Approve** or **Request changes**. You get their feedback back as markdown.
|
|
9
9
|
|
|
10
10
|
## How
|
|
11
11
|
|
|
12
12
|
1. List **every git repo and worktree you changed in this session**, as absolute paths. Include repos outside the current directory. If the work is spread over several worktrees of one repo (e.g. parallel agents), pass one of them with `"worktrees": true` instead of listing each.
|
|
13
|
-
2. Pick a base per repo:
|
|
14
|
-
- Uncommitted changes only: leave `base` out (it defaults to `HEAD`).
|
|
15
|
-
- You committed during the session: use the commit the session started from (`git log` to find it) or the branch you forked from, e.g. `main`.
|
|
13
|
+
2. Pick a base per repo, usually by leaving it out: the plugin's hooks recorded where each repo stood when this session first touched it, and fill that in, so the review shows exactly what you changed in this session, commits included. Only pass `base` for something else, e.g. a whole branch against `main`. The result starts with a "Reviewed" list showing what the user saw.
|
|
16
14
|
3. Call the `request_review` tool of the `feedbacker` MCP server with:
|
|
17
15
|
- `repos`: `[{ "path": "/abs/path", "base": "main", "worktrees": false }, ...]`
|
|
18
|
-
- `summary`:
|
|
19
|
-
- `
|
|
16
|
+
- `summary`: markdown. One or two sentences on what changed and why, then a short `- ` list of the notable parts. Use `### ` subheadings only if the change has distinct parts. No walls of text.
|
|
17
|
+
- `focus`: two to six places the user should look first, each `{ "path": "src/x.ts", "startLine": 40, "endLine": 80, "note": "why it matters" }`. Pick the main logic and anything risky or non-obvious, not tests, docs or config (the page collapses those anyway).
|
|
18
|
+
- `questions`: leave this out. Only add one if you are blocked on a decision you truly cannot make; the user asks *you* questions on the page instead.
|
|
20
19
|
4. Before the call, tell the user a review page is opening in their browser. If it doesn't appear, they can run `node "${CLAUDE_PLUGIN_ROOT}/bin/feedbacker.mjs" list` to get its URL. The tool returns once they submit, however long that takes.
|
|
21
20
|
|
|
22
21
|
If the MCP tool isn't available, run the CLI in the background instead, so the shell timeout doesn't kill it, and read its output when it exits:
|
|
@@ -28,6 +27,7 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/feedbacker.mjs" /abs/repo-a /abs/repo-b@main \
|
|
|
28
27
|
|
|
29
28
|
## After the review
|
|
30
29
|
|
|
30
|
+
- **Questions from the reviewer:** answer every one in your reply, quoting the file and line. Change code only if a question shows something is wrong, and say that you did.
|
|
31
31
|
- **Changes requested:** address every comment. Each one names the repo, file, line range and the code it refers to. Answer comments you disagree with instead of silently skipping them. Then offer another review round.
|
|
32
32
|
- **Approved:** carry on (commit, push, or whatever was next), taking any comments and answers into account.
|
|
33
33
|
- Use the answers to your questions as decisions; don't ask them again.
|
package/src/baseline.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Session baseline: where each repo stood when a Claude Code session first
|
|
2
|
+
// touched it, so a review shows only what changed in that session, even after
|
|
3
|
+
// the agent committed. Recorded and applied by the plugin's hooks
|
|
4
|
+
// (`feedbacker hook`, see hooks/hooks.json).
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { hasChanges, buildReview, stateDir } from './core.mjs';
|
|
10
|
+
|
|
11
|
+
const TOOL = 'mcp__plugin_feedbacker_feedbacker__request_review';
|
|
12
|
+
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
13
|
+
const KEEP_DAYS = 30;
|
|
14
|
+
|
|
15
|
+
function git(cwd, args) {
|
|
16
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sessionFile(id) {
|
|
20
|
+
return path.join(stateDir(), 'sessions', `${String(id).replace(/[^\w-]/g, '')}.json`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadSession(id) {
|
|
24
|
+
try { return JSON.parse(fs.readFileSync(sessionFile(id), 'utf8')); } catch { return { repos: {} }; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function saveSession(id, session) {
|
|
28
|
+
const file = sessionFile(id);
|
|
29
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
30
|
+
fs.writeFileSync(file, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
31
|
+
// Forget sessions nobody has touched for a month.
|
|
32
|
+
const cutoff = Date.now() - KEEP_DAYS * 86_400_000;
|
|
33
|
+
for (const name of fs.readdirSync(path.dirname(file))) {
|
|
34
|
+
const f = path.join(path.dirname(file), name);
|
|
35
|
+
try { if (fs.statSync(f).mtimeMs < cutoff) fs.rmSync(f); } catch {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Record the repo containing `dir` unless this session already has it.
|
|
40
|
+
// The baseline is HEAD, or a snapshot commit of uncommitted work
|
|
41
|
+
// (`git stash create`: nothing in the working tree or stash list changes).
|
|
42
|
+
export function record(id, dir) {
|
|
43
|
+
let top;
|
|
44
|
+
try {
|
|
45
|
+
let d = path.resolve(dir);
|
|
46
|
+
while (!fs.existsSync(d)) d = path.dirname(d); // a file that's about to be created
|
|
47
|
+
if (!fs.statSync(d).isDirectory()) d = path.dirname(d);
|
|
48
|
+
top = git(d, ['rev-parse', '--show-toplevel']);
|
|
49
|
+
} catch {
|
|
50
|
+
return null; // not in a git repo
|
|
51
|
+
}
|
|
52
|
+
const session = loadSession(id);
|
|
53
|
+
if (session.repos[top]) return session.repos[top];
|
|
54
|
+
const head = git(top, ['rev-parse', 'HEAD']);
|
|
55
|
+
let snapshot = '';
|
|
56
|
+
try { snapshot = git(top, ['stash', 'create']); } catch {}
|
|
57
|
+
session.started ||= new Date().toISOString();
|
|
58
|
+
session.repos[top] = { base: snapshot || head, head, recorded: new Date().toISOString() };
|
|
59
|
+
saveSession(id, session);
|
|
60
|
+
return session.repos[top];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Fill in the session baseline for repos passed without a base, and add
|
|
64
|
+
// recorded repos that have changes but weren't passed. Returns the new input,
|
|
65
|
+
// or null when nothing changes.
|
|
66
|
+
export function applyBaseline(id, input) {
|
|
67
|
+
const session = loadSession(id);
|
|
68
|
+
const known = Object.keys(session.repos);
|
|
69
|
+
if (!known.length || !Array.isArray(input?.repos)) return null;
|
|
70
|
+
let changed = false;
|
|
71
|
+
const topOf = (p) => { try { return git(path.resolve(p), ['rev-parse', '--show-toplevel']); } catch { return null; } };
|
|
72
|
+
const repos = input.repos.map((r) => {
|
|
73
|
+
const spec = typeof r === 'string' ? { path: r } : { ...r };
|
|
74
|
+
const top = topOf(spec.path);
|
|
75
|
+
if (!spec.base && top && session.repos[top]) {
|
|
76
|
+
spec.base = session.repos[top].base;
|
|
77
|
+
spec.exact = true;
|
|
78
|
+
spec.session = true;
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
return spec;
|
|
82
|
+
});
|
|
83
|
+
const passed = new Set(repos.map((r) => topOf(r.path)).filter(Boolean));
|
|
84
|
+
for (const top of known) {
|
|
85
|
+
if (passed.has(top) || !fs.existsSync(top)) continue;
|
|
86
|
+
const spec = { path: top, base: session.repos[top].base, exact: true, session: true };
|
|
87
|
+
if (hasChanges(buildReview({ repos: [spec] }))) { repos.push(spec); changed = true; }
|
|
88
|
+
}
|
|
89
|
+
return changed ? { ...input, repos } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Entry point for `feedbacker hook`: reads the hook event JSON on stdin.
|
|
93
|
+
// Never fails the tool call: any error just means no baseline.
|
|
94
|
+
export async function runHook() {
|
|
95
|
+
let event;
|
|
96
|
+
try {
|
|
97
|
+
let text = '';
|
|
98
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
99
|
+
event = JSON.parse(text);
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const id = event.session_id;
|
|
104
|
+
if (!id) return;
|
|
105
|
+
try {
|
|
106
|
+
if (event.hook_event_name === 'SessionStart' && event.cwd) {
|
|
107
|
+
record(id, event.cwd);
|
|
108
|
+
} else if (event.hook_event_name === 'PreToolUse') {
|
|
109
|
+
if (EDIT_TOOLS.has(event.tool_name)) {
|
|
110
|
+
const file = event.tool_input?.file_path || event.tool_input?.notebook_path;
|
|
111
|
+
if (file) record(id, path.resolve(event.cwd || '.', file));
|
|
112
|
+
} else if (event.tool_name === TOOL) {
|
|
113
|
+
const updated = applyBaseline(id, event.tool_input);
|
|
114
|
+
if (updated) {
|
|
115
|
+
process.stdout.write(JSON.stringify({
|
|
116
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: updated },
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
package/src/core.mjs
CHANGED
|
@@ -75,9 +75,11 @@ export function parseDiff(text) {
|
|
|
75
75
|
return files;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
// "repo", "repo@base" or { path, base, worktrees }.
|
|
78
|
+
// "repo", "repo@base" or { path, base, worktrees, exact }. `exact` diffs
|
|
79
|
+
// against base itself instead of the fork point (session baselines, which can
|
|
80
|
+
// be snapshot commits that HEAD never contains).
|
|
79
81
|
export function parseSpec(spec) {
|
|
80
|
-
if (typeof spec !== 'string') return { path: spec.path, base: spec.base || 'HEAD', worktrees: !!spec.worktrees };
|
|
82
|
+
if (typeof spec !== 'string') return { path: spec.path, base: spec.base || 'HEAD', worktrees: !!spec.worktrees, exact: !!spec.exact, session: !!spec.session };
|
|
81
83
|
const at = spec.lastIndexOf('@');
|
|
82
84
|
if (at > 0 && !fs.existsSync(spec)) return { path: spec.slice(0, at), base: spec.slice(at + 1) || 'HEAD', worktrees: false };
|
|
83
85
|
return { path: spec, base: 'HEAD', worktrees: false };
|
|
@@ -99,13 +101,13 @@ export function listWorktrees(dir) {
|
|
|
99
101
|
// point where HEAD forked from base (like a merge request), or base itself
|
|
100
102
|
// when they share no history.
|
|
101
103
|
export function loadRepo(spec) {
|
|
102
|
-
const { path: p, base } = parseSpec(spec);
|
|
103
|
-
const repo = { path: path.resolve(p), base, branch: '', files: [], error: null };
|
|
104
|
+
const { path: p, base, exact, session } = parseSpec(spec);
|
|
105
|
+
const repo = { path: path.resolve(p), base, session, branch: '', files: [], error: null };
|
|
104
106
|
try {
|
|
105
107
|
repo.path = git(repo.path, ['rev-parse', '--show-toplevel']).trim();
|
|
106
108
|
repo.branch = git(repo.path, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
107
109
|
let from = git(repo.path, ['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`]).trim();
|
|
108
|
-
try { from = git(repo.path, ['merge-base', from, 'HEAD']).trim(); } catch {}
|
|
110
|
+
if (!exact) try { from = git(repo.path, ['merge-base', from, 'HEAD']).trim(); } catch {}
|
|
109
111
|
let text = git(repo.path, ['diff', ...DIFF_FLAGS, from, '--']);
|
|
110
112
|
const untracked = git(repo.path, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean);
|
|
111
113
|
for (const f of untracked) {
|
|
@@ -120,7 +122,74 @@ export function loadRepo(spec) {
|
|
|
120
122
|
|
|
121
123
|
// `worktrees` (globally or per repo) expands each repo into all its worktrees,
|
|
122
124
|
// e.g. to review parallel agents' work together. Duplicates are dropped.
|
|
123
|
-
|
|
125
|
+
// What kind of file a path is, for the page's quick filters. Works on paths
|
|
126
|
+
// only, for any language: tests by folder or file-name convention, docs and
|
|
127
|
+
// agent instructions (CLAUDE.md, AGENTS.md), config, and generated files
|
|
128
|
+
// (lock files, build output, minified, snapshots, binaries).
|
|
129
|
+
const TEST_DIRS = /(^|\/)(tests?|__tests__|__mocks__|specs?|testdata|test-data|fixtures|e2e|testing)\//i;
|
|
130
|
+
const TEST_FILES = [
|
|
131
|
+
/\.(test|spec|e2e|cy|stories|story)\.[^/]+$/i, // app.test.ts, app.spec.js, x.e2e.ts, x.cy.ts, x.stories.tsx
|
|
132
|
+
/(^|\/)test_[^/]+\.py$/i, /_test\.(py|go|rb|exs?|dart|cpp|cc|c|rs)$/i, // test_x.py, x_test.go
|
|
133
|
+
/(Tests?|Spec|IT)\.(java|kt|kts|scala|groovy|cs|fs|vb|swift|php)$/, // FooTest.java, FooTests.cs
|
|
134
|
+
/_spec\.rb$/i, /\.t$/, // foo_spec.rb, Perl foo.t
|
|
135
|
+
];
|
|
136
|
+
const GENERATED = [
|
|
137
|
+
/(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|Cargo\.lock|go\.sum|poetry\.lock|uv\.lock|Pipfile\.lock|Gemfile\.lock|composer\.lock|mix\.lock|flake\.lock|Podfile\.lock|packages\.lock\.json)$/,
|
|
138
|
+
/\.min\.(js|css)$/i, /\.map$/i, /\.snap$/i, /\.lock$/i,
|
|
139
|
+
/(^|\/)(dist|build|out|vendor|node_modules|generated|__generated__|\.next|target)\//,
|
|
140
|
+
/\.(pb\.go|g\.dart|generated\.\w+|designer\.cs)$/i, /_pb2\.py$/,
|
|
141
|
+
];
|
|
142
|
+
const DOCS = [
|
|
143
|
+
/\.(md|mdx|markdown|rst|adoc|txt|org)$/i,
|
|
144
|
+
/(^|\/)(docs?|documentation)\//i,
|
|
145
|
+
/(^|\/)(LICEN[CS]E|COPYING|NOTICE|AUTHORS|CHANGELOG|CHANGES|CODEOWNERS)(\.\w+)?$/i,
|
|
146
|
+
];
|
|
147
|
+
const CONFIG = [
|
|
148
|
+
/\.(json|jsonc|json5|ya?ml|toml|ini|cfg|conf|properties|env|editorconfig|xml|plist)$/i,
|
|
149
|
+
/(^|\/)\.[^/]+$/, // dotfiles: .gitignore, .npmrc, .eslintrc
|
|
150
|
+
/(^|\/)\.(github|gitlab|circleci|vscode|idea|devcontainer|husky)\//,
|
|
151
|
+
/(^|\/)(Dockerfile|Containerfile|docker-compose[^/]*|Makefile|Justfile|Procfile|Brewfile)$/i,
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
export function classifyFile(file) {
|
|
155
|
+
const p = file.path || '';
|
|
156
|
+
if (file.binary) return 'generated';
|
|
157
|
+
if (GENERATED.some((re) => re.test(p))) return 'generated';
|
|
158
|
+
if (TEST_DIRS.test(p) || TEST_FILES.some((re) => re.test(p))) return 'test';
|
|
159
|
+
if (DOCS.some((re) => re.test(p))) return 'docs';
|
|
160
|
+
if (CONFIG.some((re) => re.test(p))) return 'config';
|
|
161
|
+
return 'code';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The agent's pointers to what matters: [{ path, repo?, startLine?, endLine?,
|
|
165
|
+
// note }]. `path` is relative to its repo or absolute. Each match is attached
|
|
166
|
+
// to its file; review.focus keeps them in the agent's order for the page.
|
|
167
|
+
function attachFocus(repos, focus) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const raw of Array.isArray(focus) ? focus : []) {
|
|
170
|
+
const item = typeof raw === 'string' ? { path: raw } : raw || {};
|
|
171
|
+
const note = typeof item.note === 'string' ? item.note : '';
|
|
172
|
+
const want = String(item.path || '').replace(/^\.\//, '');
|
|
173
|
+
if (!want) continue;
|
|
174
|
+
const startLine = Number(item.startLine) || 0;
|
|
175
|
+
const endLine = Number(item.endLine) || startLine;
|
|
176
|
+
let hit = null;
|
|
177
|
+
repos.forEach((repo, ri) => {
|
|
178
|
+
if (hit || (item.repo && path.resolve(item.repo) !== repo.path && path.basename(repo.path) !== item.repo)) return;
|
|
179
|
+
const rel = path.isAbsolute(want) ? path.relative(repo.path, want) : want;
|
|
180
|
+
const fi = repo.files.findIndex((f) => f.path === rel || f.oldPath === rel);
|
|
181
|
+
if (fi >= 0) hit = { ri, fi };
|
|
182
|
+
});
|
|
183
|
+
// Matched entries show the repo-relative path, however the agent wrote it.
|
|
184
|
+
const shown = hit ? repos[hit.ri].files[hit.fi].path : want;
|
|
185
|
+
const entry = { path: shown, note, startLine, endLine, ...(hit || { ri: -1, fi: -1 }) };
|
|
186
|
+
if (hit) (repos[hit.ri].files[hit.fi].focus ||= []).push({ note, startLine, endLine });
|
|
187
|
+
out.push(entry);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function buildReview({ repos = ['.'], summary = '', questions = [], focus = [], worktrees = false }) {
|
|
124
193
|
const specs = [];
|
|
125
194
|
for (const raw of repos) {
|
|
126
195
|
const spec = parseSpec(raw);
|
|
@@ -128,7 +197,7 @@ export function buildReview({ repos = ['.'], summary = '', questions = [], workt
|
|
|
128
197
|
if (spec.worktrees || worktrees) {
|
|
129
198
|
try { paths = listWorktrees(spec.path); } catch {} // not a repo: loadRepo reports it
|
|
130
199
|
}
|
|
131
|
-
for (const p of paths) specs.push({ path: p, base: spec.base });
|
|
200
|
+
for (const p of paths) specs.push({ path: p, base: spec.base, exact: spec.exact, session: spec.session });
|
|
132
201
|
}
|
|
133
202
|
const seen = new Set();
|
|
134
203
|
const loaded = specs.map(loadRepo).filter((r) => !seen.has(r.path) && seen.add(r.path));
|
|
@@ -139,7 +208,8 @@ export function buildReview({ repos = ['.'], summary = '', questions = [], workt
|
|
|
139
208
|
? path.join(path.basename(path.dirname(r.path)), name(r))
|
|
140
209
|
: name(r);
|
|
141
210
|
}
|
|
142
|
-
|
|
211
|
+
for (const r of loaded) for (const f of r.files) f.kind = classifyFile(f);
|
|
212
|
+
return { summary, questions, repos: loaded, focus: attachFocus(loaded, focus) };
|
|
143
213
|
}
|
|
144
214
|
|
|
145
215
|
export function hasChanges(review) {
|
|
@@ -148,15 +218,22 @@ export function hasChanges(review) {
|
|
|
148
218
|
|
|
149
219
|
export function toMarkdown(review, fb) {
|
|
150
220
|
const out = ['# Review feedback', '', `Verdict: **${fb.verdict === 'approve' ? 'approved' : 'changes requested'}**`, ''];
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
221
|
+
// What the user actually saw, so the agent knows what the verdict covers
|
|
222
|
+
// (a hook may have filled in bases or added repos).
|
|
223
|
+
out.push('Reviewed:', '');
|
|
224
|
+
for (const r of review.repos) {
|
|
225
|
+
const names = r.files.slice(0, 10).map((f) => f.path).filter(Boolean).join(', ') + (r.files.length > 10 ? ', …' : '');
|
|
226
|
+
const what = r.error ? `could not be read (${r.error})`
|
|
227
|
+
: `${r.files.length} file${r.files.length === 1 ? '' : 's'} changed${names ? ` (${names})` : ''}`;
|
|
228
|
+
out.push(`- ${r.path}${r.branch && r.branch !== 'HEAD' ? ` (${r.branch})` : ''}: ${what}, ${describeBase(r)}`);
|
|
155
229
|
}
|
|
156
|
-
|
|
157
|
-
|
|
230
|
+
out.push('');
|
|
231
|
+
if (fb.general.trim()) out.push('## General', '', fb.general.trim(), '');
|
|
232
|
+
const questions = fb.comments.filter((c) => c.kind === 'question');
|
|
233
|
+
const comments = fb.comments.filter((c) => c.kind !== 'question');
|
|
234
|
+
const byRepo = (list) => {
|
|
158
235
|
for (const repo of review.repos) {
|
|
159
|
-
const cs =
|
|
236
|
+
const cs = list.filter((c) => c.repo === repo.path);
|
|
160
237
|
if (!cs.length) continue;
|
|
161
238
|
out.push(`### ${repo.path}`, '');
|
|
162
239
|
for (const c of cs) {
|
|
@@ -164,6 +241,19 @@ export function toMarkdown(review, fb) {
|
|
|
164
241
|
out.push(`**${c.file}:${lines}** (${c.side} side)`, '', '```diff', c.code, '```', '', c.body.trim(), '');
|
|
165
242
|
}
|
|
166
243
|
}
|
|
244
|
+
};
|
|
245
|
+
if (questions.length) {
|
|
246
|
+
out.push('## Questions from the reviewer', '',
|
|
247
|
+
'The reviewer asks about this code. Answer each one in your reply. Change code only where a question shows something is wrong, and say so.', '');
|
|
248
|
+
byRepo(questions);
|
|
249
|
+
}
|
|
250
|
+
if (comments.length) {
|
|
251
|
+
out.push('## Comments', '');
|
|
252
|
+
byRepo(comments);
|
|
253
|
+
}
|
|
254
|
+
if (fb.answers.length) {
|
|
255
|
+
out.push('## Answers to your questions', '');
|
|
256
|
+
for (const a of fb.answers) out.push(`**Q: ${a.question}**`, '', a.answer.trim() || '_(no answer)_', '');
|
|
167
257
|
}
|
|
168
258
|
if (!fb.comments.length && !fb.general.trim() && !fb.answers.some((a) => a.answer.trim())) {
|
|
169
259
|
out.push('_No comments._', '');
|
|
@@ -171,6 +261,12 @@ export function toMarkdown(review, fb) {
|
|
|
171
261
|
return out.join('\n');
|
|
172
262
|
}
|
|
173
263
|
|
|
264
|
+
export function describeBase(r) {
|
|
265
|
+
if (r.session) return 'changes since the session started';
|
|
266
|
+
if (r.base === 'HEAD') return 'uncommitted changes';
|
|
267
|
+
return `compared with ${/^[0-9a-f]{40}$/.test(r.base) ? r.base.slice(0, 10) : r.base}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
174
270
|
// Keep only the fields we expect from the browser, with safe defaults.
|
|
175
271
|
export function normalizeFeedback(review, fb) {
|
|
176
272
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
@@ -178,6 +274,7 @@ export function normalizeFeedback(review, fb) {
|
|
|
178
274
|
const comments = (Array.isArray(fb.comments) ? fb.comments : []).map((c) => ({
|
|
179
275
|
repo: str(c.repo), file: str(c.file), side: c.side === 'old' ? 'old' : 'new',
|
|
180
276
|
startLine: Number(c.startLine) || 0, endLine: Number(c.endLine) || 0, code: str(c.code), body: str(c.body),
|
|
277
|
+
kind: c.kind === 'question' ? 'question' : 'comment',
|
|
181
278
|
}));
|
|
182
279
|
return { verdict: fb.verdict === 'approve' ? 'approve' : 'request_changes', general: str(fb.general), answers, comments };
|
|
183
280
|
}
|
package/src/dist/app.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
@font-face{font-family:Atkinson Hyperlegible Next Variable;font-style:normal;font-display:swap;font-weight:200 800;src:url("./fonts/atkinson-hyperlegible-next-latin-ext-wght-normal-2N3YVG5Z.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Atkinson Hyperlegible Next Variable;font-style:normal;font-display:swap;font-weight:200 800;src:url("./fonts/atkinson-hyperlegible-next-latin-wght-normal-VX3WN42K.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-cyrillic-ext-wght-normal-BUWC6ZS3.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-cyrillic-wght-normal-ENSFVLK4.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-greek-wght-normal-Y3BRZMJV.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-vietnamese-wght-normal-SP6EBKAH.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-latin-ext-wght-normal-TSLVDSQR.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-latin-wght-normal-VBS632QH.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--paper: #fbfbfc;--panel: #f2f3f6;--sunk: #e9ebf0;--ink: #1b1f2a;--muted: #5d6475;--faint: #8a90a0;--line: #dde0e7;--add: #e4f4ee;--add-gutter: #c8e9dc;--add-ink: #13795b;--del: #fbeaed;--del-gutter: #f4d1d8;--del-ink: #b4233c;--hunk: #eef0f6;--marker: #ffe766;--marker-soft: #fff6bf;--marker-ink: #5c4700;--logo-ink: #1b1f2a;--on-marker: #3d2f00;--focus: #3355d6;--approve: #13795b;--approve-ink: #ffffff;--reject: #b4233c;--reject-ink: #ffffff;--syn-keyword: #8a2a9c;--syn-string: #1c6b4a;--syn-number: #a5500a;--syn-comment: #7c8293;--syn-title: #2347b8;--syn-type: #9a3412;--syn-attr: #6d4c00;--sans: "Atkinson Hyperlegible Next Variable", system-ui, sans-serif;--mono: "JetBrains Mono Variable", ui-monospace, monospace;--bar: 56px;color-scheme:light}@media(prefers-color-scheme:dark){:root{--paper: #14171e;--panel: #1a1e27;--sunk: #222733;--ink: #e4e7ee;--muted: #9aa1b2;--faint: #6f7689;--line: #2a303c;--add: #10261f;--add-gutter: #173a2f;--add-ink: #5cc9a0;--del: #2a1820;--del-gutter: #45212d;--del-ink: #f0778b;--hunk: #1c2130;--marker: #f2d44c;--marker-soft: rgba(242, 212, 76, .16);--marker-ink: #f2d44c;--focus: #7d97ff;--approve: #2f9e78;--reject: #c53a52;--syn-keyword: #d49bf0;--syn-string: #8fd3a8;--syn-number: #f0b072;--syn-comment: #737a8d;--syn-title: #8fb0ff;--syn-type: #f2a37f;--syn-attr: #e6c977;color-scheme:dark}}*{box-sizing:border-box}html{scroll-padding-top:calc(var(--bar) + 12px)}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased}button,textarea{font:inherit;color:inherit}:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.bar{position:sticky;top:0;z-index:10;height:var(--bar);display:flex;align-items:center;gap:16px;padding:0 20px;background:color-mix(in srgb,var(--paper) 88%,transparent);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}.brand{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}.brand .logo{flex:none;display:block}.brand .wordmark{font-weight:800;font-size:17px;letter-spacing:-.015em}.status .brand{margin-bottom:28px}.bar .stats{display:flex;gap:12px;color:var(--muted);font-size:14px;white-space:nowrap;overflow:hidden}.bar .spacer{flex:1}.plus{color:var(--add-ink);font-family:var(--mono);font-size:13px}.minus{color:var(--del-ink);font-family:var(--mono);font-size:13px}.marks{background:var(--marker);color:var(--on-marker);border-radius:999px;padding:0 9px;font-weight:700;font-size:13px;white-space:nowrap}.btn{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:6px 14px;cursor:pointer;font-weight:600;font-size:14px;white-space:nowrap}.btn:hover{background:var(--sunk)}.btn.approve{background:var(--approve);border-color:var(--approve);color:var(--approve-ink)}.btn.reject{background:transparent;border-color:var(--reject);color:var(--reject)}.btn.approve:hover{filter:brightness(1.08)}.btn.reject:hover{background:var(--del)}.btn.small{padding:3px 10px;font-size:13px;border-radius:6px}.btn.primary{background:var(--ink);border-color:var(--ink);color:var(--paper)}.btn.quiet{border-color:transparent;background:transparent;color:var(--muted)}.btn.quiet:hover{color:var(--ink);background:var(--sunk)}.picker{position:relative;display:inline-flex;min-width:0}.picker select{appearance:none;min-width:0;max-width:320px;text-overflow:ellipsis;font:inherit;font-size:14px;font-weight:600;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 30px 5px 12px;cursor:pointer}.picker select:hover{background:var(--sunk)}.picker:after{content:"\25be";position:absolute;right:11px;top:50%;transform:translateY(-52%);pointer-events:none;color:var(--muted);font-size:12px}.layout{display:grid;grid-template-columns:280px minmax(0,1fr)}.tree{position:sticky;top:var(--bar);align-self:start;height:calc(100vh - var(--bar));overflow:auto;padding:20px 12px 40px 16px;border-right:1px solid var(--line);font-size:14px}.tree .repo{display:flex;align-items:baseline;gap:8px;margin:0 0 6px 6px;font-weight:700}.tree>div+div{margin-top:22px}.branch{font-family:var(--mono);font-size:12px;font-weight:500;color:var(--muted);background:var(--sunk);border-radius:5px;padding:1px 6px;white-space:nowrap}.tree a{display:grid;grid-template-columns:14px minmax(0,1fr) auto;gap:8px;align-items:baseline;padding:3px 6px;border-radius:6px;color:var(--ink);text-decoration:none}.tree a:hover{background:var(--panel)}.tree .path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.tree .count{font-size:12px}.tree .count .marks{padding:0 6px;font-size:12px}.st{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--faint)}.st.added{color:var(--add-ink)}.st.deleted{color:var(--del-ink)}.st.renamed{color:var(--syn-title)}.review{padding:28px 32px 120px;min-width:0}.handoff{max-width:76ch;margin-bottom:36px}.handoff .from{color:var(--muted);font-size:14px;margin:0 0 6px}.summary{font-size:21px;line-height:1.5;margin:0;white-space:pre-wrap;text-wrap:pretty}.summary.empty{color:var(--muted);font-size:17px}.questions{margin-top:24px;display:grid;gap:14px}.question{border-left:3px solid var(--line);padding:2px 0 2px 16px}.question.answered{border-left-color:var(--approve)}.question p{margin:0 0 8px;font-weight:700;white-space:pre-wrap}textarea{display:block;width:100%;min-height:64px;resize:vertical;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);line-height:1.5}textarea:focus{border-color:var(--focus);outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--focus) 22%,transparent)}.repo-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:10px;margin:32px 0 12px}.repo-head h2{margin:0;font-size:19px}.repo-head .where{color:var(--muted);font-size:13px;font-family:var(--mono);overflow-wrap:anywhere}.empty-note{color:var(--muted);margin:0 0 8px}.error{color:var(--del-ink)}.file{border:1px solid var(--line);border-radius:10px;margin-bottom:18px;background:var(--paper)}.fhead{position:sticky;top:var(--bar);z-index:5;display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--panel);border-bottom:1px solid var(--line);border-radius:10px 10px 0 0;font-family:var(--mono);font-size:13px}.file.collapsed .fhead{border-bottom:none;border-radius:10px}.fhead .name{flex:1;min-width:0;overflow-wrap:anywhere}.fhead .old{color:var(--muted)}.fold{border:none;background:none;cursor:pointer;color:var(--muted);padding:0 2px;font-size:13px;width:18px}.fold:hover{color:var(--ink)}.fnote{padding:12px 16px;color:var(--muted);font-size:14px}.scroll{overflow-x:auto;border-radius:0 0 10px 10px}table{border-collapse:collapse;width:100%;font:12.5px/21px var(--mono);font-variant-ligatures:none}td.ln{width:1%;min-width:48px;padding:0 10px;text-align:right;color:var(--faint);user-select:none;cursor:pointer;vertical-align:top;position:relative}tr:not(.hunk):hover td.ln:nth-child(2):before{content:"+";position:absolute;left:-2px;top:1px;width:18px;height:18px;line-height:18px;text-align:center;background:var(--marker);color:var(--on-marker);border-radius:5px;font-weight:700}td.ln:hover{color:var(--ink)}td.code{white-space:pre;padding:0 12px 0 4px}td.code .sign{display:inline-block;width:1.4em;color:var(--faint);user-select:none}tr.add td{background:var(--add)}tr.add td.ln{background:var(--add-gutter)}tr.add .sign{color:var(--add-ink)}tr.del td{background:var(--del)}tr.del td.ln{background:var(--del-gutter)}tr.del .sign{color:var(--del-ink)}tr.hunk td{background:var(--hunk);color:var(--muted);padding-top:3px;padding-bottom:3px}tr.sel td{background:var(--marker-soft)!important}tr.sel td.ln{color:var(--ink)}tr.sel td.ln:first-child{box-shadow:inset 3px 0 0 var(--marker)}tr.comment>td{padding:8px 14px 10px 62px;background:var(--panel);font:15px/1.55 var(--sans);white-space:normal;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.note-card{border-left:3px solid var(--marker);padding:4px 0 4px 12px;max-width:80ch}.note-card .meta{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;margin-bottom:2px}.note-card .meta .where{font-family:var(--mono);font-size:12px}.note-card .meta .actions{margin-left:auto;display:flex;gap:2px}.note-card p{margin:0;white-space:pre-wrap}.form{max-width:80ch}.form .row{display:flex;align-items:center;gap:8px;margin-top:8px}.form .hint{color:var(--faint);font-size:13px;margin-right:auto}.finish{max-width:76ch;margin-top:44px;padding-top:24px;border-top:1px solid var(--line)}.finish h2{margin:0 0 4px;font-size:19px}.finish p{margin:0 0 12px;color:var(--muted)}.finish .row{display:flex;gap:10px;margin-top:12px;flex-wrap:wrap}.hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-literal{color:var(--syn-keyword)}.hljs-string,.hljs-regexp,.hljs-template-tag,.hljs-addition{color:var(--syn-string)}.hljs-number,.hljs-symbol,.hljs-variable.constant_{color:var(--syn-number)}.hljs-comment,.hljs-quote,.hljs-meta{color:var(--syn-comment);font-style:italic}.hljs-title,.hljs-title.function_,.hljs-section,.hljs-name{color:var(--syn-title)}.hljs-type,.hljs-title.class_,.hljs-selector-class{color:var(--syn-type)}.hljs-attr,.hljs-attribute,.hljs-property,.hljs-params{color:var(--syn-attr)}.hljs-strong{font-weight:700}.hljs-emphasis{font-style:italic}.status{max-width:46ch;margin:22vh auto 0;padding:0 16px}.status h1{font-size:30px;margin:0 0 8px}.status p{color:var(--muted);margin:0;font-size:17px}.status .stamp{display:table;margin-bottom:18px;padding:2px 12px;border-radius:999px;font-weight:700;font-size:14px;animation:stamp .42s cubic-bezier(.2,.9,.3,1.3)}.status .stamp.approve{background:var(--add);color:var(--add-ink)}.status .stamp.request_changes{background:var(--del);color:var(--del-ink)}@keyframes stamp{0%{transform:scale(.6);opacity:0}to{transform:scale(1);opacity:1}}@media(prefers-reduced-motion:reduce){.status .stamp{animation:none}}@media(max-width:900px){.layout{display:block}.tree{display:none}.review{padding:20px 16px 100px}.bar{padding:0 16px;gap:10px}.bar .stats,.bar .marks,.bar .wordmark{display:none}.picker select{max-width:34vw}.bar .btn{padding:6px 10px}.summary{font-size:18px}tr.comment>td{padding-left:14px}}
|
|
1
|
+
@font-face{font-family:Atkinson Hyperlegible Next Variable;font-style:normal;font-display:swap;font-weight:200 800;src:url("./fonts/atkinson-hyperlegible-next-latin-ext-wght-normal-2N3YVG5Z.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Atkinson Hyperlegible Next Variable;font-style:normal;font-display:swap;font-weight:200 800;src:url("./fonts/atkinson-hyperlegible-next-latin-wght-normal-VX3WN42K.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-cyrillic-ext-wght-normal-BUWC6ZS3.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-cyrillic-wght-normal-ENSFVLK4.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-greek-wght-normal-Y3BRZMJV.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-vietnamese-wght-normal-SP6EBKAH.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-latin-ext-wght-normal-TSLVDSQR.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url("./fonts/jetbrains-mono-latin-wght-normal-VBS632QH.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--paper: #fbfbfc;--panel: #f2f3f6;--sunk: #e9ebf0;--ink: #1b1f2a;--muted: #5d6475;--faint: #8a90a0;--line: #dde0e7;--add: #e4f4ee;--add-gutter: #c8e9dc;--add-ink: #13795b;--del: #fbeaed;--del-gutter: #f4d1d8;--del-ink: #b4233c;--add-word: #a9dfc8;--del-word: #f0b6c1;--hunk: #eef0f6;--marker: #ffe766;--marker-soft: #fff6bf;--marker-ink: #5c4700;--logo-ink: #1b1f2a;--on-marker: #3d2f00;--focus: #3355d6;--agent: #3355d6;--agent-soft: #eaeffd;--approve: #13795b;--approve-ink: #ffffff;--reject: #b4233c;--reject-ink: #ffffff;--syn-keyword: #8a2a9c;--syn-string: #1c6b4a;--syn-number: #a5500a;--syn-comment: #7c8293;--syn-title: #2347b8;--syn-type: #9a3412;--syn-attr: #6d4c00;--sans: "Atkinson Hyperlegible Next Variable", system-ui, sans-serif;--mono: "JetBrains Mono Variable", ui-monospace, monospace;--bar: 56px;color-scheme:light}@media(prefers-color-scheme:dark){:root{--paper: #14171e;--panel: #1a1e27;--sunk: #222733;--ink: #e4e7ee;--muted: #9aa1b2;--faint: #6f7689;--line: #2a303c;--add: #10261f;--add-gutter: #173a2f;--add-ink: #5cc9a0;--del: #2a1820;--del-gutter: #45212d;--del-ink: #f0778b;--add-word: #1f5a45;--del-word: #6a2a3b;--hunk: #1c2130;--marker: #f2d44c;--marker-soft: rgba(242, 212, 76, .16);--marker-ink: #f2d44c;--focus: #7d97ff;--agent: #8fa6ff;--agent-soft: #1b2236;--approve: #2f9e78;--reject: #c53a52;--syn-keyword: #d49bf0;--syn-string: #8fd3a8;--syn-number: #f0b072;--syn-comment: #737a8d;--syn-title: #8fb0ff;--syn-type: #f2a37f;--syn-attr: #e6c977;color-scheme:dark}}*{box-sizing:border-box}html{scroll-padding-top:calc(var(--bar) + 12px)}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased}button,textarea{font:inherit;color:inherit}:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.bar{position:sticky;top:0;z-index:10;height:var(--bar);display:flex;align-items:center;gap:16px;padding:0 20px;background:color-mix(in srgb,var(--paper) 88%,transparent);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}.brand{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}.brand .logo{flex:none;display:block}.brand .wordmark{font-weight:800;font-size:17px;letter-spacing:-.015em}.status .brand{margin-bottom:28px}.bar .stats{display:flex;gap:12px;color:var(--muted);font-size:14px;white-space:nowrap;overflow:hidden}.bar .spacer{flex:1}.plus{color:var(--add-ink);font-family:var(--mono);font-size:13px}.minus{color:var(--del-ink);font-family:var(--mono);font-size:13px}.marks{background:var(--marker);color:var(--on-marker);border-radius:999px;padding:0 9px;font-weight:700;font-size:13px;white-space:nowrap}.btn{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:6px 14px;cursor:pointer;font-weight:600;font-size:14px;white-space:nowrap}.btn:hover{background:var(--sunk)}.btn.approve{background:var(--approve);border-color:var(--approve);color:var(--approve-ink)}.btn.reject{background:transparent;border-color:var(--reject);color:var(--reject)}.btn.approve:hover{filter:brightness(1.08)}.btn.reject:hover{background:var(--del)}.btn.small{padding:3px 10px;font-size:13px;border-radius:6px}.btn.primary{background:var(--ink);border-color:var(--ink);color:var(--paper)}.btn.quiet{border-color:transparent;background:transparent;color:var(--muted)}.btn.quiet:hover{color:var(--ink);background:var(--sunk)}.picker{position:relative;display:inline-flex;min-width:0}.picker select{appearance:none;min-width:0;max-width:320px;text-overflow:ellipsis;font:inherit;font-size:14px;font-weight:600;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 30px 5px 12px;cursor:pointer}.picker select:hover{background:var(--sunk)}.picker:after{content:"\25be";position:absolute;right:11px;top:50%;transform:translateY(-52%);pointer-events:none;color:var(--muted);font-size:12px}.layout{display:grid;grid-template-columns:280px minmax(0,1fr)}.tree{position:sticky;top:var(--bar);align-self:start;height:calc(100vh - var(--bar));overflow:auto;padding:20px 12px 40px 16px;border-right:1px solid var(--line);font-size:14px}.tree .repo{display:flex;align-items:baseline;gap:8px;margin:0 0 6px 6px;font-weight:700}.tree>div+div{margin-top:22px}.branch{font-family:var(--mono);font-size:12px;font-weight:500;color:var(--muted);background:var(--sunk);border-radius:5px;padding:1px 6px;white-space:nowrap}.tree a{display:grid;grid-template-columns:14px minmax(0,1fr) auto;gap:8px;align-items:baseline;padding:3px 6px;border-radius:6px;color:var(--ink);text-decoration:none}.tree a:hover{background:var(--panel)}.tree a.minor .path{color:var(--muted)}.tree a.focused .path{font-weight:700;color:var(--ink)}.tree .path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.tree .count{font-size:12px}.tree .count .marks{padding:0 6px;font-size:12px}.st{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--faint)}.st.added{color:var(--add-ink)}.st.deleted{color:var(--del-ink)}.st.renamed{color:var(--syn-title)}.review{padding:28px 32px 120px;min-width:0}.handoff{max-width:76ch;margin-bottom:28px}.summary{font-size:15.5px;line-height:1.6;text-wrap:pretty}.summary>:first-child{margin-top:0}.summary p{margin:0 0 10px}.summary .summary-lead,.summary-lead{font-size:19px;line-height:1.5;margin:0 0 12px}.summary-lead.empty{color:var(--muted);font-size:17px}.summary h3{font-size:15px;margin:18px 0 6px}.summary ul,.summary ol{margin:0 0 12px;padding-left:22px}.summary li{margin:3px 0}.summary code,.focus-list a,.focus-list .missing{font-family:var(--mono);font-size:.88em}.summary code{background:var(--sunk);padding:1px 5px;border-radius:4px}.handoff h3{font-size:15px;margin:0 0 8px}.focus-list{margin-top:22px;padding:12px 16px;border-left:3px solid var(--agent);background:var(--agent-soft);border-radius:0 8px 8px 0}.focus-list ul{list-style:none;margin:0;padding:0;display:grid;gap:10px}.focus-list li{display:grid;gap:1px}.focus-list a{color:var(--agent);text-decoration:none;font-weight:600}.focus-list a:hover{text-decoration:underline}.focus-list a .repo{font-family:var(--sans);font-weight:700;color:var(--muted);margin-right:6px}.focus-list .missing{color:var(--muted);text-decoration:line-through}.focus-list .why{color:var(--ink)}.questions{margin-top:22px;display:grid;gap:12px}.question{border-left:3px solid var(--line);padding:2px 0 2px 16px}.question.answered{border-left-color:var(--approve)}.question p{margin:0 0 8px;font-weight:700;white-space:pre-wrap}.toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 20px;margin-bottom:4px}.toolbar .empty-note{margin:0}.filters{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.filters .lead{color:var(--muted);font-size:13px;margin-right:2px}.chip{font:inherit;font-size:13px;font-weight:600;cursor:pointer;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;padding:2px 10px}.chip .n{font-weight:500;margin-left:2px}.chip:hover{background:var(--panel);color:var(--ink)}.chip.on{background:var(--ink);border-color:var(--ink);color:var(--paper)}textarea{display:block;width:100%;min-height:64px;resize:vertical;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);line-height:1.5}textarea:focus{border-color:var(--focus);outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--focus) 22%,transparent)}.repo-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:10px;margin:32px 0 12px}.repo-head h2{margin:0;font-size:19px}.repo-head .where{color:var(--muted);font-size:13px;font-family:var(--mono);overflow-wrap:anywhere}.empty-note{color:var(--muted);margin:0 0 8px}.error{color:var(--del-ink)}.file{border:1px solid var(--line);border-radius:10px;margin-bottom:18px;background:var(--paper)}.fhead{position:sticky;top:var(--bar);z-index:5;display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--panel);border-bottom:1px solid var(--line);border-radius:10px 10px 0 0;font-family:var(--mono);font-size:13px}.file.collapsed .fhead{border-bottom:none;border-radius:10px}.fhead .name{flex:1;min-width:0;overflow-wrap:anywhere}.fhead .old{color:var(--muted)}.kind-tag{font:12px var(--sans);color:var(--muted);background:var(--sunk);border-radius:5px;padding:0 6px}.file.focused{border-color:color-mix(in srgb,var(--agent) 45%,var(--line))}tr.agent-note>td{padding:6px 14px 6px 62px;background:var(--agent-soft);font:14px/1.5 var(--sans);white-space:normal}.agent-note.file-level{padding:8px 14px;background:var(--agent-soft);border-bottom:1px solid var(--line);font-size:14px}.agent-card{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;border-left:3px solid var(--agent);padding-left:10px;max-width:90ch}.agent-card .who{color:var(--agent);font-weight:700;font-size:13px}.agent-card .lines{color:var(--muted);font-size:12px;font-family:var(--mono)}.viewed-box{display:inline-flex;align-items:center;gap:6px;font:13px var(--sans);color:var(--muted);cursor:pointer;white-space:nowrap}.viewed-box input{accent-color:var(--approve);margin:0}.file.viewed .fhead .name{color:var(--muted)}.tree a.viewed .path{color:var(--faint)}.tree .tick{color:var(--approve);font-weight:700}.fold{border:none;background:none;cursor:pointer;color:var(--muted);padding:0 2px;font-size:13px;width:18px}.fold:hover{color:var(--ink)}.fnote{padding:12px 16px;color:var(--muted);font-size:14px}.scroll{overflow-x:auto;border-radius:0 0 10px 10px}table{border-collapse:collapse;width:100%;font:12.5px/21px var(--mono);font-variant-ligatures:none}td.ln{width:1%;min-width:48px;padding:0 10px;text-align:right;color:var(--faint);user-select:none;cursor:pointer;vertical-align:top;position:relative}tr:not(.hunk):hover td.ln:nth-child(2):before{content:"+";position:absolute;left:-2px;top:1px;width:18px;height:18px;line-height:18px;text-align:center;background:var(--marker);color:var(--on-marker);border-radius:5px;font-weight:700}td.ln:hover{color:var(--ink)}td.code{white-space:pre;padding:0 12px 0 4px}td.code .sign{display:inline-block;width:1.4em;color:var(--faint);user-select:none}tr.add td{background:var(--add)}tr.add td.ln{background:var(--add-gutter)}tr.add .sign{color:var(--add-ink)}tr.del td{background:var(--del)}tr.del td.ln{background:var(--del-gutter)}tr.del .sign{color:var(--del-ink)}mark.wd{color:inherit;padding:0;border-radius:0}tr.add mark.wd{background:var(--add-word)}tr.del mark.wd{background:var(--del-word)}tr.sel mark.wd{background:var(--marker);color:var(--on-marker, #3d2f00)}tr.hunk td{background:var(--hunk);color:var(--muted);padding-top:3px;padding-bottom:3px}tr.sel td{background:var(--marker-soft)!important}tr.sel td.ln{color:var(--ink)}tr.sel td.ln:first-child{box-shadow:inset 3px 0 0 var(--marker)}tr.comment>td{padding:8px 14px 10px 62px;background:var(--panel);font:15px/1.55 var(--sans);white-space:normal;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.note-card{border-left:3px solid var(--marker);padding:4px 0 4px 12px;max-width:80ch}.note-card .meta{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;margin-bottom:2px}.note-card .meta .where{font-family:var(--mono);font-size:12px}.note-card .meta .actions{margin-left:auto;display:flex;gap:2px}.note-card p{margin:0;white-space:pre-wrap}.note-card.question-card{border-left-color:var(--agent)}.kind-pill{color:var(--agent);font-weight:700;font-size:12px}.marks.ask{background:var(--agent-soft);color:var(--agent)}.btn.ask{border-color:var(--agent);color:var(--agent);background:transparent}.btn.ask:hover{background:var(--agent-soft)}.form{max-width:80ch}.form .row{display:flex;align-items:center;gap:8px;margin-top:8px}.form .hint{color:var(--faint);font-size:13px;margin-right:auto}.finish{max-width:76ch;margin-top:44px;padding-top:24px;border-top:1px solid var(--line)}.finish h2{margin:0 0 4px;font-size:19px}.finish p{margin:0 0 12px;color:var(--muted)}.finish .row{display:flex;gap:10px;margin-top:12px;flex-wrap:wrap}.hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-literal{color:var(--syn-keyword)}.hljs-string,.hljs-regexp,.hljs-template-tag,.hljs-addition{color:var(--syn-string)}.hljs-number,.hljs-symbol,.hljs-variable.constant_{color:var(--syn-number)}.hljs-comment,.hljs-quote,.hljs-meta{color:var(--syn-comment);font-style:italic}.hljs-title,.hljs-title.function_,.hljs-section,.hljs-name{color:var(--syn-title)}.hljs-type,.hljs-title.class_,.hljs-selector-class{color:var(--syn-type)}.hljs-attr,.hljs-attribute,.hljs-property,.hljs-params{color:var(--syn-attr)}.hljs-strong{font-weight:700}.hljs-emphasis{font-style:italic}.status{max-width:46ch;margin:22vh auto 0;padding:0 16px}.status h1{font-size:30px;margin:0 0 8px}.status p{color:var(--muted);margin:0;font-size:17px}.status .stamp{display:table;margin-bottom:18px;padding:2px 12px;border-radius:999px;font-weight:700;font-size:14px;animation:stamp .42s cubic-bezier(.2,.9,.3,1.3)}.status .stamp.approve{background:var(--add);color:var(--add-ink)}.status .stamp.request_changes{background:var(--del);color:var(--del-ink)}@keyframes stamp{0%{transform:scale(.6);opacity:0}to{transform:scale(1);opacity:1}}@media(prefers-reduced-motion:reduce){.status .stamp{animation:none}}@media(max-width:900px){.layout{display:block}.tree{display:none}.review{padding:20px 16px 100px}.bar{padding:0 16px;gap:10px}.bar .stats,.bar .marks,.bar .wordmark{display:none}.picker select{max-width:34vw}.bar .btn{padding:6px 10px}.summary{font-size:18px}tr.comment>td{padding-left:14px}}
|