# Reaper — guide for agent sessions Reaper is a self-hosted web tool that finds media nobody watches, **explains why it thinks each item is expendable** (every signal, and every protection that was checked and did *not* fire), lets you review and approve, then removes it safely *through* Sonarr/Radarr and refreshes Plex. Python 3.13 / FastAPI backend + React 19 / Vite frontend, one container. > **Prime directive: Reaper deletes irreplaceable data from a server other people depend on. > Every ambiguity resolves toward keeping the file.** When in doubt, fail closed. **The conventions live in [`CONTRIBUTING.md`](CONTRIBUTING.md), and they bind a session here exactly as they bind a human contributor.** Setup, the verification gates, commit and pull request conventions, how operator-facing text is written, and the AI policy are stated there once, for both audiences. This file holds what is specific to working inside the repository: the numbered rules, the architecture, the safety model, and the documentation discipline. Where the two touch the same subject, CONTRIBUTING is the copy to correct. [`AGENTS.md`](AGENTS.md) is the entry point other agent tools read, and it routes back here. ## Where the engineering rules live 148 numbered blockers, adversarially verified across seven review passes. **The numbers are permanent** — the tests and the archived review passes in `docs/history/` cite them, so never renumber and never reuse a number for a different rule. **Code comments do not cite rule numbers.** A comment states its constraint in plain language a first-time reader gets in one pass; the rule file is where the number lives. A comment that does cite a rule may only cite one that exists; `tests/test_repo_hygiene.py` fails on one that does not. New rules append to the scoped file that governs them, from 149. They live in `.claude/rules/`, scoped by `paths` frontmatter so each set loads when you read a file it governs, and a file must be read before it can be edited. **The scoping is the budget**: a set that loads for work it does not govern is paid for on every unrelated session, so a cluster large enough to notice earns its own file. | File | Governs | Rules | | --- | --- | --- | | `.claude/rules/backend.md` | `src/reaper/**/*.py`, `alembic/**/*.py` — safety path, engine, evidence, clients, persistence | 1–6, 8–10, 13, 22, 23, 26–35, 38, 52, 55–59, 63, 65, 70, 71, 73, 77, 78, 81, 82, 87–97, 102–117, 124, 127–129, 131, 140, 142, 143, 148 | | `.claude/rules/auth.md` | `src/reaper/auth/**`, `secrets.py`, `logbuffer.py`, `services/{backup,restore}.py`, `api/{settings,plex,backup,deps,auth}.py` — credentials, sessions, at-rest key material | 11, 12, 14, 74–76, 83, 84, 98–101, 125, 126, 130 | | `.claude/rules/frontend.md` | `frontend/src/**/*.{ts,tsx,css}` and `frontend/index.html` (rule 69 governs it) — UI grammar, gating surfaces | 17–20, 36, 39–47, 51, 53, 54, 60–62, 66, 67, 69, 79, 80, 85, 86, 138, 139, 146 | | `.claude/rules/review-queue.md` | `ReviewQueue`, `OverrideControls`, `ShowPanel`, `SeasonList`, `reviewFate` — fate, overrides, the two-level spare | 48–50, 120–123 | | `.claude/rules/tests.md` | `tests/**/*.py`, `frontend/src/**/*.test.ts{,x}`, `frontend/src/test/**` — test discipline | 37, 118, 119, 132, 133, 135–137, 141, 145, 147 | Eleven rules bind every file and stay here, under *Rules that apply everywhere*. Where two rules overlap, the more specific one governs. Read the governing file before working in a tree you haven't touched this session. This table, each file's `Holds` line, and the count are one fact written four times, so `test_every_index_of_the_rules_matches_the_rules` checks all of them and names each one that disagrees. **A new rule earns its number, and most candidates do not.** Before appending 149, in order: **extend an existing rule** if one covers the class; **write the gate instead** if the violation is greppable, since `test_repo_hygiene.py` binds an author who never read the rules and prose cannot; then **append**, instruction only, no narrative. A rule narrating the gate that enforces it pays twice for one constraint. ## Golden rules - **Nothing identifying in the tree** — code, docs, tests, and commit messages alike: no real titles, hosts, paths, usernames, or library stats. Findings from real data are recorded as ratios and shapes. **Screenshots are the one exception, and only of the maintainer's own instance.** **Other people are never in the exception**: Scales lists the names of everyone who requested something, so a shot of it is cropped above that list, never retouched. `scripts/gen_screenshot_mockup.py` builds an invented version under `docs/media/review-queue-mockup.png`, kept so reversing this call is a one-line README edit. - **American English everywhere**, including identifiers and commit messages, except names spelled British at the source (`asyncio.CancelledError`, `aria-labelledby`). CONTRIBUTING.md's Writing section is the full copy. - **Treat Reaper as production code.** It will be released; write for an unknown operator, never for one specific server. - **Operator copy is read at a glance, never twice.** A phrase over a sentence, a sentence over two. Lead with the outcome and leave the explanation to help text bound to the control. Help text obeys the same budget, and cut copy is cut, never parked in a code comment. After writing an operator string, cut it once more. Rule 21 governs its vocabulary, this rule its length. - **Mock up UI/UX before touching code.** Load the `reaper-artifact` skill, then present a rendered, self-contained HTML artifact in Reaper's look and feel and iterate on *that* until it is approved — only then edit frontend code. The skill hands you the app's live tokens and component styles so the mockup matches without re-researching them. Iterating on a picture is far cheaper than iterating on a diff. - **Ship additive, non-breaking migrations. Never make a tester rebuild their DB.** CONTRIBUTING.md's Database migrations section has the mechanics: the frozen baseline, one revision per change chained by `down_revision`, nullable `ADD COLUMN`s the next scan backfills. **Schema still has to be able to leave, and rule 148 is the only door.** Removal is a two-release sequence, never an ad-hoc drop; the reasoning lives in `docs/DECISIONS.md`'s Migrations section. - **A change that alters what the app *does* updates `docs/STATUS.md` in the same commit** — edit the line that is now wrong, never append beside it. Measured findings, including negative results, go to `docs/LEARNINGS.md`. **`STATUS.md` records the state, never the route to it, and it is budgeted twice at 120 lines and 100 columns**, both enforced. `docs/README.md`'s "A line budget is not a size budget" section has the incident that shaped the second cap. **A row holds a phrase, never a sentence.** Reasoning behind a locked choice goes to `docs/DECISIONS.md`, one section per daggered row, and `test_repo_hygiene.py` checks the two agree both ways. **Closed work leaves the file entirely** — a shipped fix is not state, its record is the tracker and the code. - **A bug you are not fixing becomes an issue before the session ends — don't wait to be asked.** A defect left in a transcript dies with the session, and "I flagged it in the summary" is not a record. Every defect leaves fixed or filed, in *every* session, not just `/reaper-review`: most are found while building something else. Filing needs no approval; say what you filed in the summary rather than asking first. **A candidate you could not demonstrate is filed too, as a question** — `Status/Need More Info`, no `Reviewed/` label, because an issue asserting a defect must not assert what nobody showed. Promoting it later is one label edit *and* the evidence that settled it, written into the issue; killing it is a close as `Reviewed/Invalid`. The `reaper-review` skill's *Opening issues* section holds every mechanic, label and cap, and binds every session rather than only a review pass — read it before filing. It is stated once, there, so nothing here can drift from it. - **A defect your own unlanded branch created is FIXED on that branch, never filed.** The tracker describes what an operator can hit, and nobody can hit a branch. So the first question about any candidate found while building is not "how severe" but **"is this on `dev`?"** — `git show origin/dev:` and read the line. If the branch introduced it, it belongs in the diff that introduced it, with the test that pins it; filing it instead ships a known-broken change and asks someone else to notice. **When a tracking issue is genuinely wanted anyway** — the fix is deferred, or it spans work someone else holds — the title says so and the body opens with it: `on , not on dev`, plus the base commit and the `git show` that proves the contrast. An issue that does not say which tree it lives on will be verified against `dev`, because that is the only tree a reader has, and it will be closed. - **Commit as you go, and keep the pull request focused — don't wait to be asked.** **The pull request is the unit that tells one story**: a fix ships with the test that pins it and the doc line it corrects, and unrelated work earns its own branch. CONTRIBUTING.md's How work lands section covers the squash mechanics. Put the `Co-Authored-By` trailer at the end of the **PR description**, since that is the text that becomes the commit message. ## Rules that apply everywhere **7 / 24. A comment states what the code must do or must never do, and never argues for it.** Cut history, hypotheticals about a change nobody made, and answers to "why not X". They go in the pull request or `docs/`. Most existing comments break this. Never match their voice or length, whatever an instruction to match surrounding comments says. CONTRIBUTING.md's Code comments section has an example. **A comment may not claim a safeguard that is not implemented, and one that names a safeguard cites the function implementing it** — verified to exist and to be called before merging. If you cannot cite it, correct the comment in the same commit. **15. The shipped artifact keeps building in CI.** Install from the committed lockfile with digest-pinned base images; never let unpinned `>=` floors resolve fresh at build time. CI runs `docker build`, so don't build the image locally to satisfy a gate. **16. Every operator-configurable credential lives in the DB-backed, encrypted, UI-editable surface and is documented in `.env.example`.** Never strand a setting as env-only and undocumented while the UI advertises its outcome. **21. Every operator-facing string is plain language** — frontend strings and backend `detail`/message strings alike. Lead with the outcome and say what it means for their files. Keep internal vocabulary out of it: no rating keys, no tmdb/imdb/tvdb ids, no "collision"/"guard"/"coverage bp"/abstain-as-jargon. If a normal person wouldn't say it, reword it. This binds notices, tooltips, empty states, and error text alike. **No em dashes in operator-facing copy**: reword with a period, comma, or colon. **A middot does not separate two facts either, and neither does a dash**: it is punctuation a screen reader may voice ("40 titles *middle dot* 1.2 TB freed") or drop entirely, so the separator is a comma, which is read as the pause it looks like (#177). Arrows ("Policy → Deletion") are fine. A middot is still fine where it is *only* decoration — a dot between chips, a placeholder for "no value" — and there it carries `aria-hidden`, because nothing is lost by not hearing it. The test is whether a reader who never hears the character still gets the sentence. A string that is plain but long still fails. **25. Operator copy may only reference features that are wired.** Confirm the route or UI path exists before writing text that names a mechanism (backtest, cap, interlock); a DB constraint or schema for an unwired feature is a blocker, not a placeholder. **64. Removing a surface removes its whole supply chain in the same change** — route, schemas, client method, props, query-key invalidations, and comments naming it. Grep for the query key and the prop name before closing. **68. Generated assets ship with their generator.** A comment saying an asset is generated names a committed, runnable script (`frontend/scripts/gen-icons.mjs`, `scripts/policy_lab_extract.py`), and a drift test covers every generated artifact, not just one. **72. A fix lands on every sibling of the thing you fixed, in the same change.** Grep for the siblings before closing, then fix or defer each *in writing*; a "when next touched" deferral is honored the moment ANY commit touches the sibling, not when someone remembers. On its own this covers a copied **function** (paging loops, section resolution, error mapping). Four backend rules say what "sibling" means otherwise: **127** an interlock whose docstring claims *every*, **140** a value you re-qualified, **142** a discriminator you typed, **143** a set whose membership you changed. Same sweep each time, so finding one usually means checking the rest. **134. A gate is judged by its exit code, never by the output you kept.** A pipeline exits with its LAST command's status, so `npm --prefix frontend run build | tail -4` reports `tail`'s success while a failing `tsc` scrolls past, and anything chained after it with `&&` then runs on a broken tree. Run each gate on its own and read the status (`cmd > /tmp/out 2>&1; echo "exit: $?"`), and only pipe a command whose success you are not currently deciding on. `| head` is worse still: it SIGPIPEs the writer, so the command dies partway and reports that as its own result. This binds every verification in this file. **144. Generating one copy of an operator-facing claim raises the risk on every copy you did not generate.** Rule 72 sweeps siblings of a *function* and rule 103 guards a *list* mirroring a declaration; this is the same obligation for a *sentence*. One fact about what the app does is normally stated in several places — a help paragraph, an API description, the error body that fires when it is enforced — each written by someone reading a different one. Deriving one from the code does not make the rest safe; it makes them **more** dangerous, because the derived copy is demonstrably correct and vouches for a consistency that does not exist. So grep the sibling copies of any sentence you are about to generate, and either generate them from the same declaration or **point the generated one's test at them by name** — a failure message naming the other file costs one line, where a comment asking future authors to remember does nothing. A rounded claim is written to reassure, so it fails toward telling the operator the app is safer than it is. ## Branch & merge workflow - **`dev` is the default branch, and all work lands there** (CONTRIBUTING.md's Branches section). Every unit of work gets its own branch that merges back into `dev`. - **Cut that branch from the latest upstream `dev`.** CONTRIBUTING.md's Branches section has the `git fetch origin` / `git checkout -b origin/dev` recipe. **A worktree sharpens this**: the session opens on whatever branch it was cut for, so branching from HEAD can carry someone else's commits in. Check `git log --oneline origin/dev..HEAD` first — empty means current. - **Re-check before you open the PR.** CONTRIBUTING.md covers the rebase; then re-run the gates, since a branch current when cut can still merge into a tree its tests never ran against. - **A pull request carries its `Kind/` and `Priority/` labels, same vocabulary as an issue** (`gh pr create --label "Kind/Bug,Priority/Critical"`, or `gh pr edit --add-label` after the fact). `Reviewed/` is issue triage and stays off a PR. CONTRIBUTING.md's Review and merge section has the rest. - **Landing a branch into `dev` is CI-gated, and the style is `squash`.** It is the only style the repository allows; merge commits and rebase merges are both turned off, so there is no choice to get wrong. Check the run with `gh pr checks ` before merging (a fresh PR sits pending for minutes; `--watch` blocks until it settles), then `gh pr merge --squash `. **The squash commit takes its subject from the PR title and its body from the PR description** (`squash_merge_commit_title=PR_TITLE`, `squash_merge_commit_message=PR_BODY`). `.github/workflows/pr-validation.yml` checks the title parses as a Conventional Commit, because that subject is permanent and feeds the release notes. The head branch is deleted automatically on merge. A **draft will not merge**, but `gh pr ready ` clears it, so a `WIP:` title is a label for humans and no longer a thing to strip. One round-trip cost survives: **a squash-merge lands a sha CI never tested**, since the change is replayed onto whatever `dev` is now, so landing several PRs back to back ends with the gates re-run on the merged `dev` rather than three green per-branch runs. - **`main` is release-only.** Never push to `main` directly. Promote `dev` with a **squash-merged** pull request from a promotion branch, so `main` reads as a sequence of releases while the granular history lives on `dev`. The promotion branch carries `main`'s tip without touching the tree, which moves the PR's merge base to `main` and shrinks the diff to what actually landed: ``` git fetch origin git checkout -b promote- origin/dev git merge -s ours origin/main -m "Merge main back so the promotion diffs against the last release" git push -u origin promote- gh pr create --base main --head promote- --label "Kind/Release,Priority/High" ``` **`Kind/Release` is what keeps this pull request out of the next release's notes.** `.github/release.yml` excludes that label (#934). Then `gh pr merge --squash `. The ours-strategy merge keeps `dev`'s tree bit for bit (release.yml verifies that before tagging) and never conflicts, whatever the histories look like. The head branch deletes itself at merge; the release tag keeps its head commit alive, and that commit's first parent is `dev`'s line, which is what scopes the next release's generated notes to the PRs that landed since this one. ## Verification gates **The gate list and how to run it live in [`CONTRIBUTING.md`](CONTRIBUTING.md#verification-gates)**, along with the rule that the writing form of both formatters runs before anything is staged. Run the relevant subset while iterating and the full set before a commit. When a change is observable in the app, *drive it end-to-end* (the `verify` skill) rather than stopping at green tests. What follows is what a session needs on top of that list. **Read the exit code, not the tail of the output** — rule 134, and the one that silently defeats every other gate. **A green `npm run test` is not a quiet one.** Vitest's console interception drops test console output on some Node versions — nothing on Node 26, printed on CI's pinned Node 24 — so `act()` warnings and unhandled rejections are invisible exactly where you would act on them. Run `npx vitest run --disableConsoleIntercept` to see them locally, or read the CI log. Rule 135 is the standing answer: a test with something to tell you must fail, not warn. **Asking whether CI is green** is cheaper than reading a log: `gh pr checks ` lists one row per job, and `CI gate` is the required check. A `paths` filter that skips a workflow publishes no check run. An `if:` that skips a job still publishes one, marked `skipped`, which `CI gate` counts as a pass. **Which jobs run depends on the files changed**, a PR's diff or, on a push, everything since the branch's last green CI run. `ci.yml`'s `changes` job sorts paths into three lanes, first match winning: `manual/*`/`website/*` is site (#589), `docs/*`/`.claude/*`/ `*.md` is prose, everything else is code. Prose runs `hygiene` alone, code runs `check`, `frontend` and `docker`, and site runs `site`, `hygiene` and `frontend` too, since the guards that read `manual/` and `website/` live in those last two (#783). **The manual publishes from Cloudflare Pages**, which reads `dev` directly, so `site` gates nothing. `codeql.yml` and `weblate-notes.yml` carry their own path lists, and `tests/test_repo_hygiene.py` pins all three by name. **Reading a CI log.** `gh run view --log-failed` is almost always the whole answer: it prints only the failing steps. `gh run list --branch ` finds the run, `gh run view --log` dumps it in full, and `gh run watch ` blocks until it finishes. **Confirm the run is for the sha you think it is** (`gh run view --json headSha`, against `git rev-parse HEAD`) — a squash-merge or a fresh push means the newest run for a branch is often not the commit you are holding, which is the one way to read a green log for someone else's code and believe it. **Both halves of the tree are machine-formatted, so never hand-argue style.** CONTRIBUTING.md carries the division of territory, prettier's root-directory hazard, and what to do if a reformat sweeps files you did not touch. ## Dev environment - **API :8420, frontend :5173** (Vite proxies `/api`). In an interactive session start them via `.claude/launch.json` (`preview_start`, names `reaper-api` / `reaper-frontend`) — never hand-run dev servers. - **Headless / background job (no `preview_start`):** run **`scripts/dev-local.sh`**. It runs preflight and then migrations, in the entrypoint's order, so a staged restore is applied here exactly as it is in the container; then it boots both auto-reloading servers (API `--reload`, Vite HMR) against the shared real `data/`, waits for health, and prints the URLs. `down` stops them, `status` / `logs` inspect. The UI at :5173 is the live dev server, not a build; `npm run build` is a CI gate only. **A second instance beside a running one** is `REAPER_PORT` + `REAPER_WEB_PORT`, and the two move together: `REAPER_PORT` reaches uvicorn *and* Vite, whose `/api` proxy target reads it (`frontend/vite.config.ts`). Setting only the web port leaves the second UI talking to the first instance's API. Those two ports also name its logs (`.dev-logs/api-.log`, in the main checkout whichever tree booted it), so pass them to `down` / `logs` / `status` as well, or those commands answer for the default instance instead. - API calls require the header **`X-Reaper-CSRF: 1`**; auth is a cookie session. - Secrets live in a gitignored **`.env.local`**; `data/` (`reaper.db`, `cache.db`) is gitignored and rebuildable. Never paste real keys into the transcript or a commit. ## Architecture - `src/reaper/clients/` — the **only** place HTTP lives (one sanctioned exception, `notify/discord.py`'s webhook POST; see rule 33). `GuardedTransport` (and its `GuardedSession` twin for plexapi) refuses any mutating request unless deletion is armed on the host **and** the executor declared the intent to the journal first. - `src/reaper/engine/` — `gates` (hard, fail-closed protections), `signals` (soft weighted pressure; `score()` is **unsigned** pressure over a fixed denominator, so missing or keep-arguing evidence can only *lower* the score — a signed score off a neutral baseline inverts under failure, see the "Why unsigned" note atop `signals.py`), `verdict.decide_verdict` (the one condemn/abstain/protect decision), and the "why" record. - `src/reaper/services/` — `snapshot` (gather → freeze → hash → score), `planner` (build the journalled plan), `executor` (the real send + interlocks), plus grace, leaving_soon, scan_runner, whitelist. - `src/reaper/api/` — FastAPI routers. `frontend/src/` — the React SPA. - A **scan is a snapshot**: all evidence is frozen and hashed *before* scoring, so a transient timeout can never flip an item's fate mid-run. ## Safety model Two **independent** layers sit under every mutation, and neither is trusted alone: 1. The executor's `dry_run` (the default) walks every interlock and records what it *would* send, but sends nothing. 2. The transport guard refuses any mutating call unless the host is armed **and** the intent was journalled first — a property of the host a browser cannot reach. Deletion is armed only from the UI (password-gated). The **one** route that deletes is `POST /api/runs/{id}/execute`; it requires the host armed and the exact content-bound confirmation phrase, recomputed server-side. The scheduler never deletes. The executor's interlocks (manifest re-check, caps that abort-not-truncate, the canary, the per-item streaming veto and played-since-approval check) each resolve toward keeping the file. ## Where things are documented - `docs/README.md` — what belongs in which file, and the rule that keeps them current. - `docs/STATUS.md` — **start here.** What is true right now: milestones, open work, decisions locked. Small and edited in place, budgeted (see Golden rules). - `docs/DECISIONS.md` — why each locked decision is what it is, one section per daggered row of `STATUS.md`'s table. Read the row, then this if you are about to change the behavior it describes: several of these decisions were reversed once already, and the reversal is the part a future reader needs most. - `docs/LEARNINGS.md`, `docs/SIGNALS.md` — findings from real data. `SIGNALS.md` is cited from six places in `src/`: `engine/signals.py`, `engine/policy.py` (three times), `engine/gates.py`, and `api/review.py`. Read it before touching any of them: it is also the only place the rewatch curve is written down, now that the engines that measured it are gone. - No plan is live. `I18N_PLAN.md` and `RETURN_PLAN.md` were the last two and are frozen into `docs/history/` (#862 and #553 closed them). `docs/README.md`'s map is the list to correct. - `docs/history/` — frozen: the retired plan narratives and the review passes, including the finding IDs behind the numbered rules. Never edit an archived file to bring it up to date.