AIDive

Video-Paket

Das Retro-Schleifen-Kit: aus einer gescheiterten Claude Code Session einen Check machen

9 Min. Lesezeit

TL;DR und Fazit

Wenn Claude Code eine Regel ignoriert, ist der Reflex eine lautere Zeile in der CLAUDE.md. Die Doku sagt, warum das scheitert: Claude behandelt diese Dateien als Kontext, nicht als erzwungene Konfiguration, und um eine Aktion zu blockieren, nutzt man stattdessen einen Hook. s4 Die Retro-Schleife geht den anderen Weg: weniger Zeilen und ein Check, der sich nicht ignorieren lässt.

In unserem Lauf baute eine Claude Code Session ein Gutschrift-Feature für eine kleine Rechnungs-API und lieferte einen Nummerierungsfehler aus, mit grünen eigenen Tests. Ein Retro auf dieser Session, dem gesagt wurde, was schiefging, schlug vor, was es verhindert hätte. Wir gaben vier Änderungen von Hand frei: einen Pre-Commit-Hook, einen Test, eine Standards-Zeile und einen Kommentar an der Quelle. Eine frische Session mit derselben Anfrage lieferte die Nummern dann lückenlos aus.

Das Fazit:

  • Starte einen Retro nach einer Session, die schiefgegangen ist, bevor du sie leerst, oder auf dem Log der alten Session.
  • Lass ihn nicht im Autopiloten laufen. Er schlägt nur vor, und nichts ändert sich, bis du einen Kandidaten auswählst. s2
  • Ordne jeden Kandidaten einem Moment des Transcripts zu und streiche die, die du nicht zuordnen kannst.
  • Leite jeden Überlebenden in den richtigen Träger: einen Check für einen mechanischen Fehler, eine Standards-Zeile für eine Ermessensfrage, einen Kommentar an der Quelle für eine Navigationslücke.
  • Die Checks, die du hinzufügst, sind Code, den du jetzt pflegst. Der Retro prüft nicht die Checks, die er letzten Monat vorgeschlagen hat. s2

Das Pack unten enthält die Checkliste, das Hook-Skript und den Nummerierungstest aus unserem Lauf.

Quellenbasierte Synthese

Was der Retro ist. Er ist ein Skill in Matt Pococks Sammlung. Er wird nur vom Nutzer aufgerufen (disable-model-invocation: true) und sucht in sieben Kategorien nach Kandidaten: Navigation, Automated checks, Coding standards, Global AGENTS.md, Tool economy, No-ops und Information access. s1 Sein letzter Schritt präsentiert die Kandidaten nur, nach Schweregrad geordnet. s1 Er wurde in v1.3.0 ins Claude Code Plugin übernommen. s22

Wie man ihn installiert. Die Installationszeile lautet claude plugin install mattpocock-skills@claude-plugins-official. s25 Eine Falle: Ein Issue meldete, dass der Retro-Befehl bei der Installation über den offiziellen Marketplace fehlte. s24 Der Marketplace-Eintrag pinnt inzwischen einen neueren Commit. s26 Auf unserer Maschine stand der lokale Marketplace noch auf dem alten Pin, also führten wir zuerst das Marketplace-Update aus, dann die Installation und prüften den Befehl in einer neuen Session.

Warum das Log und nicht der Code. Claude Code speichert Transcripts als JSONL unter ~/.claude/projects/<project>/<session-id>.jsonl, und claude --resume <transcript-path> setzt eine Session aus dieser Datei fort. s8 Das Format ist intern und ändert sich zwischen Versionen, baue also keine Skripte darauf. s8 Die Aufbewahrung beträgt 30 Tage und ist mit cleanupPeriodDays konfigurierbar. s8

Der Retro ist laut, bis du ihn zurückverfolgst. Die Doku rät, jeden Kandidaten zu verwerfen, den du keinem konkreten Moment zuordnen kannst. s2 Sie warnt außerdem, dass das Modell dazu neigt, die Schwierigkeiten aus der Mitte der Session zu vergessen, und generische Ratschläge erfindet, um die Kategorien zu füllen. s2 Ein offenes Issue zählt 17 Retro-Läufe über 12 Sessions, 15 davon gefolgt von der Bitte des Nutzers an den Agenten, zu beweisen, dass die Kandidaten nötig waren, und fordert, dass null Überlebende als vollständiger Retro zählen. s7

Leite jeden Überlebenden weiter. Ein Repo ohne Guardrail, also ohne Pre-Commit-Hook und ohne CI-Job, der seinen Lint-, Typecheck- oder Test-Befehl ausführt, ist selbst ein Fund. s1 Ein mechanischer Verstoß bekommt einen deterministischen Check, Punkt. s1 Steering-Dateien sollten incredibly sparingly verwendet werden, meist nur für Navigationsverweise auf andere Dateien. s1 Ein Entwickler, der in Produktion eine Lessons-Schleife betreibt, kommt zur selben Leiter: Eine Regel, die zurückrutscht, wird ein Test, eine Lint-Regel oder ein Hook. s6

Halte die CLAUDE.md klein. Die Doku peilt unter 200 Zeilen pro CLAUDE.md an, weil längere Dateien mehr Kontext verbrauchen und die Befolgung senken. s4 Ihr Test für jede Zeile: Würde das Entfernen dazu führen, dass Claude Fehler macht? Wenn nicht, streichen. s9 Den Aufblähungs-Fehlermodus, eine ignorierte Regel, die eine lautere Regel und eine längere Datei bekommt, beschreibt ein Entwickler. s10

Schließe die Schleife. Die Doku sagt, behandle die CLAUDE.md wie Code: überprüfe sie, wenn etwas schiefgeht, miste sie regelmäßig aus und teste Änderungen, indem du beobachtest, ob sich Claudes Verhalten tatsächlich ändert. s9 /doctor prompt-audit braucht Claude Code v2.1.283 oder neuer, meldet Funde mit vorgeschlagenen Änderungen und ändert nichts, bis du darum bittest. s4 Er läuft über den mitgelieferten claude-api Skill und ist deshalb nicht verfügbar, solange dieser Skill ausgeschaltet ist. s4 Und ein offenes Issue beschreibt, wie der Retro freigegebene Änderungen umsetzt und dann den Commit vergisst. s23

Was wir in unserem Lauf sahen. Der erste Retro begann mit "This session was smooth", weil die Session nie erfahren hatte, dass sie gescheitert war. Erst nachdem wir den Fehler als Argument gemeldet hatten, fand er die Ursache, eine vor der Validierung genommene Nummer. Details in den Messungen unten.

Code

Jede Datei ist die echte aus unserem Lauf oder das Skript, mit dem wir ihn ausgeführt haben. Passe die Pfade an dein Repo an.

retro-checklist.md ist die Checkliste der Seite als Datei: die sieben Schritte, die Trace-Frage Wort für Wort, die vier Träger, die Commit-Erinnerung und die Pfad-Falle beim prompt-audit.

retro-checklist.md

# The retro checklist

Run it after a session that went wrong, before you clear it, or later on the old session's log.

## The seven gestures

1. Install: `claude plugin marketplace update claude-plugins-official`, then `claude plugin install mattpocock-skills@claude-plugins-official --scope project`. Open a NEW session, type a slash, check that the retro command is listed.
2. Run your real task in a plain session. Let it finish. Keep the session open.
3. Run the retro in that session, then run it again with what failed written after it, as its argument. The retro reads the session log, and a session that never learned it failed will call itself smooth.
4. Trace every candidate. Type this, word for word:

   > For each candidate, quote the moment of this session's transcript that backs it; drop any candidate you cannot trace to a specific moment.

   Zero survivors is a valid retro. Weaker survivors are a judgement call: you decide.
5. Approve one survivor at a time, each into its carrier (below). One change per turn, read the diff after each.
6. Run the prompt audit WITH a path, apply its edits if any, then commit the files yourself. The retro proposes and edits, it does not commit.
7. Re-run the same task in a fresh session and watch what it reads first and whether the failure happens again.

## The four carriers

| What failed | Carrier | Example from our run |
| --- | --- | --- |
| A mechanical failure | A deterministic check: a pre-commit hook, a test, a lint rule, a CI job | `pre-commit.sh` (typecheck, then tests) and `numbering.test.ts` |
| A judgement call | One line in `CODING_STANDARDS.md`, read by the reviewer | `CODING_STANDARDS.md` |
| A navigation gap | A comment at the source, or one CLAUDE.md pointer if a comment cannot do it | A doc comment above `nextId` |
| Nothing the above can hold | Do not write a rule | Both retros declined a CLAUDE.md line |

## Traps

- Commit reminder: the retro can implement what you approve and then leave the files uncommitted. Stage the approved files and commit them yourself.
- A new hook checks the working tree, not the commit. If the tree still holds the bug, the hook refuses the commit. Fix the code first, then commit.
- A cherry-pick does not run the pre-commit hook.
- Prompt-audit path trap: `/doctor prompt-audit` with no path audited the user-level files under `~/.claude`, not the repo, and said nothing about the new files. Give it the path: `/doctor prompt-audit CODING_STANDARDS.md`.
- The retro has no dry-run mode. Try a proposed check against the repo before you let it block merges.
- Do not wire the retro to a session-end hook. Keep a human on the approval.
- Keep the transcript format out of your scripts: it is internal and changes between versions.

retro-checklist.md on GitHub

pre-commit.sh ist der Hook, den wir freigegeben haben. Er führt den Typecheck aus, dann die Tests, und blockt den Commit, wenn einer fehlschlägt. Speichere ihn als .githooks/pre-commit, mache ihn ausführbar und führe git config core.hooksPath .githooks aus.

pre-commit.sh

#!/bin/sh
set -e
pnpm typecheck
pnpm test

pre-commit.sh on GitHub

numbering.test.ts ist der vitest-Test, den wir freigegeben haben. Er nagelt eine Regel fest: Eine abgelehnte Gutschrift-Anfrage verbrennt keine Nummer.

numbering.test.ts

import { describe, expect, it } from "vitest";
import { seedCustomer, seedOpenInvoice, testApp } from "./helpers";

describe("credit note numbering", () => {
  it("does not burn a number on a rejected request", async () => {
    const app = testApp();
    const customerId = await seedCustomer(app);
    const id = await seedOpenInvoice(app, { customerId, unitPrice: 10_000 });
    await app.handle("POST", `/invoices/${id}/pay`);
    const url = `/invoices/${id}/credit-notes`;

    const first = await app.handle("POST", url, { amount: 4_000 });
    expect(first.body).toMatchObject({ number: "CN-0001" });

    const rejected = await app.handle("POST", url, { amount: 6_001 });
    expect(rejected.status).not.toBe(201);

    const next = await app.handle("POST", url, { amount: 6_000 });
    expect(next.status).toBe(201);
    expect(next.body).toMatchObject({ number: "CN-0002" });
  });
});

numbering.test.ts on GitHub

CODING_STANDARDS.md ist die eine Standards-Zeile, die wir freigegeben haben, die Datei, die ein Reviewer liest.

CODING_STANDARDS.md

# Coding standards

- Take a sequence number (`nextId`) only once every validation has passed, so a rejected request leaves the sequence gapless.

CODING_STANDARDS.md on GitHub

retro-output.md ist der volle Text beider Retro-Durchläufe und beider Trace-Antworten aus unserer Session.

retro-output.md

# Retro output, session A (cb5884fb-bd6f-4684-8866-d0adc12ac26e)

## 1. `/retro` (resumed headless, 2026-10-23 10:39:44, $0.0658, 17.5 s, 5 turns)

Prompt typed: `/retro` -> transcript records `<command-name>/mattpocock-skills:retro</command-name>`. Tool calls: Skill(mattpocock-skills:writing-for-agents), Bash(ls -a; ls .github; ls .git/hooks | grep -v sample; cat tsconfig.json), Bash(find .claude -type f; cat .claude/settings*.json).

Verbatim:

> This session was smooth. The pass came out clean on the first typecheck and test run, and I found no `AGENTS.md`/`CLAUDE.md` or `CODING_STANDARDS.md` to prune. The candidates below are in order of severity.
> 
> **1. No guardrail runs the checks (automated checks)**
> - `package.json` already has `test` and `typecheck`, but nothing runs them: there's no `.github/` workflow and no pre-commit hook.
> - This session only stayed green because I ran both by hand. An agent, or a person, can commit a type error or a failing test.
> - **Fix:** add a CI job running `pnpm typecheck && pnpm test`, plus a pre-commit hook running the same. Neither needs prose.
> 
> **2. The migration rule is the one convention that could be missed (automated checks)**
> - The README says "any change to the stored shape bumps `schemaVersion` and adds a migration".
> - I found it only because I read `README.md` while working out the design. If I'd skipped it, nothing would have failed. Adding a field to `StoreData` without a migration still typechecks.
> - **Fix:** a test that fails when someone changes the stored shape without bumping the version. For example, snapshot the output of `migrate({})` and assert the snapshot's `schemaVersion` equals `CURRENT_SCHEMA_VERSION`. This is a deterministic check, so it should replace the README sentence rather than add to it.
> 
> **3. Adding a counter or store collection touches five places (navigation)**
> - Adding credit notes meant editing `types.ts`, `json-store.ts` (the `empty()` function), `migrations.ts` (the migration, plus the fallback defaults at the bottom of `migrate`), `context.ts`, and a new repo file.
> - The `{ customer: 0, invoice: 0, invoiceNumber: 0 }` literal is duplicated in `json-store.ts` and `migrations.ts`. I had to find both with `grep`.
> - **Fix:** have `migrate` build its defaults from one shared `emptyData()`. That removes a hidden dependency rather than documenting it. A pointer in a steering file would cost context on every turn and go stale, so I wouldn't write one.
> 
> **4. Nothing for the reviewer to enforce (coding standards)**
> - There's no `CODING_STANDARDS.md`. The README's conventions are mostly mechanical: integer cents, domain functions that don't mutate their input, and which error type maps to which status.
> - A reviewer agent would have no judgement-call rules to apply. I wouldn't add any until a real review miss shows one is needed.
> 
> **5. Tool economy and information access: nothing to change**
> - My one failed Bash call (a rejected heredoc) cost a retry, but that was my command and not the tooling.
> - I had everything I needed, since the tests call `createApp().handle` directly with no server or logs to tee.
> 
> I'd start with 1 and 2. They're small, mechanical, and would catch the likeliest future mistake. Want me to add the CI workflow, the pre-commit hook and the schema-version test?

## 2. Trace step (same session, 10:40:33, $0.0322, 17.1 s, 1 turn)

Prompt: "For each candidate, quote the moment of this session's transcript that backs it; drop any candidate you cannot trace to a specific moment."

Verbatim:

> Two of the five candidates have no support in the transcript, and I got a detail wrong in a third. Here is each one against what actually happened.
> 
> **1. No guardrail: kept**
> - The moment is my `ls -a; ls .github 2>/dev/null; ls .git/hooks | grep -v sample` command. The listing shows `.claude .git .gitignore data node_modules package.json … vitest.config.ts`. There is no `.github`, and the hooks `grep` printed nothing.
> - It isn't a mistake I made, since my own `pnpm typecheck` and `pnpm test` run came back clean. It's a repo-state finding: the checks exist as scripts and nothing runs them.
> 
> **2. Migration rule has no check: dropped**
> - I can't point to a moment where I missed or nearly missed a migration. I read `migrations.ts` in my first big `cat`, before I'd seen the README. I would have found the migration step either way, so my claim that I found it only through the README was wrong.
> - "Nothing would have failed if I'd skipped it" is a hypothetical, not something that happened.
> 
> **3. Five places to touch for a new collection: kept, but weaker than I said**
> - The moment is my batch of edits to `types.ts`, `json-store.ts`, `migrations.ts` and `context.ts`. Two of them changed the same literal, `{ customer: 0, invoice: 0, invoiceNumber: 0 }`, which now reads `…, creditNote: 0`. It appears in both `json-store.ts` and the fallback in `migrate`.
> - I said I "had to find both with `grep`". That's wrong. I never ran one, since the `grep` was in the command that was rejected. I already knew both locations from reading the files, and the edits went through without friction.
> - The duplicated literal is real but caused no trouble. I'd rate it low severity.
> 
> **4. No `CODING_STANDARDS.md`: dropped**
> - The only moment is the `git ls-files` output showing the file doesn't exist. No review step happened in this session and no reviewer missed anything.
> - "I wouldn't add any until a real review miss shows one is needed" was my own opinion, not something the session showed.
> 
> **5. Tool economy: dropped as a finding**
> - The moment is my first Bash call being rejected with `Contains brace with quote character (expansion obfuscation)`. It cost one retry, and I switched to Write and Edit.
> - It was a one-off, and I can't tell from the transcript whether the harness or my heredoc-in-Python approach was at fault. I don't think it justifies a change.
> 
> That leaves candidate 1 as the only solid finding and candidate 3 as a minor one. For 1, I can add a CI workflow and a pre-commit hook that both run `pnpm typecheck && pnpm test`.

## 3. Second `/retro`, with the defect reported by the developer (same session, 10:41:10, $0.0491, 23.1 s, 1 turn, no tool call; it skipped the writing-for-agents reload)

Prompt: `/retro Finance tested this build after the session: they sent a credit note that was too big (rejected, fine), and the next valid credit note came out CN-0004 where CN-0002 was due. Rejected requests burn credit-note numbers. Don't fix the code; run the retro on this session with that failure in mind.`

Verbatim:

> I'm skipping the style-guide reload because it's already in this session. The cause is in my own code, and I traced each finding to a specific moment.
> 
> **What happened**
> - In `issueCreditNote` (`src/http/handlers/invoices.ts`) I call `ctx.store.nextId("creditNote")` first and `createCreditNote(...)` second. `nextId` persists the counter immediately, and `createCreditNote` is what throws the 409 for an over-limit amount. So a rejected request still burns a number.
> - I copied the order from the existing `/issue` handler, which I had read. It does `const n = ctx.store.nextId("invoiceNumber"); const issued = issue(current, ...)`, and `issue()` throws if the invoice isn't a draft. So issuing a non-draft invoice probably burns an `INV-` number the same way. I haven't run that, only read it.
> 
> **1. A test that would have caught it: I wrote the scenario and skipped the assertion (automated checks)**
> - The moment is the test I wrote: "rejects bad amounts (400) and amounts above what is left (409)".
>   ```
>   await app.handle("POST", ..., { amount: 8_000 });
>   expect((await app.handle("POST", ..., { amount: 1_001 })).status).toBe(409);
>   expect((await app.handle("POST", ..., { amount: 1_000 })).status).toBe(201);
>   ```
>   That is exactly Finance's sequence: accept, reject, accept. I only checked status codes, never the number on the last note, which comes out as `CN-0003`.
> - The "numbers credit notes sequentially" test only covers two successful requests in a row.
> - **Fix:** add a test that each numbered sequence (`INV-`, `CN-`) stays gapless after rejected requests. Run it over both sequences, which also exposes the `/issue` case.
> - **Rule:** when a test reaches a failure path, assert the state afterwards, not just the status code.
> 
> **2. `nextId` looks pure but isn't (navigation)**
> - In `json-store.ts`, `nextId(kind)` runs `this.update(...)`, so every call writes to the counter and persists it. The `Store` type in `json-store.ts` has only the signature `nextId(kind: keyof Counters): number;`, with no comment about the side effect.
> - Nothing at the call site tells you to allocate only after validation. This is a gotcha no config reveals, so a short doc comment on `nextId` is worth its cost. Something like: "Consumes the number even if the caller then fails; call it only after validation."
> - Keep it as a comment on `nextId`, not a steering-file line. It matters only to someone calling `nextId`, and it can't go stale there.
> 
> **3. The gapless-numbering convention isn't written down (coding standards)**
> - The README's "Conventions" list has cents, immutability, error-to-status mapping, and migrations. It says nothing about invoice or credit-note numbers being gapless, though the request ("numbered `CN-0001`, `CN-0002`...") implies it.
> - This is partly a judgement call: reviewers need to know that numbers are allocated after validation. One README line would do. Finding 1's test is the deterministic check; the README line explains why the test exists.
> 
> **4. Missing CI, from the first retro: still real, but it wouldn't have helped here**
> - My own typecheck and tests passed, so a CI job would also have passed. I'm keeping it as a separate, unrelated finding, not as a cause of this failure.
> 
> **5. My summary stated the rules but didn't test them**
> - I told you "Rules I chose" and flagged the two-update void risk, but never exercised a rejection followed by a success. The summary read as complete when that path was untested. This is a process gap, and finding 1 is the fix.
> 
> I'd start with 1 and 2. I haven't touched the code, as you asked.

## 4. Trace step on the second retro (same session, 10:41:43, $0.0498, 33.4 s, 1 turn)

Prompt: same trace prompt as section 2.

Verbatim:

> I traced each candidate again. Two survive on firm evidence, two survive but with a gap I should state, and one is dropped.
> 
> **1. Test asserts status but not the number: kept, firm**
> - The moment is the test I wrote in `test/credit-notes.test.ts`:
>   ```
>   await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 8_000 });
>   expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1_001 })).status).toBe(409);
>   expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1_000 })).status).toBe(201);
>   ```
>   That is accept, reject, accept, with only status codes checked. By my reading of the handler, the last note comes out as `CN-0003`. I derived that and haven't run it.
> - The adjacent test, "numbers credit notes sequentially…", covers two successes in a row and never a rejection.
> - The cause, which I only read and haven't run, is this in the handler I wrote:
>   ```
>   const id = ctx.store.nextId("creditNote");
>   const note = createCreditNote(invoice, existing, amount, …
>   ```
>   That order matches the existing issue handler, which I read:
>   ```
>   const n = ctx.store.nextId("invoiceNumber");
>   const issued = issue(current, `INV-…`, ctx.now());
>   ```
>   and `issue()` throws `ConflictError` for non-drafts. So the `INV-` sequence very likely has the same leak. I haven't run that either.
> 
> **2. `nextId` has no warning about its side effect: kept, firm on the facts**
> - The moment is the `Store` type I read in `json-store.ts`. `update` has a comment ("Apply a change and persist it (when file-backed)."), while `nextId(kind: keyof Counters): number;` has none. Its implementation calls `this.update(...)`, so it persists immediately.
> - The `/issue` call site I copied has no comment either.
> - I can't show that a comment would have changed what I did, because I never weighed the ordering at all. I copied it. The comment is a bet on the next caller, not a proven fix.
> 
> **3. Gapless numbering isn't written in the README: kept, weaker**
> - The moment is the `README.md` output I read before writing the handler. Its Conventions list covers cents, no mutation, error-to-status mapping and `schemaVersion`. It has no line about numbers.
> - So the agent doing the implementation, meaning me, read the conventions and found nothing on this.
> - I overstated one thing last turn. Your request said "numbered `CN-0001`, `CN-0002`...", and I said that implies gapless numbers. That was my inference, not your words. Finance has now confirmed it as a requirement, but nothing in the original request stated it.
> 
> **4. No CI or pre-commit hook: kept, unrelated to this failure**
> - The moment is my `ls -a; ls .github 2>/dev/null; ls .git/hooks | grep -v sample` output, which showed no `.github` and no hooks.
> - It wouldn't have caught this. My own run ended `Tests 42 passed (42)`, and CI would have passed too.
> 
> **5. "My summary stated rules without testing them": dropped**
> - I can quote the summary ("Rules I chose", plus the line about the void being two separate store updates). But it describes my behaviour, not a change to the environment, and finding 1 already covers the underlying gap. Keeping it would count one moment twice.
> 
> The order of importance is 1, 2, then 3. Finding 4 stays on the list but belongs to a separate piece of work. I still haven't changed any code.

retro-output.md on GitHub

doctor-prompt-audit.md ist der prompt-audit-Lauf ohne Pfad, dann mit Pfad. Er zeigt die Pfad-Falle.

doctor-prompt-audit.md

# `/doctor prompt-audit` (no path) on the ledgerly copy, session 26612b59-9edd-493b-a397-fb54300fa037

# Prompt audit: Claude Code configuration for ledgerly

**Assumptions**
- **Target model:** Claude Sonnet 5.5, the model running this session. A skill that writes prompts for other models (`prompt-master`) is audited for its Claude-facing text only.
- **Scope:** only the Claude Code configuration that loads into this project, as you specified. I proposed no edits to plugin files.
- **Settings and secrets:** I did not read `.claude/settings.json`, `.mcp.json` or `~/.claude.json`.
- **Edits are not applied.** Every edit below touches `~/.claude/`, so it **affects all projects**.
- **Dependency not verified:** I could not verify that `~/.claude` is under git, so I used no `git blame`. No hunk below relies on history.

**Inventory**
- **Project files:** none. The project root has no `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md` or `.claude/` instruction files. The ancestors `/path/to/work` and `/private/tmp` have none either.
- **Managed policy:** no managed-policy `CLAUDE.md` exists.
- **User level:**
  - `~/.claude/CLAUDE.md`, which imports `~/.claude/RTK.md`.
  - `~/.claude/rules/context7.md`.
  - 27 skills under `~/.claude/skills/*/SKILL.md`.
  - There are no `commands/`, `agents/` or `output-styles/` folders.
- **Plugins:** `caveman`, `marketing`, `cowork-plugin-management` and `gleam-reels` are installed. I did not audit them in depth, and they have no findings or edits.
- **Coverage gap:** I read `CLAUDE.md`, `RTK.md`, the rule, `context7-mcp`, most of `prompt-master`, the relevant parts of `graphify`, and the top of `agent-reach`. I covered the other skills (Cloudflare, HyperFrames, browser, media) with signal greps and frontmatter only. I did not read their `references/` subfiles.
- **Possible mismatch:** this session's skill list shows none of the `~/.claude/skills/*` entries. These files may not be loading in this project, so the skill findings may not affect it.


## Findings of the first run

The audit without a path found no project instruction files and went on to audit the user-level files under the home folder instead: six proposed edits and four flags, all about files outside this repository, none applied. That part is left out of this copy because it concerns one developer's personal configuration, not the project.

# Second run: `/doctor prompt-audit CODING_STANDARDS.md` (session 9b5f0cd5-1701-498e-8d05-0355c7f1ece6)

## Prompt audit: `CODING_STANDARDS.md`

**Assumptions**
- **Scope:** only `CODING_STANDARDS.md`, which the request names. The inventory found no `CLAUDE.md`, `AGENTS.md`, rule files, skills, commands or subagents in the repo. `.claude/settings.json` exists but I did not read it, per the audit rules.
- **Target model:** Claude Sonnet 5.5, the model running this audit. The file is read by the coding agent, not by application code.
- **Group 4 (request config and architecture):** not applicable. The file is an agent instruction file and has no request-building code.

**Result: the file is clean.** It has one line, so there is nothing to change and the proposed diff is empty.

**Inventory:** `CODING_STANDARDS.md` has one rule at line 3: "Take a sequence number (`nextId`) only once every validation has passed, so a rejected request leaves the sequence gapless."

**Checks**

| Group | Findings |
|---|---|
| 1 Dated prompt text | 0. The rule has no emphasis, hedging, scaffold or step list. It states a reason, so it is context the model can't know, not a prohibition without provenance. |
| 2 Brittle config files | 0. `git blame` puts the line in `adaa593`, the retro commit that followed the credit-note session. It encodes a failure that actually happened, not a one-off stumble. |
| 3 Tool descriptions | Not applicable. |
| 4 Request config and architecture | Not applicable. |

Group 2 also covers stale facts. `nextId` is still defined (`src/store/json-store.ts:11,52`) and the sequence kinds it names still exist. No other instruction file ranks on the same point, so there is no conflict.

**One thing outside the audit:** the rule is violated in the code. `src/http/handlers/invoices.ts:44` calls `ctx.store.nextId("invoiceNumber")` before `issue()` runs. `issue()` throws `ConflictError` for any non-draft invoice (`src/domain/invoice.ts:42-44`). Issuing an already-issued, paid or void invoice therefore consumes an invoice number and leaves a gap. The fix is to validate first, or to pass an allocator into `issue()` the way `createCreditNote` takes `allocate`. I didn't check `customers.ts:8` or `credit-note.ts`.

This is a code bug, not a prompt problem, and the rule is working as intended. Keep it, and consider an enforcing test: `test/numbering.test.ts` may only cover credit notes. I haven't edited anything.

doctor-prompt-audit.md on GitHub

run-session.sh führt einen headless Claude Code Turn in einem isolierten Setup nur für das Projekt mit einer Allow-Liste aus. Passe die env-Zeile an deine eigene an.

run-session.sh

#!/usr/bin/env bash
# Usage: run-session.sh <workdir> <out.json> <prompt> [resume-session-id]
# One headless Claude Code turn with an isolated project-only setup and an allow-list.
set -uo pipefail
W="$1"; OUT="$2"; PROMPT="$3"; SID="${4:-}"
source ./env.sh  # your own env: unset CLAUDE* variables, optionally CLAUDE_CODE_DISABLE_AUTO_MEMORY=1
ALLOWED="Read,Edit,Write,MultiEdit,Glob,Grep,Skill,TodoWrite,Task,Agent,Bash(node:*),Bash(npm:*),Bash(pnpm:*),Bash(npx vitest:*),Bash(npx tsc:*),Bash(tsc:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git add:*),Bash(git commit:*),Bash(git checkout -b:*),Bash(ls:*),Bash(cat:*),Bash(mkdir:*),Bash(head:*),Bash(tail:*),Bash(grep:*),Bash(wc:*),Bash(gh:*)"
DIS="AskUserQuestion,EnterPlanMode,ExitPlanMode,EnterWorktree,ExitWorktree,WebFetch,WebSearch"
ARGS=(-p "$PROMPT")
[ -n "$SID" ] && ARGS+=(--resume "$SID")
cd "$W" || exit 1
T0=$(date +%s)
claude "${ARGS[@]}" --model claude-sonnet-5-5 --permission-mode acceptEdits --output-format json \
  --allowedTools "$ALLOWED" --disallowedTools "$DIS" --setting-sources project --strict-mcp-config \
  > "$OUT" 2> "$OUT.stderr" < /dev/null
RC=$?
echo "rc=$RC wall=$(( $(date +%s) - T0 ))s" | tee "$OUT.wall"
python3 -c "import json,sys;d=json.load(open(sys.argv[1]));print({k:d.get(k) for k in ('session_id','total_cost_usd','duration_ms','num_turns','is_error','subtype')})" "$OUT"

run-session.sh on GitHub

oracle.sh führt den versteckten Finanztest aus, fünf von außerhalb der Session geprüfte Anforderungen, gegen eine Arbeitskopie. Richte ihn auf deine eigenen versteckten Tests.

oracle.sh

#!/usr/bin/env bash
# Usage: oracle.sh <workdir> <out.json>  -- the hidden finance test for the credit-note task, run from OUTSIDE the work copy.
H=/path/to/hidden-tests
W="$1"; OUT="$2"
cd "$H" && env -u CLAUDECODE WORK="$W" BASE="$(git -C "$W" rev-list --max-parents=0 HEAD | head -1)" TASK=F2 \
  "$H/node_modules/.bin/vitest" run --reporter=json --outputFile="$OUT" > "$OUT.log" 2>&1
python3 - "$OUT" <<'PY'
import json,sys
j=json.load(open(sys.argv[1]))
for tr in j["testResults"]:
    for a in tr["assertionResults"]:
        print(a["status"].upper(), a["title"]); 
        if a["status"]!="passed": print("   ", " ".join(a.get("failureMessages") or [])[:500].replace("\n"," | "))
PY

oracle.sh on GitHub

sessionA-F2.diff ist der Code der Session, die den Bug auslieferte, mit dem Fix.

sessionA-F2.diff

diff --git a/README.md b/README.md
index 890f13c..ae1e4f9 100644
--- a/README.md
+++ b/README.md
@@ -27,4 +27,8 @@ pnpm typecheck   # tsc --noEmit
 
 ## Invoice lifecycle
 
-`draft` → (`issue`) → `open` → (`pay`) → `paid`; `draft`/`open` → (`void`) → `void`.
+`draft` → (`issue`) → `open` → (`pay`) → `paid`; `draft`/`open`/`paid` → (`void`) → `void`.
+
+Credit notes (`POST`/`GET /invoices/:id/credit-notes`, numbered `CN-0001`, ...) can be issued against
+`open` or `paid` invoices, up to the invoice total minus earlier credit notes. Open invoices' credits
+reduce the outstanding report. Voiding a paid invoice issues a credit note for whatever is left.
diff --git a/src/domain/credit-note.ts b/src/domain/credit-note.ts
new file mode 100644
index 0000000..9a48b40
--- /dev/null
+++ b/src/domain/credit-note.ts
@@ -0,0 +1,43 @@
+import { ConflictError, ValidationError } from "./errors";
+import { computeTotals, type Invoice } from "./invoice";
+import { assertCents, type Cents, type Currency, sum } from "./money";
+
+export type CreditNote = {
+  id: string;
+  /** CN-0001, ... */
+  number: string;
+  invoiceId: string;
+  currency: Currency;
+  /** cents, always positive */
+  amount: Cents;
+  createdAt: string;
+};
+
+export function parseCreditNoteAmount(body: unknown): Cents {
+  if (typeof body !== "object" || body === null) {
+    throw new ValidationError("body must be an object");
+  }
+  return assertCents((body as Record<string, unknown>).amount, "amount", { min: 1 });
+}
+
+/** What is still creditable on an invoice: its total minus the credit notes already issued. */
+export function remainingCredit(invoice: Invoice, notes: CreditNote[]): Cents {
+  return computeTotals(invoice).total - sum(notes.map((n) => n.amount));
+}
+
+export function createCreditNote(
+  invoice: Invoice,
+  existing: CreditNote[],
+  amount: Cents,
+  ids: { id: string; number: string },
+  now: Date,
+): CreditNote {
+  if (invoice.status !== "open" && invoice.status !== "paid") {
+    throw new ConflictError(`invoice ${invoice.id} is ${invoice.status}, only open or paid invoices can be credited`);
+  }
+  const remaining = remainingCredit(invoice, existing);
+  if (amount > remaining) {
+    throw new ConflictError(`credit note of ${amount} exceeds the ${remaining} still creditable on invoice ${invoice.id}`);
+  }
+  return { ...ids, invoiceId: invoice.id, currency: invoice.currency, amount, createdAt: now.toISOString() };
+}
diff --git a/src/domain/invoice.ts b/src/domain/invoice.ts
index 5b69fc9..1a3967b 100644
--- a/src/domain/invoice.ts
+++ b/src/domain/invoice.ts
@@ -52,8 +52,9 @@ export function markPaid(invoice: Invoice, now: Date): Invoice {
   return { ...invoice, status: "paid", paidAt: now.toISOString() };
 }
 
+/** Paid invoices can be voided too, but the caller must credit what is left on them first. */
 export function voidInvoice(invoice: Invoice, now: Date): Invoice {
-  if (invoice.status === "paid" || invoice.status === "void") {
+  if (invoice.status === "void") {
     throw new ConflictError(`invoice ${invoice.id} is ${invoice.status} and cannot be voided`);
   }
   return { ...invoice, status: "void", voidedAt: now.toISOString() };
diff --git a/src/http/context.ts b/src/http/context.ts
index c0a89d3..38d7ffa 100644
--- a/src/http/context.ts
+++ b/src/http/context.ts
@@ -1,5 +1,6 @@
 import type { Store } from "../store/json-store";
 import { customerRepo } from "../store/customer-repo";
+import { creditNoteRepo } from "../store/credit-note-repo";
 import { invoiceRepo } from "../store/invoice-repo";
 
 export type AppContext = {
@@ -7,8 +8,9 @@ export type AppContext = {
   now: () => Date;
   customers: ReturnType<typeof customerRepo>;
   invoices: ReturnType<typeof invoiceRepo>;
+  creditNotes: ReturnType<typeof creditNoteRepo>;
 };
 
 export function createContext(store: Store, now: () => Date = () => new Date()): AppContext {
-  return { store, now, customers: customerRepo(store), invoices: invoiceRepo(store) };
+  return { store, now, customers: customerRepo(store), invoices: invoiceRepo(store), creditNotes: creditNoteRepo(store) };
 }
diff --git a/src/http/handlers/invoices.ts b/src/http/handlers/invoices.ts
index 09959e1..17b3910 100644
--- a/src/http/handlers/invoices.ts
+++ b/src/http/handlers/invoices.ts
@@ -1,3 +1,4 @@
+import { type CreditNote, createCreditNote, parseCreditNoteAmount, remainingCredit } from "../../domain/credit-note";
 import { ValidationError } from "../../domain/errors";
 import { type Invoice, type InvoiceStatus, issue, markPaid, voidInvoice } from "../../domain/invoice";
 import { parseNewInvoice } from "../../domain/validation";
@@ -51,8 +52,33 @@ export function invoiceRoutes(router: Router, ctx: AppContext): void {
     return { status: 200, body: invoiceView(ctx.invoices.save(paid)) };
   });
 
+  /** Voiding a paid invoice credits whatever is left on it, so the books stay balanced. */
   router.on("POST", "/invoices/:id/void", ({ params }) => {
-    const voided = voidInvoice(ctx.invoices.get(params.id), ctx.now());
+    const current = ctx.invoices.get(params.id);
+    const now = ctx.now();
+    const voided = voidInvoice(current, now);
+    if (current.status === "paid") {
+      const left = remainingCredit(current, ctx.creditNotes.listForInvoice(current.id));
+      if (left > 0) issueCreditNote(current, left);
+    }
     return { status: 200, body: invoiceView(ctx.invoices.save(voided)) };
   });
+
+  router.on("POST", "/invoices/:id/credit-notes", ({ params, body }) => {
+    const invoice = ctx.invoices.get(params.id);
+    const amount = parseCreditNoteAmount(body);
+    return { status: 201, body: issueCreditNote(invoice, amount) };
+  });
+
+  router.on("GET", "/invoices/:id/credit-notes", ({ params }) => {
+    const invoice = ctx.invoices.get(params.id);
+    return { status: 200, body: ctx.creditNotes.listForInvoice(invoice.id) };
+  });
+
+  function issueCreditNote(invoice: Invoice, amount: number): CreditNote {
+    const existing = ctx.creditNotes.listForInvoice(invoice.id);
+    const id = ctx.store.nextId("creditNote");
+    const note = createCreditNote(invoice, existing, amount, { id: `cn_${id}`, number: `CN-${String(id).padStart(4, "0")}` }, ctx.now());
+    return ctx.creditNotes.insert(note);
+  }
 }
diff --git a/src/http/handlers/reports.ts b/src/http/handlers/reports.ts
index 19262c9..c84089a 100644
--- a/src/http/handlers/reports.ts
+++ b/src/http/handlers/reports.ts
@@ -1,4 +1,5 @@
-import { computeTotals, isOverdue } from "../../domain/invoice";
+import { remainingCredit } from "../../domain/credit-note";
+import { isOverdue } from "../../domain/invoice";
 import type { Currency } from "../../domain/money";
 import type { AppContext } from "../context";
 import type { Router } from "../router";
@@ -11,8 +12,8 @@ export function reportRoutes(router: Router, ctx: AppContext): void {
     let count = 0;
     let overdue = 0;
     for (const invoice of ctx.invoices.list({ status: "open" })) {
-      const { total } = computeTotals(invoice);
-      totals[invoice.currency] = (totals[invoice.currency] ?? 0) + total;
+      const owed = remainingCredit(invoice, ctx.creditNotes.listForInvoice(invoice.id));
+      totals[invoice.currency] = (totals[invoice.currency] ?? 0) + owed;
       count += 1;
       if (isOverdue(invoice, now)) overdue += 1;
     }
diff --git a/src/store/credit-note-repo.ts b/src/store/credit-note-repo.ts
new file mode 100644
index 0000000..c922142
--- /dev/null
+++ b/src/store/credit-note-repo.ts
@@ -0,0 +1,14 @@
+import type { CreditNote } from "../domain/credit-note";
+import type { Store } from "./json-store";
+
+export function creditNoteRepo(store: Store) {
+  return {
+    listForInvoice(invoiceId: string): CreditNote[] {
+      return store.read().creditNotes.filter((n) => n.invoiceId === invoiceId);
+    },
+    insert(note: CreditNote): CreditNote {
+      store.update((d) => ({ ...d, creditNotes: [...d.creditNotes, note] }));
+      return note;
+    },
+  };
+}
diff --git a/src/store/json-store.ts b/src/store/json-store.ts
index b404c51..99205b7 100644
--- a/src/store/json-store.ts
+++ b/src/store/json-store.ts
@@ -13,9 +13,10 @@ export type Store = {
 function empty(): StoreData {
   return {
     schemaVersion: CURRENT_SCHEMA_VERSION,
-    counters: { customer: 0, invoice: 0, invoiceNumber: 0 },
+    counters: { customer: 0, invoice: 0, invoiceNumber: 0, creditNote: 0 },
     customers: [],
     invoices: [],
+    creditNotes: [],
   };
 }
 
diff --git a/src/store/migrations.ts b/src/store/migrations.ts
index c8b73a9..91d4f99 100644
--- a/src/store/migrations.ts
+++ b/src/store/migrations.ts
@@ -1,6 +1,6 @@
 import type { StoreData } from "./types";
 
-export const CURRENT_SCHEMA_VERSION = 1;
+export const CURRENT_SCHEMA_VERSION = 2;
 
 type RawData = Record<string, unknown> & { schemaVersion?: number };
 type Migration = (data: RawData) => RawData;
@@ -21,9 +21,18 @@ const v0ToV1: Migration = (data) => {
   };
 };
 
+/** v1 -> v2: credit notes (a `creditNotes` list and a `creditNote` counter). */
+const v1ToV2: Migration = (data) => ({
+  ...data,
+  schemaVersion: 2,
+  counters: { creditNote: 0, ...(data.counters as Record<string, number> | undefined) },
+  creditNotes: (data.creditNotes as unknown[] | undefined) ?? [],
+});
+
 /** migrations[n] upgrades a file from version n to n + 1. */
 const migrations: Record<number, Migration> = {
   0: v0ToV1,
+  1: v1ToV2,
 };
 
 export function migrate(raw: unknown): StoreData {
@@ -43,8 +52,9 @@ export function migrate(raw: unknown): StoreData {
   }
   return {
     schemaVersion: CURRENT_SCHEMA_VERSION,
-    counters: (data.counters as StoreData["counters"]) ?? { customer: 0, invoice: 0, invoiceNumber: 0 },
+    counters: (data.counters as StoreData["counters"]) ?? { customer: 0, invoice: 0, invoiceNumber: 0, creditNote: 0 },
     customers: (data.customers as StoreData["customers"]) ?? [],
     invoices: (data.invoices as StoreData["invoices"]) ?? [],
+    creditNotes: (data.creditNotes as StoreData["creditNotes"]) ?? [],
   };
 }
diff --git a/src/store/types.ts b/src/store/types.ts
index 222b191..a9d7277 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -1,7 +1,8 @@
+import type { CreditNote } from "../domain/credit-note";
 import type { Customer } from "../domain/customer";
 import type { Invoice } from "../domain/invoice";
 
-export type Counters = { customer: number; invoice: number; invoiceNumber: number };
+export type Counters = { customer: number; invoice: number; invoiceNumber: number; creditNote: number };
 
 /** The shape of the JSON file at CURRENT_SCHEMA_VERSION. */
 export type StoreData = {
@@ -9,4 +10,5 @@ export type StoreData = {
   counters: Counters;
   customers: Customer[];
   invoices: Invoice[];
+  creditNotes: CreditNote[];
 };
diff --git a/test/credit-notes.test.ts b/test/credit-notes.test.ts
new file mode 100644
index 0000000..f841a3c
--- /dev/null
+++ b/test/credit-notes.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vitest";
+import { openStore } from "../src/store/json-store";
+import { migrate } from "../src/store/migrations";
+import { seedCustomer, seedOpenInvoice, testApp } from "./helpers";
+
+async function setup(opts: { unitPrice?: number; discount?: number } = {}) {
+  const app = testApp();
+  const customerId = await seedCustomer(app);
+  const id = await seedOpenInvoice(app, { customerId, unitPrice: opts.unitPrice ?? 10_000, discount: opts.discount });
+  return { app, id };
+}
+
+describe("credit notes", () => {
+  it("numbers credit notes sequentially and lists them per invoice", async () => {
+    const { app, id } = await setup();
+    const first = await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 2_500 });
+    const second = await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1_000 });
+    expect(first.status).toBe(201);
+    expect(first.body).toMatchObject({ number: "CN-0001", invoiceId: id, amount: 2_500, currency: "EUR" });
+    expect(second.body).toMatchObject({ number: "CN-0002" });
+    const list = await app.handle("GET", `/invoices/${id}/credit-notes`);
+    expect((list.body as { number: string }[]).map((n) => n.number)).toEqual(["CN-0001", "CN-0002"]);
+  });
+
+  it("rejects bad amounts (400) and amounts above what is left (409)", async () => {
+    const { app, id } = await setup({ discount: 1_000 });
+    for (const amount of [0, -5, 1.5, "10", undefined]) {
+      expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount })).status).toBe(400);
+    }
+    await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 8_000 });
+    expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1_001 })).status).toBe(409);
+    expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1_000 })).status).toBe(201);
+  });
+
+  it("refuses drafts and unknown invoices", async () => {
+    const app = testApp();
+    const customerId = await seedCustomer(app);
+    const draft = await app.handle("POST", "/invoices", {
+      customerId,
+      currency: "EUR",
+      lines: [{ description: "x", quantity: 1, unitPrice: 100 }],
+    });
+    const id = (draft.body as { id: string }).id;
+    expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 10 })).status).toBe(409);
+    expect((await app.handle("GET", "/invoices/inv_999/credit-notes")).status).toBe(404);
+  });
+
+  it("reduces the outstanding report for open invoices", async () => {
+    const { app, id } = await setup();
+    await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 4_000 });
+    const report = await app.handle("GET", "/reports/outstanding");
+    expect(report.body).toMatchObject({ totals: { EUR: 6_000 }, count: 1 });
+  });
+
+  it("voids a paid invoice by crediting what is left", async () => {
+    const { app, id } = await setup();
+    await app.handle("POST", `/invoices/${id}/pay`);
+    await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 3_000 });
+    const res = await app.handle("POST", `/invoices/${id}/void`);
+    expect(res.status).toBe(200);
+    expect(res.body).toMatchObject({ status: "void" });
+    const notes = (await app.handle("GET", `/invoices/${id}/credit-notes`)).body as { amount: number }[];
+    expect(notes.map((n) => n.amount)).toEqual([3_000, 7_000]);
+  });
+
+  it("voids a fully credited paid invoice without another note", async () => {
+    const { app, id } = await setup();
+    await app.handle("POST", `/invoices/${id}/pay`);
+    await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 10_000 });
+    expect((await app.handle("POST", `/invoices/${id}/void`)).status).toBe(200);
+    const notes = (await app.handle("GET", `/invoices/${id}/credit-notes`)).body as unknown[];
+    expect(notes).toHaveLength(1);
+  });
+
+  it("refuses credit notes on void invoices and double voids", async () => {
+    const { app, id } = await setup();
+    await app.handle("POST", `/invoices/${id}/pay`);
+    await app.handle("POST", `/invoices/${id}/void`);
+    expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1 })).status).toBe(409);
+    expect((await app.handle("POST", `/invoices/${id}/void`)).status).toBe(409);
+  });
+
+  it("migrates a v1 file by adding the credit note list and counter", () => {
+    const data = migrate({
+      schemaVersion: 1,
+      counters: { customer: 1, invoice: 1, invoiceNumber: 1 },
+      customers: [],
+      invoices: [],
+    });
+    expect(data.creditNotes).toEqual([]);
+    expect(data.counters).toEqual({ customer: 1, invoice: 1, invoiceNumber: 1, creditNote: 0 });
+    expect(openStore().nextId("creditNote")).toBe(1);
+  });
+});
diff --git a/test/invoice.test.ts b/test/invoice.test.ts
index 85202f2..4ad9677 100644
--- a/test/invoice.test.ts
+++ b/test/invoice.test.ts
@@ -53,9 +53,10 @@ describe("invoice domain", () => {
     expect(() => markPaid(draft({ status: "void" }), NOW)).toThrow(ConflictError);
   });
 
-  it("voids drafts and open invoices, never paid ones", () => {
+  it("voids drafts, open and paid invoices, never void ones", () => {
     expect(voidInvoice(draft(), NOW).status).toBe("void");
-    expect(() => voidInvoice(draft({ status: "paid" }), NOW)).toThrow(ConflictError);
+    expect(voidInvoice(draft({ status: "paid" }), NOW).status).toBe("void");
+    expect(() => voidInvoice(draft({ status: "void" }), NOW)).toThrow(ConflictError);
   });
 
   it("knows when an open invoice is overdue", () => {

sessionA-F2.diff on GitHub

sessionB-F2.diff ist der Code der frischen Session, die dieselbe Anfrage erneut ausführte.

sessionB-F2.diff

diff --git a/README.md b/README.md
index 890f13c..3aa7a58 100644
--- a/README.md
+++ b/README.md
@@ -28,3 +28,5 @@ pnpm typecheck   # tsc --noEmit
 ## Invoice lifecycle
 
 `draft` → (`issue`) → `open` → (`pay`) → `paid`; `draft`/`open` → (`void`) → `void`.
+A `paid` invoice is voided by crediting whatever is left on it (credit notes `CN-0001`, ..., via
+`POST /invoices/:id/credit-notes` `{ "amount": cents }`; list with `GET`), then it becomes `void`.
diff --git a/src/domain/credit-note.ts b/src/domain/credit-note.ts
new file mode 100644
index 0000000..e2047c3
--- /dev/null
+++ b/src/domain/credit-note.ts
@@ -0,0 +1,39 @@
+import { ConflictError, ValidationError } from "./errors";
+import { computeTotals, type Invoice } from "./invoice";
+import { assertCents, type Cents, type Currency, sum } from "./money";
+
+export type CreditNote = {
+  id: string;
+  /** CN-0001, CN-0002, ... */
+  number: string;
+  invoiceId: string;
+  currency: Currency;
+  /** cents, always > 0 */
+  amount: Cents;
+  createdAt: string;
+};
+
+/** What can still be credited on an invoice: its total minus the credit notes already issued. */
+export function remainingCredit(invoice: Invoice, notes: CreditNote[]): Cents {
+  return computeTotals(invoice).total - sum(notes.map((n) => n.amount));
+}
+
+/** Checks a credit-note request against the invoice and returns the amount. Throws before any number is taken. */
+export function parseCreditAmount(body: unknown, invoice: Invoice, notes: CreditNote[]): Cents {
+  if (typeof body !== "object" || body === null) {
+    throw new ValidationError("body must be an object");
+  }
+  const amount = assertCents((body as Record<string, unknown>).amount, "amount", { min: 1 });
+  if (invoice.status !== "paid") {
+    throw new ConflictError(`invoice ${invoice.id} is ${invoice.status}, only paid invoices can be credited`);
+  }
+  const remaining = remainingCredit(invoice, notes);
+  if (amount > remaining) {
+    throw new ConflictError(`amount ${amount} exceeds the ${remaining} left to credit on invoice ${invoice.id}`);
+  }
+  return amount;
+}
+
+export function creditNumber(n: number): string {
+  return `CN-${String(n).padStart(4, "0")}`;
+}
diff --git a/src/domain/invoice.ts b/src/domain/invoice.ts
index 5b69fc9..60be8ac 100644
--- a/src/domain/invoice.ts
+++ b/src/domain/invoice.ts
@@ -52,8 +52,9 @@ export function markPaid(invoice: Invoice, now: Date): Invoice {
   return { ...invoice, status: "paid", paidAt: now.toISOString() };
 }
 
-export function voidInvoice(invoice: Invoice, now: Date): Invoice {
-  if (invoice.status === "paid" || invoice.status === "void") {
+/** A paid invoice can be voided only once it is fully credited (`fullyCredited`). */
+export function voidInvoice(invoice: Invoice, now: Date, opts: { fullyCredited?: boolean } = {}): Invoice {
+  if ((invoice.status === "paid" && !opts.fullyCredited) || invoice.status === "void") {
     throw new ConflictError(`invoice ${invoice.id} is ${invoice.status} and cannot be voided`);
   }
   return { ...invoice, status: "void", voidedAt: now.toISOString() };
diff --git a/src/http/context.ts b/src/http/context.ts
index c0a89d3..4b26d92 100644
--- a/src/http/context.ts
+++ b/src/http/context.ts
@@ -1,4 +1,5 @@
 import type { Store } from "../store/json-store";
+import { creditNoteRepo } from "../store/credit-note-repo";
 import { customerRepo } from "../store/customer-repo";
 import { invoiceRepo } from "../store/invoice-repo";
 
@@ -7,8 +8,15 @@ export type AppContext = {
   now: () => Date;
   customers: ReturnType<typeof customerRepo>;
   invoices: ReturnType<typeof invoiceRepo>;
+  creditNotes: ReturnType<typeof creditNoteRepo>;
 };
 
 export function createContext(store: Store, now: () => Date = () => new Date()): AppContext {
-  return { store, now, customers: customerRepo(store), invoices: invoiceRepo(store) };
+  return {
+    store,
+    now,
+    customers: customerRepo(store),
+    invoices: invoiceRepo(store),
+    creditNotes: creditNoteRepo(store),
+  };
 }
diff --git a/src/http/handlers/invoices.ts b/src/http/handlers/invoices.ts
index 09959e1..d393c34 100644
--- a/src/http/handlers/invoices.ts
+++ b/src/http/handlers/invoices.ts
@@ -1,3 +1,4 @@
+import { type CreditNote, creditNumber, parseCreditAmount, remainingCredit } from "../../domain/credit-note";
 import { ValidationError } from "../../domain/errors";
 import { type Invoice, type InvoiceStatus, issue, markPaid, voidInvoice } from "../../domain/invoice";
 import { parseNewInvoice } from "../../domain/validation";
@@ -51,8 +52,39 @@ export function invoiceRoutes(router: Router, ctx: AppContext): void {
     return { status: 200, body: invoiceView(ctx.invoices.save(paid)) };
   });
 
+  router.on("POST", "/invoices/:id/credit-notes", ({ params, body }) => {
+    const invoice = ctx.invoices.get(params.id);
+    const amount = parseCreditAmount(body, invoice, ctx.creditNotes.listForInvoice(invoice.id));
+    return { status: 201, body: ctx.creditNotes.insert(newCreditNote(ctx, invoice, amount)) };
+  });
+
+  router.on("GET", "/invoices/:id/credit-notes", ({ params }) => {
+    const invoice = ctx.invoices.get(params.id);
+    return { status: 200, body: ctx.creditNotes.listForInvoice(invoice.id) };
+  });
+
   router.on("POST", "/invoices/:id/void", ({ params }) => {
-    const voided = voidInvoice(ctx.invoices.get(params.id), ctx.now());
+    const current = ctx.invoices.get(params.id);
+    if (current.status !== "paid") {
+      return { status: 200, body: invoiceView(ctx.invoices.save(voidInvoice(current, ctx.now()))) };
+    }
+    // Paid invoice: credit whatever is left, then void it.
+    const left = remainingCredit(current, ctx.creditNotes.listForInvoice(current.id));
+    if (left > 0) ctx.creditNotes.insert(newCreditNote(ctx, current, left));
+    const voided = voidInvoice(current, ctx.now(), { fullyCredited: true });
     return { status: 200, body: invoiceView(ctx.invoices.save(voided)) };
   });
 }
+
+/** Takes the next number: call only once the amount has been validated. */
+function newCreditNote(ctx: AppContext, invoice: Invoice, amount: number): CreditNote {
+  const n = ctx.store.nextId("creditNote");
+  return {
+    id: `cn_${n}`,
+    number: creditNumber(n),
+    invoiceId: invoice.id,
+    currency: invoice.currency,
+    amount,
+    createdAt: ctx.now().toISOString(),
+  };
+}
diff --git a/src/store/credit-note-repo.ts b/src/store/credit-note-repo.ts
new file mode 100644
index 0000000..c922142
--- /dev/null
+++ b/src/store/credit-note-repo.ts
@@ -0,0 +1,14 @@
+import type { CreditNote } from "../domain/credit-note";
+import type { Store } from "./json-store";
+
+export function creditNoteRepo(store: Store) {
+  return {
+    listForInvoice(invoiceId: string): CreditNote[] {
+      return store.read().creditNotes.filter((n) => n.invoiceId === invoiceId);
+    },
+    insert(note: CreditNote): CreditNote {
+      store.update((d) => ({ ...d, creditNotes: [...d.creditNotes, note] }));
+      return note;
+    },
+  };
+}
diff --git a/src/store/json-store.ts b/src/store/json-store.ts
index e1daef5..03df910 100644
--- a/src/store/json-store.ts
+++ b/src/store/json-store.ts
@@ -14,9 +14,10 @@ export type Store = {
 function empty(): StoreData {
   return {
     schemaVersion: CURRENT_SCHEMA_VERSION,
-    counters: { customer: 0, invoice: 0, invoiceNumber: 0 },
+    counters: { customer: 0, invoice: 0, invoiceNumber: 0, creditNote: 0 },
     customers: [],
     invoices: [],
+    creditNotes: [],
   };
 }
 
diff --git a/src/store/migrations.ts b/src/store/migrations.ts
index c8b73a9..e8545db 100644
--- a/src/store/migrations.ts
+++ b/src/store/migrations.ts
@@ -1,6 +1,6 @@
 import type { StoreData } from "./types";
 
-export const CURRENT_SCHEMA_VERSION = 1;
+export const CURRENT_SCHEMA_VERSION = 2;
 
 type RawData = Record<string, unknown> & { schemaVersion?: number };
 type Migration = (data: RawData) => RawData;
@@ -21,9 +21,18 @@ const v0ToV1: Migration = (data) => {
   };
 };
 
+/** v1 -> v2: credit notes (`creditNotes` list and `counters.creditNote`). */
+const v1ToV2: Migration = (data) => ({
+  ...data,
+  schemaVersion: 2,
+  counters: { ...(data.counters as object), creditNote: 0 },
+  creditNotes: [],
+});
+
 /** migrations[n] upgrades a file from version n to n + 1. */
 const migrations: Record<number, Migration> = {
   0: v0ToV1,
+  1: v1ToV2,
 };
 
 export function migrate(raw: unknown): StoreData {
@@ -43,8 +52,9 @@ export function migrate(raw: unknown): StoreData {
   }
   return {
     schemaVersion: CURRENT_SCHEMA_VERSION,
-    counters: (data.counters as StoreData["counters"]) ?? { customer: 0, invoice: 0, invoiceNumber: 0 },
+    counters: (data.counters as StoreData["counters"]) ?? { customer: 0, invoice: 0, invoiceNumber: 0, creditNote: 0 },
     customers: (data.customers as StoreData["customers"]) ?? [],
     invoices: (data.invoices as StoreData["invoices"]) ?? [],
+    creditNotes: (data.creditNotes as StoreData["creditNotes"]) ?? [],
   };
 }
diff --git a/src/store/types.ts b/src/store/types.ts
index 222b191..a9d7277 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -1,7 +1,8 @@
+import type { CreditNote } from "../domain/credit-note";
 import type { Customer } from "../domain/customer";
 import type { Invoice } from "../domain/invoice";
 
-export type Counters = { customer: number; invoice: number; invoiceNumber: number };
+export type Counters = { customer: number; invoice: number; invoiceNumber: number; creditNote: number };
 
 /** The shape of the JSON file at CURRENT_SCHEMA_VERSION. */
 export type StoreData = {
@@ -9,4 +10,5 @@ export type StoreData = {
   counters: Counters;
   customers: Customer[];
   invoices: Invoice[];
+  creditNotes: CreditNote[];
 };
diff --git a/test/invoice.test.ts b/test/invoice.test.ts
index 85202f2..97ef4d9 100644
--- a/test/invoice.test.ts
+++ b/test/invoice.test.ts
@@ -53,9 +53,10 @@ describe("invoice domain", () => {
     expect(() => markPaid(draft({ status: "void" }), NOW)).toThrow(ConflictError);
   });
 
-  it("voids drafts and open invoices, never paid ones", () => {
+  it("voids drafts and open invoices, paid ones only when fully credited", () => {
     expect(voidInvoice(draft(), NOW).status).toBe("void");
     expect(() => voidInvoice(draft({ status: "paid" }), NOW)).toThrow(ConflictError);
+    expect(voidInvoice(draft({ status: "paid" }), NOW, { fullyCredited: true }).status).toBe("void");
   });
 
   it("knows when an open invoice is overdue", () => {
diff --git a/test/numbering.test.ts b/test/numbering.test.ts
index a97d189..1fa4dd8 100644
--- a/test/numbering.test.ts
+++ b/test/numbering.test.ts
@@ -20,3 +20,34 @@ describe("credit note numbering", () => {
     expect(next.body).toMatchObject({ number: "CN-0002" });
   });
 });
+
+describe("credit notes and voiding", () => {
+  it("lists credit notes and voids a paid invoice by crediting the rest", async () => {
+    const app = testApp();
+    const customerId = await seedCustomer(app);
+    const id = await seedOpenInvoice(app, { customerId, unitPrice: 10_000 });
+    await app.handle("POST", `/invoices/${id}/pay`);
+    await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 2_500 });
+
+    const voided = await app.handle("POST", `/invoices/${id}/void`);
+    expect(voided.status).toBe(200);
+    expect(voided.body).toMatchObject({ status: "void" });
+
+    const list = await app.handle("GET", `/invoices/${id}/credit-notes`);
+    expect((list.body as { number: string; amount: number }[]).map((n) => [n.number, n.amount])).toEqual([
+      ["CN-0001", 2_500],
+      ["CN-0002", 7_500],
+    ]);
+  });
+
+  it("rejects bad amounts and unpaid invoices", async () => {
+    const app = testApp();
+    const customerId = await seedCustomer(app);
+    const id = await seedOpenInvoice(app, { customerId });
+    const url = `/invoices/${id}/credit-notes`;
+    expect((await app.handle("POST", url, { amount: 100 })).status).toBe(409);
+    await app.handle("POST", `/invoices/${id}/pay`);
+    expect((await app.handle("POST", url, { amount: 0 })).status).toBe(400);
+    expect((await app.handle("POST", url, {})).status).toBe(400);
+  });
+});
diff --git a/test/store.test.ts b/test/store.test.ts
index a1bf345..82cc59f 100644
--- a/test/store.test.ts
+++ b/test/store.test.ts
@@ -31,7 +31,7 @@ describe("store", () => {
     writeFileSync(
       file,
       JSON.stringify({
-        counters: { customer: 1, invoice: 1, invoiceNumber: 0 },
+        counters: { customer: 1, invoice: 1, invoiceNumber: 0, creditNote: 0 },
         customers: [{ id: "cus_1", name: "Acme", email: "a@acme.io", createdAt: "2025-01-01T00:00:00.000Z" }],
         invoices: [{ id: "inv_1", customer: { id: "cus_1", name: "Acme" }, status: "draft" }],
       }),

sessionB-F2.diff on GitHub

Mach das am Montag

  • Aktualisiere den offiziellen Marketplace, installiere das Skills-Pack im Project Scope und prüfe, dass der Retro-Befehl in einer neuen Session auftaucht.
  • Führe deine echte Aufgabe in einer normalen Session aus. Lass sie durchlaufen und halte die Session offen.
  • Starte den Retro in dieser Session und dann noch einmal, mit dem gefundenen Fehler dahinter geschrieben.
  • Stelle die Trace-Frage: "For each candidate, quote the moment of this session's transcript that backs it; drop any candidate you cannot trace to a specific moment."
  • Gib Überlebende einzeln in ihren Träger frei: einen Check für einen mechanischen Fehler, eine Standards-Zeile für eine Ermessensfrage, einen Kommentar an der Quelle für eine Navigationslücke.
  • Führe /doctor prompt-audit mit einem Pfad zu den geänderten Dateien aus, übernimm seine Änderungen und committe die Dateien dann selbst.
  • Führe dieselbe Aufgabe in einer frischen Session erneut aus und sieh zu, wie der Fehler nicht auftritt.

Messungen

Das sind kleine Läufe auf einer erfundenen Rechnungs-API (Node und TypeScript, TypeScript, vitest) mit Claude Code 2.1.294. Sie sind eine Demonstration, kein Benchmark. Der versteckte Finanztest besteht aus fünf vor dem Lauf geschriebenen Checks.

Schritt Ergebnis
Gutschrift-Aufgabe, Versuch 1 Sauber: 40 bestanden, versteckter Finanztest 5/5 (verworfen, nicht die Fehler-Session)
Gutschrift-Aufgabe, Versuch 2 42 bestanden, Typecheck exit 0, versteckter Finanztest 4/5. CN-0004 statt des fälligen CN-0002
Erster Retro, kein Fehler gemeldet 5 gereihte Punkte, begann mit "This session was smooth"
Trace auf dem ersten Retro 2 Überlebende, 3 gestrichen
Zweiter Retro, Fehler gemeldet 5 Kandidaten
Trace auf dem zweiten Retro 4 Überlebende, 1 gestrichen
Von Hand freigegeben 4 Änderungen: Hook, Test, Standards-Zeile, Kommentar an der Quelle. Keine CLAUDE.md-Zeile
Erster Commit-Versuch Der Hook blockte ihn: 1 fehlgeschlagen, 42 bestanden (43). Der Baum enthielt noch den fehlerhaften Code
Nach dem Ein-Zeilen-Fix Suite 43/43, versteckter Finanztest 5/5, Commits gingen durch
Frische Session B, dieselbe Anfrage 37 bestanden, Typecheck exit 0, versteckter Finanztest 5/5, Nummern lückenlos nach einer abgelehnten Anfrage

Lies das mit Vorsicht:

  • Von den zwei normalen Läufen, die wir mit dieser Aufgabe machten, lieferte einer den Bug vor dem Retro aus. Nach dem Retro haben wir einen einzigen Wiederholungslauf, also ist das n=1.
  • Session B las die Standards-Datei, den Hook und den roten Nummerierungstest, bevor sie Code schrieb. Ihr wurde die Lehre mitgegeben. Ihr Bestehen zeigt die Schleife, die wie geplant funktioniert, keine unabhängige Replikation.
  • Der Retro fand den Bug nie von selbst. Sein erster Durchlauf fand ein fehlendes Guardrail, das laut ihm selbst diesen Bug nicht gefangen hätte.
  • Der neue Hook prüft den Arbeitsbaum. Ein git cherry-pick des Retro-Commits auf die Basis führte ihn nicht aus.
  • Ohne Pfad las der prompt-audit die Dateien auf Nutzerebene und sagte nichts zu den neuen. Mit dem Pfad der Standards-Datei antwortete er, die Datei sei sauber.
  • Die ältere Issue-Route in diesem Repo verliert Rechnungsnummern auf dieselbe Weise. Wir ließen sie, sie liegt außerhalb des Umfangs dieser Session.

Weiterführend

Lies die Person, die eine Lessons-Schleife wirklich betreibt. Ein Entwickler spielt beim Session-Start eine datierte Lessons-Datei wieder ein und macht aus einer Regel, die immer wieder rutscht, einen Test, eine Lint-Regel oder einen Hook, mit einer CLAUDE.md als kurzem Router. s6

Lies die Issues, bevor du dich auf den Retro verlässt. Issue 1173 ist das Argument für den Trace-Schritt. s7 Issue 1311 ist der vergessene Commit. s23 Issue 1158 ist der fehlende Befehl im offiziellen Marketplace. s24

Lerne, wo Logs liegen und wie lange sie bleiben. Die Sessions-Seite behandelt den Transcript-Pfad, das Fortsetzen per Pfad und die 30-Tage-Bereinigung. s8

Auto Memory ist die automatische Alternative. Wir haben sie weggelassen. Eine Schimpftirade eines Autors argumentiert, dass sie still schreibt, nicht versioniert ist und Trivia neben dauerhafte Regeln stellt, weshalb bei uns ein Mensch jede Zeile freigibt. s11 Sieh das als eine Meinung und teste es in deinem eigenen Setup.

/insights ist der wöchentliche Begleiter. Er baut einen HTML-Bericht über bis zu 200 Sessions mit Reibungspunkten und Vorschlägen. s14 Ein Erfahrungsbericht machte aus 64 Sessions 5 CLAUDE.md-Zeilen, 2 Skills und Hooks. s12 Wir haben ihn hier nicht ausgeführt.

Anthropics eigene Schleife ist manuell. Der Harness-Beitrag beschreibt das Tuning, indem man die Logs des Evaluators liest, findet, wo sein Urteil abwich, und den Prompt aktualisiert. s13 Wir fanden auf der ersten Seite des Engineering-Index keinen offiziellen Retro-Befehl und keinen Beitrag dazu. s27

Warum wir es nicht automatisiert haben. Ein Session-Ende-Trigger wäre Klebecode, den wir selbst geschrieben und nicht getestet hätten. Session-Ende-Hooks können das Beenden nicht blockieren und haben ein Standard-Timeout von 1,5 Sekunden, und der Retro-Skill wird nur vom Nutzer aufgerufen. s5 s1 Die eigene Doku des Retros sagt, dass er nur vorschlägt und sich nichts ändert, bis du einen Kandidaten auswählst. s2 Wir behielten den Menschen in der Schleife.

Was wir außerhalb des Umfangs ließen. Derselbe Reihenfolge-Fehler steckt in der älteren Rechnungs-Issue-Route dieses Repos, und ein Retro darauf wäre ein eigener Lauf. Wir fügten auch keinen CI-Job hinzu, weil unsere Kopie kein Remote hatte und ein Workflow nie gelaufen wäre.

FAQ

Warum nannte der Retro meine Session smooth? Er liest das Session-Log, und die Session hatte nie erfahren, dass sie gescheitert war: Ihre Tests und der Typecheck waren grün. Führe ihn erneut aus, mit dem Fehler hinter dem Befehl als Argument.

Ändert der Retro meine Dateien? Er schlägt nur vor. Nichts ändert sich, bis du einen Kandidaten auswählst. s2 Sobald du freigibst, werden die Änderungen womöglich umgesetzt, aber nicht committet, also committe sie selbst. s23

Kann ich ihn auf einer Session laufen lassen, die ich schon geleert habe? Ja, wenn das Log noch auf der Platte liegt. Transcripts liegen unter ~/.claude/projects/ und werden standardmäßig nach 30 Tagen bereinigt, und claude --resume <transcript-path> öffnet eines. s8

Warum blockte mein neuer Hook den Commit? Er führt deinen Typecheck und deine Tests auf dem Arbeitsbaum aus. Wenn der Baum noch den Bug enthält, den der Test jetzt fängt, wird der Commit abgelehnt. Behebe zuerst den Code, dann committe.

Warum keine CLAUDE.md-Zeile? Beide unserer Retro-Durchläufe lehnten eine ab und sagten, ein Verweis koste bei jedem Turn Kontext und veralte. Die Doku peilt unter 200 Zeilen an und sagt, die CLAUDE.md sei Kontext, keine erzwungene Konfiguration. s4

Dateien