AIDive

影片資料包

retro 循環工具包:把失敗的 Claude Code session 變成檢查

閱讀約 9 分鐘

TL;DR 與結論

當 Claude Code 忽略一條規則時,直覺反應是在 CLAUDE.md 裡寫一行更大聲的字。文件說明了為什麼這行不通:Claude 把這些檔案當作 context,而不是強制執行的設定,要阻擋某個動作,你應該改用 hook。 s4 retro 循環反其道而行:行數更少,並且有一個無法被忽略的檢查。

在我們的執行中,一個 Claude Code session 在一個小型請款 API 上做出了貸項通知單功能,並在自己的測試全綠的情況下發佈了一個編號 bug。對那次 session 執行 retro,並告訴它哪裡失敗之後,它提出了本來可以防止這個 bug 的做法。我們手動批准了四個修改:一個 pre-commit hook、一個測試、一行規範,以及原始碼處的一則註解。接著,一個拿到同一個需求的全新 session,發佈出了沒有缺口的號碼。

結論如下:

  • 在一次出錯的 session 之後、在你清掉它之前,或是對舊 session 的紀錄執行 retro。
  • 不要讓它自動執行。它只會提出建議,在你挑選候選項目之前什麼都不會改變。 s2
  • 把每個候選項目追溯到對話紀錄中的某個時刻,並捨棄你無法追溯的項目。
  • 把每個倖存者送到正確的載體:機械性的失敗用檢查,需要判斷的部分用規範的一行,導覽缺口用原始碼處的註解。
  • 你加進去的檢查就是你現在要維護的程式碼。retro 不會審核它上個月提出的檢查。 s2

下面的資源包裡有我們這次執行的檢查清單、hook 腳本和編號測試。

有來源的整理

retro 是什麼。 它是 Matt Pocock 的集合中的一個 skill。它只能由使用者呼叫(disable-model-invocation: true),並在七個類別中尋找候選項目:Navigation、Automated checks、Coding standards、Global AGENTS.md、Tool economy、No-ops 和 Information access。 s1 它的最後一步只是依嚴重程度列出候選項目。 s1 它在 v1.3.0 進入了 Claude Code 的 plugin。 s22

如何安裝。 安裝指令是 claude plugin install mattpocock-skills@claude-plugins-official。 s25 一個陷阱:有一個 issue 回報,透過官方 marketplace 安裝時找不到 retro 指令。 s24 marketplace 的項目現在固定在較新的 commit。 s26 在我們的機器上,本機 marketplace 仍停在舊的固定版本,所以我們先執行 marketplace 更新,再安裝,然後在新的 session 裡檢查這個指令。

為什麼讀紀錄,而不是程式碼。 Claude Code 把對話紀錄以 JSONL 格式存放在 ~/.claude/projects/<project>/<session-id>.jsonl,而 claude --resume <transcript-path> 可以從該檔案恢復 session。 s8 這個格式是內部的,各版本之間會變動,所以不要在它上面寫腳本。 s8 保留期限是 30 天,可以用 cleanupPeriodDays 設定。 s8

不追溯的話,retro 很吵。 文件要你捨棄任何無法追溯到具體時刻的候選項目。 s2 它們也警告,模型傾向於忘記 session 中段的掙扎,並為了滿足各個類別而編造一般性的建議。 s2 一個開放中的 issue 統計了 12 次 session 上的 17 次 retro 執行,其中 15 次之後使用者都要求 agent 證明這些候選項目是必要的,並要求「零個倖存者」也算是完整的 retro。 s7

為每個倖存者找路。 一個沒有護欄的 repo,也就是沒有 pre-commit hook,也沒有執行 lint、型別檢查或測試指令的 CI job,本身就是一個發現。 s1 機械性的違規得到一個確定性的檢查,沒有例外。 s1 steering 檔應該「極少」使用,通常只用於指向其他檔案的導覽指標。 s1 一位在正式環境裡運行經驗教訓循環的開發者得到了同樣的階梯:一條不斷退步的規則會變成測試、lint 規則或 hook。 s6

讓 CLAUDE.md 保持精簡。 文件的目標是每個 CLAUDE.md 檔案少於 200 行,因為更長的檔案會消耗更多 context 並降低遵循度。 s4 它們對每一行的檢驗是:刪掉它會不會讓 Claude 出錯?如果不會,就刪掉。 s9 有一位開發者描述了膨脹的失敗模式:一條被忽略的規則換來一條更大聲的規則,和一個更長的檔案。 s10

關上循環。 文件說要把 CLAUDE.md 當成程式碼:出錯時檢討它,定期修剪,並透過觀察 Claude 的行為是否真的改變來測試修改。 s9 /doctor prompt-audit 需要 Claude Code v2.1.283 或更新版本,它會回報發現並附上建議的修改,在你要求之前不會改變任何東西。 s4 它透過內建的 claude-api skill 執行,所以在那個 skill 被關閉時無法使用。 s4 還有一個開放中的 issue 描述 retro 實作了已批准的修改,然後忘了 commit。 s23

我們這次執行看到的。 第一次 retro 開頭是 "This session was smooth",因為 session 從來不知道自己失敗了。直到我們把失敗當作參數回報,它才找到原因:在驗證之前取了號碼。細節在下面的量測裡。

程式碼

每個檔案都是我們這次執行的真實檔案,或是我們用來執行的腳本。請把路徑改成符合你的 repo。

retro-checklist.md 是本頁的檢查清單變成檔案:七個步驟、一字不差的追溯問題、四個載體、commit 提醒,以及 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 是我們批准的 hook。它先執行型別檢查,再執行測試,只要其中一個失敗就阻擋 commit。把它存成 .githooks/pre-commit,設為可執行,並執行 git config core.hooksPath .githooks。

pre-commit.sh

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

pre-commit.sh on GitHub

numbering.test.ts 是我們批准的 vitest 測試。它鎖住一條規則:被拒絕的貸項通知單請求不會燒掉任何號碼。

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 是我們批准的那一行規範,也就是審查者會讀的檔案。

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 是我們 session 中兩次 retro 以及兩次追溯回答的完整文字。

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 是先不給路徑、再給路徑所做的 prompt 審核。它展示了路徑陷阱。

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 在隔離的、僅限專案的設定下,用一份允許清單執行一輪無頭模式的 Claude Code。請把 env 那一行改成你自己的。

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 針對一份工作副本,執行隱藏的財務測試,也就是從 session 外部檢查的五項需求。請把它指向你自己的隱藏測試。

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 是發佈了 bug 的那次 session 的程式碼,附帶修正。

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 是重跑同一個需求的全新 session 的程式碼。

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

星期一就做這些

  • 更新官方 marketplace,用專案範圍安裝 skills 套件,並在新的 session 裡確認 retro 指令有出現。
  • 在一般的 session 裡執行你真正的任務。讓它跑完,並保持 session 開著。
  • 在那個 session 裡執行 retro,然後再執行一次,並在後面寫上你發現的失敗。
  • 問追溯的問題:"For each candidate, quote the moment of this session's transcript that backs it; drop any candidate you cannot trace to a specific moment."(對每個候選項目,引述這次 session 對話紀錄中支持它的片段;捨棄任何你無法追溯到具體時刻的候選項目。)
  • 一次批准一個倖存者進入它的載體:機械性的失敗用檢查,需要判斷的部分用規範的一行,導覽缺口用原始碼處的註解。
  • 用你修改過的檔案路徑執行 /doctor prompt-audit,套用它的修改,然後自己 commit 這些檔案。
  • 在全新的 session 裡重跑同一個任務,看著失敗沒有發生。

量測

這些是在一個虛構的請款 API(Node 和 TypeScript、TypeScript、vitest)上,用 Claude Code 2.1.294 做的小型執行。它們是示範,不是基準測試。隱藏的財務測試是執行前寫好的五項檢查。

步驟 結果
貸項通知單任務,第 1 次嘗試 乾淨:40 個通過,隱藏的財務測試 5/5(已捨棄,不是有缺陷的 session)
貸項通知單任務,第 2 次嘗試 42 個通過,型別檢查 exit 0,隱藏的財務測試 4/5。應該是 CN-0002 的地方拿到 CN-0004
第一次 retro,未回報失敗 5 個排序項目,開頭是 "This session was smooth"
對第一次 retro 的追溯 2 個倖存,3 個捨棄
第二次 retro,已回報失敗 5 個候選項目
對第二次 retro 的追溯 4 個倖存,1 個捨棄
手動批准 4 個修改:hook、測試、規範的一行、原始碼處的註解。沒有 CLAUDE.md 的行
第一次 commit 嘗試 hook 擋下了:1 個失敗,42 個通過(43)。工作目錄裡仍是有 bug 的程式碼
一行修正之後 測試套件 43/43,隱藏的財務測試 5/5,commit 通過
全新 session B,同一個需求 37 個通過,型別檢查 exit 0,隱藏的財務測試 5/5,被拒絕的請求之後號碼沒有缺口

請謹慎解讀:

  • 在這個任務上我們做的兩次一般執行中,有一次在 retro 之前就發佈了 bug。retro 之後我們只有一次重跑,所以這是 n=1。
  • Session B 在寫程式碼之前,讀了規範檔、hook 和紅燈的編號測試。它拿到了教訓。它的通過顯示循環如設計般運作,不是獨立的重現。
  • retro 從來沒有自己找到那個 bug。它的第一輪找到的是缺少的護欄,用它自己的話說,那個護欄抓不到這個 bug。
  • 新的 hook 檢查的是工作目錄。把 retro 的 commit 用 git cherry-pick 套到基底上時,並沒有執行它。
  • 不給路徑時,prompt 審核讀的是使用者層級的檔案,對新檔案隻字未提。給了規範檔的路徑後,它回答該檔案是乾淨的。
  • 那個 repo 裡較舊的開立發票 route 以同樣的方式洩漏發票號碼。我們沒有動它,它不在這次 session 的範圍內。

延伸閱讀

讀讀真正在運行經驗教訓循環的人。 一位開發者在 session 開始時重新注入一個標有日期的經驗教訓檔,並把一條不斷退步的規則變成測試、lint 規則或 hook,同時讓 CLAUDE.md 維持成一個簡短的路由器。 s6

在依賴 retro 之前先讀這些 issue。 Issue 1173 是追溯步驟的理由。 s7 Issue 1311 是被遺忘的 commit。 s23 Issue 1158 是官方 marketplace 上不見的指令。 s24

了解紀錄放在哪裡、能保存多久。 sessions 頁面涵蓋了對話紀錄的路徑、依路徑恢復,以及 30 天的清理。 s8

自動記憶是自動化的替代方案。 我們沒有採用它。有一位作者的抱怨文章主張,它是悄悄寫入的、沒有版本控制,並且把瑣碎的事和長期的規則放在一起,這就是我們讓人類批准每一行的原因。 s11 把它當作一種意見,並在你自己的環境上測試。

/insights 是每週的搭檔。 它對最多 200 個 session 產生一份 HTML 報告,包含摩擦點和建議。 s14 一份實地報告把 64 個 session 變成了 5 行 CLAUDE.md、2 個 skill 和 hook。 s12 我們在這裡沒有執行它。

Anthropic 自己的循環是手動的。 它的 harness 文章描述的調校方式是閱讀評估者的紀錄,找出它的判斷在哪裡偏離,然後更新 prompt。 s13 我們在 engineering 索引的第一頁沒有找到官方的 retro 指令或文章。 s27

為什麼我們沒有把它自動化。 session 結束時的觸發會是我們自己寫、而且沒有測試過的黏合程式碼。SessionEnd hook 無法阻止終止,預設逾時為 1.5 秒,而且 retro skill 只能由使用者呼叫。 s5 s1 retro 自己的文件說它只會提出建議,在你挑選候選項目之前什麼都不會改變。 s2 我們讓人類留在循環裡。

我們排除在範圍之外的。 同樣的順序 bug 也存在於那個 repo 較舊的開立發票 route,對它做 retro 會是另一次獨立的執行。我們也沒有加入 CI job,因為我們的副本沒有 remote,workflow 永遠不會執行。

FAQ

為什麼 retro 說我的 session 很順利? 它讀的是 session 紀錄,而 session 從來不知道自己失敗了:它的測試和型別檢查都是綠的。請再執行一次,並把失敗的內容寫在指令後面,當作它的參數。

retro 會更改我的檔案嗎? 它只會提出建議。在你挑選候選項目之前什麼都不會改變。 s2 一旦你批准,修改可能會被實作但不會被 commit,所以請自己 commit。 s23

我可以對已經清掉的 session 執行嗎? 可以,只要紀錄還在磁碟上。對話紀錄放在 ~/.claude/projects/ 底下,預設 30 天後清理,而 claude --resume <transcript-path> 可以開啟其中一個。 s8

為什麼我的新 hook 擋下了 commit? 它在工作目錄上執行你的型別檢查和測試。如果工作目錄裡仍是現在會被測試抓到的 bug,commit 就會被拒絕。先修正程式碼,再 commit。

為什麼沒有 CLAUDE.md 的行? 我們的兩輪 retro 都拒絕寫一行,理由是指標每一輪都會消耗 context 並且會過時。文件的目標是少於 200 行,並說 CLAUDE.md 是 context,而不是強制執行的設定。 s4

檔案