SLOPSHOPPER

nvim-review

Picks up code reviews exported from Neovim (review.nvim) into .review/inbox and hands them to Claude as MR-style review comments

newcommandtoaststatusprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nvim-review
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /nvim-review ⎿ nvim-review: No review waiting (nothing in /work/app/.review/inbox). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Structure

This flake uses the dendritic pattern via Den: flake-parts + import-tree auto-discover every .nix file under modules/, and each file declares one den.aspects.<name> covering both the NixOS and Home Manager sides of a single concern.

Per-host values (e.g. paths) are typed options declared on the host schema via den.schema.host.imports (see modules/programs/games/battlenet.nix), set on the host as den.hosts.<system>.<host>.<option>, and read in an aspect through its host argument.

Installation

Every host (NixOS or not) is an age recipient in .sops.yaml - after either install path below, import the GPG key and register the host's age key per docs/sops.md, then rebuild once secrets can decrypt.

NixOS

  1. Boot a NixOS installer image. See docs/iso-wifi.md for wireless bring-up inside installation ISO.
  2. Partition and format disks to match the target host's modules/hosts/<host>/_hardware-configuration.nix
  3. Install: `` nixos-install --flake github:faku99/nix#<host> ``
  4. Reboot

Nix daemon only

  1. Install Nix on the existing distro
  2. Clone this repo.
  3. First run `` nix run home-manager -- switch --flake github:faku99/nix#<home> ` Afterwards, home-manager switch --flake .#<home>` from a local clone.
Source 1 files
hooks/register.ts 89 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Neovim drops exported reviews here, under the git root.
4const INBOX = '.review/inbox'
5const DONE = '.review/done'
6const POLL_MS = 1500
7
8// How Claude should treat the review. Questions get answers, not code changes:
9// that's the whole point of reviewing "why" the code is the way it is.
10const PREAMBLE = `Here is my code review of the current changes, in the format of MR review comments.
11Handle each comment in order:
12- If it is a question ("why…?", "is this needed?"), answer it with your reasoning. Do NOT change code for a question unless your answer concludes the code is wrong — then say so and propose the fix.
13- If it is a change request, make the change.
14- If you disagree with a request, say why instead of silently complying.
15Reply comment by comment, quoting the file:line each answer refers to. Finish with a short list of what you changed.
16
17`
18
19// Set while a review is between "picked up" and "turn started", so the timer
20// never submits the same review twice or two at once.
21let inFlight = false
22
23// Git root of the session's working directory, so a subdirectory start still works.
24async function reviewRoot($: EngineInterface): Promise<string> {
25  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
26  const root = top.exitCode === 0 ? top.stdout.trim() : ''
27  return root === '' ? '' : `${root}/`
28}
29
30// Pick up the oldest review in the inbox, if any. Returns what happened, for /nvim-review.
31async function pickUp($: EngineInterface): Promise<string> {
32  if (inFlight) return 'A review is already queued for Claude.'
33  const root = await reviewRoot($)
34  const inbox = root + INBOX
35  const done = root + DONE
36  if (!(await $.fs.exists(inbox))) return `No review waiting (nothing in ${inbox}).`
37
38  const entries = await $.fs.list(inbox)
39  const pending = entries
40    .filter(f => f.kind === 'file' && f.name.endsWith('.md'))
41    .sort((a, b) => a.mtimeMs - b.mtimeMs)
42  if (pending.length === 0) return 'No review waiting (nothing in .review/inbox).'
43
44  // Claim it by moving it out of the inbox first. If another Claude Code session
45  // in the same repo got there before us, mv fails and that session handles it.
46  const name = pending[0].name
47  await $.fs.write(`${done}/.keep`, '')
48  const moved = await $.process.run(['mv', `${inbox}/${name}`, `${done}/${name}`])
49  if (moved.exitCode !== 0) return 'Another session took that review.'
50
51  const markdown = await $.fs.read(`${done}/${name}`)
52  if (markdown.trim() === '') return 'The waiting review was empty; skipped.'
53
54  inFlight = true
55  $.ui.status(`review ${name} queued`)
56  $.ui.toast(`Review received from Neovim (${name})`)
57  // Waits for the session to be idle, then starts the turn. Not awaited, so a
58  // review exported while Claude is busy simply queues behind the current turn.
59  void $.prompt
60    .submit({ text: PREAMBLE + markdown, asUser: true })
61    .finally(() => {
62      inFlight = false
63      $.ui.status(undefined)
64    })
65  return `Sent review ${name} to Claude.`
66}
67
68export const register: Register = on => {
69  on('session.start', async ($, e, next) => {
70    // Watch the inbox: a review exported from Neovim reaches Claude with no typing.
71    $.clock.every(POLL_MS, async () => {
72      await pickUp($)
73    })
74    try {
75      await $.command.register({
76        name: 'nvim-review',
77        description: 'Send the next review exported from Neovim to Claude now',
78      })
79    } catch {
80      // A name clash only costs the manual command; the watcher still runs.
81    }
82    return next(e)
83  })
84
85  on('command.run', { command: 'nvim-review' }, async $ => {
86    return { text: await pickUp($) }
87  })
88}
89