TL;DR와 결론
Claude Code가 규칙을 무시하면 반사적으로 CLAUDE.md의 한 줄을 더 세게 쓰고 싶어집니다. 문서는 그것이 통하지 않는 이유를 이렇게 설명합니다. Claude는 이런 파일을 강제되는 설정이 아니라 context로 다루며, 어떤 동작을 막으려면 hook을 쓰라고 합니다. s4 retro 루프는 반대로 갑니다. 줄은 줄이고, 무시할 수 없는 검사를 둡니다.
우리 실행에서는 Claude Code 세션 하나가 작은 청구서 API에 크레딧 노트 기능을 만들었고, 자체 테스트가 초록불인 채로 채번 버그를 출시했습니다. 그 세션에 무엇이 실패했는지 알려 주고 retro를 돌리자, 막을 수 있었을 것들이 제안되었습니다. 우리는 손으로 네 가지 변경을 승인했습니다. pre-commit hook, 테스트, 표준 한 줄, 그리고 원천에 다는 주석 하나입니다. 같은 요청을 받은 새 세션은 번호를 빈틈없이 출시했습니다.
결론입니다.
- retro는 잘못된 세션 뒤에, 세션을 지우기 전에, 또는 오래된 세션의 로그에 돌리세요.
- 자동 조종으로 돌리지 마세요. 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 플러그인에 편입되었습니다. s22
설치 방법. 설치 명령은 claude plugin install mattpocock-skills@claude-plugins-official입니다. s25 함정이 하나 있습니다. 공식 마켓플레이스로 설치했을 때 retro 명령이 없다는 이슈가 보고되었습니다. s24 마켓플레이스 항목은 지금은 더 새로운 커밋에 고정되어 있습니다. s26 우리 컴퓨터의 로컬 마켓플레이스는 아직 예전 고정 상태였기 때문에, 마켓플레이스 업데이트를 먼저 하고 그다음 설치한 뒤 새 세션에서 명령을 확인했습니다.
코드가 아니라 로그를 읽는 이유. Claude Code는 트랜스크립트를 ~/.claude/projects/<project>/<session-id>.jsonl에 JSONL로 저장하며, claude --resume <transcript-path>로 그 파일에서 세션을 재개할 수 있습니다. s8 이 형식은 내부용이고 버전마다 바뀌므로, 이를 기반으로 스크립트를 만들지 마세요. s8 보존 기간은 30일이고 cleanupPeriodDays로 조정할 수 있습니다. s8
추적하기 전까지 retro는 잡음이 많다. 문서는 특정한 순간까지 추적할 수 없는 후보는 버리라고 합니다. s2 또 모델은 세션 중반의 어려움을 잊기 쉽고, 범주를 채우려고 일반적인 조언을 지어낸다고 경고합니다. s2 열려 있는 이슈는 12개 세션에 걸친 retro 실행 17회 중 15회에서 사용자가 에이전트에게 그 후보가 정말 필요했다는 증거를 요구했다고 집계하고, 생존자가 0개인 것도 완료된 retro로 쳐 달라고 요청합니다. s7
살아남은 후보를 분류하기. 가드레일이 없는 리포지토리, 즉 pre-commit hook도 없고 lint, 타입 체크, 테스트 명령을 돌리는 CI 잡도 없는 리포지토리는 그 자체로 지적 사항입니다. s1 기계적인 위반에는 결정적인 검사를 두면 그만입니다. s1 steering 파일은 incredibly sparingly, 즉 아주 드물게, 보통은 다른 파일로 안내하는 포인터에만 써야 합니다. s1 실제 환경에서 lessons loop를 돌리는 한 개발자도 같은 사다리에 이릅니다. 자꾸 어겨지는 규칙은 테스트, 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 그리고 열려 있는 이슈에는 retro가 승인된 변경을 구현한 뒤 커밋을 잊는다는 보고가 있습니다. s23
우리 실행에서 본 것. 첫 retro는 "This session was smooth"로 시작했습니다. 세션이 자기가 실패했다는 것을 몰랐기 때문입니다. 실패를 인자로 알려 준 뒤에야 검증 전에 번호를 가져가는 원인을 찾았습니다. 자세한 내용은 아래 측정에 있습니다.
코드
각 파일은 우리 실행에서 쓴 실제 파일이거나, 실행에 쓴 스크립트입니다. 경로는 여러분의 리포지토리에 맞게 고치세요.
**retro-checklist.md**는 이 페이지의 체크리스트를 파일로 만든 것입니다. 일곱 가지 동작, 추적 질문 원문, 네 가지 담을 곳, 커밋 알림, 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.
**pre-commit.sh**는 승인한 hook입니다. 타입 체크를 돌리고 이어서 테스트를 돌려서, 하나라도 실패하면 커밋을 막습니다. .githooks/pre-commit으로 저장하고, 실행 가능하게 만든 다음 git config core.hooksPath .githooks를 실행하세요.
pre-commit.sh
#!/bin/sh
set -e
pnpm typecheck
pnpm test
**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" });
});
});
**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.
**retro-output.md**는 우리 세션에서 나온 두 번의 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.
**doctor-prompt-audit.md**는 경로 없이, 그리고 경로를 주고 돌린 prompt audit입니다. 경로 함정을 보여 줍니다.
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"
**oracle.sh**는 세션 바깥에서 확인하는 다섯 가지 요구 사항, 즉 숨은 경리 테스트를 작업 사본에 대해 돌립니다. 여러분의 숨은 테스트를 가리키게 하세요.
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
**sessionA-F2.diff**는 버그를 출시한 세션의 코드와 그 수정입니다.
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", () => {
**sessionB-F2.diff**는 같은 요청을 다시 돌린 새 세션의 코드입니다.
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" }],
}),
월요일에 할 일
- 공식 마켓플레이스를 업데이트하고, skills 팩을 프로젝트 범위로 설치한 다음, 새 세션에서 retro 명령이 보이는지 확인한다.
- 평범한 세션에서 실제 작업을 돌린다. 끝날 때까지 기다리고 세션을 열어 둔다.
- 그 세션에서 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."
- 살아남은 후보를 하나씩 각자의 담을 곳으로 승인한다. 기계적 실패에는 검사, 판단에는 표준 한 줄, 탐색의 틈에는 원천의 주석.
- 손댄 파일의 경로를 주고
/doctor prompt-audit을 돌리고, 그 수정을 적용한 뒤 파일은 직접 커밋한다. - 같은 작업을 새 세션에서 다시 돌려서, 실패가 일어나지 않는 것을 확인한다.
측정
가상의 청구서 API(Node, TypeScript, vitest)와 Claude Code 2.1.294로 한 작은 실행입니다. 시연이지 벤치마크가 아닙니다. 숨은 경리 테스트는 실행 전에 작성한 다섯 가지 검사입니다.
| 단계 | 결과 |
|---|---|
| 크레딧 노트 작업, 시도 1 | 깨끗함: 40개 통과, 숨은 경리 테스트 5/5(버그 세션이 아니라서 폐기) |
| 크레딧 노트 작업, 시도 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 추가 없음 |
| 첫 커밋 시도 | hook이 막음: 1개 실패, 42개 통과(43). 트리에는 아직 버그 코드가 남아 있었음 |
| 한 줄 수정 후 | 스위트 43/43, 숨은 경리 테스트 5/5, 커밋 통과 |
| 새 세션 B, 같은 요청 | 37개 통과, 타입 체크 exit 0, 숨은 경리 테스트 5/5, 거부된 요청 뒤에도 번호에 빈틈 없음 |
읽을 때 유의할 점입니다.
- 이 작업에서 돌린 평범한 실행 두 번 중 한 번은 retro 이전에 버그를 출시했습니다. retro 이후의 재실행은 한 번뿐이므로 n=1입니다.
- 세션 B는 코드를 쓰기 전에 표준 파일, hook, 빨간 채번 테스트를 읽었습니다. 교훈을 건네받은 것입니다. 그 성공은 루프가 설계대로 작동했다는 증거이지, 독립적인 재현이 아닙니다.
- retro는 스스로 버그를 찾지 못했습니다. 첫 번째 패스에서 찾은 것은 가드레일 부재였고, 본인의 말로는 이 버그를 잡지 못했을 것입니다.
- 새 hook은 작업 트리를 검사합니다. retro 커밋을 베이스에
git cherry-pick했을 때는 hook이 돌지 않았습니다. - 경로 없는 prompt audit는 사용자 수준 파일을 읽었고 새 파일에 대해서는 아무 말도 하지 않았습니다. 표준 파일의 경로를 주자 그 파일이 깨끗하다고 답했습니다.
- 그 리포지토리의 오래된 발행 라우트도 같은 식으로 청구서 번호를 흘립니다. 이번 세션의 범위 밖이라서 그대로 두었습니다.
더 나아가기
실제로 lessons loop를 돌리는 사람의 글을 읽어 보세요. 한 개발자는 날짜가 적힌 lessons 파일을 세션 시작 때 다시 주입하고, 자꾸 어겨지는 규칙을 테스트, lint 규칙, hook으로 바꿉니다. CLAUDE.md는 짧은 라우터로 유지합니다. s6
retro에 의존하기 전에 이슈를 읽어 보세요. 이슈 1173은 추적 단계가 필요한 이유를 보여 줍니다. s7 이슈 1311은 잊힌 커밋입니다. s23 이슈 1158은 공식 마켓플레이스에서 명령이 없는 문제입니다. s24
로그가 어디에 있고 얼마나 오래 남는지 알아 두세요. sessions 페이지는 트랜스크립트 경로, 경로로 재개하기, 30일 정리를 다룹니다. s8
auto memory는 자동으로 하는 대안입니다. 이번에는 쓰지 않았습니다. 한 필자의 비판은 auto memory가 조용히 기록하고, 버전 관리가 안 되고, 사소한 것과 오래 갈 규칙을 한데 섞어 둔다는 것입니다. 그래서 우리는 사람이 한 줄씩 승인하는 방식을 택했습니다. s11 하나의 의견으로 받아들이고, 여러분의 환경에서 직접 시험해 보세요.
/insights는 주간 동반자입니다. 최대 200개 세션에 걸친 HTML 보고서를 만들어 마찰 지점과 제안을 보여 줍니다. s14 한 현장 보고서에서는 64개 세션이 CLAUDE.md 5줄, skill 2개, hook으로 바뀌었습니다. s12 이번에는 돌리지 않았습니다.
Anthropic 자신의 루프는 수동입니다. 하네스 글은 평가자의 로그를 읽고, 판단이 어긋난 지점을 찾고, 프롬프트를 갱신하는 식의 튜닝을 설명합니다. s13 engineering 색인의 첫 페이지에서는 공식 retro 명령이나 글을 찾지 못했습니다. s27
자동화하지 않은 이유. 세션 종료 트리거는 우리가 직접 짜서 시험하지 않은 연결 코드가 됩니다. 세션 종료 hook은 종료를 막을 수 없고 기본 타임아웃이 1.5초이며, retro skill은 사용자 호출 전용입니다. s5 s1 retro 자신의 문서도 제안만 할 뿐 여러분이 후보를 고르기 전에는 아무것도 바뀌지 않는다고 말합니다. s2 우리는 사람을 루프에 남겨 두었습니다.
범위 밖으로 둔 것. 같은 순서 버그가 그 리포지토리의 오래된 청구서 발행 라우트에도 있는데, 거기에 retro를 돌리는 것은 별개의 실행입니다. CI 잡도 추가하지 않았습니다. 우리 사본에는 리모트가 없어서 워크플로가 한 번도 돌지 않았을 것이기 때문입니다.
FAQ
retro는 왜 제 세션이 순탄했다고 했나요? retro가 읽는 것은 세션 로그이고, 세션은 자기가 실패했다는 것을 몰랐기 때문입니다. 테스트와 타입 체크가 초록불이었습니다. 명령 뒤에 실패 내용을 인자로 적어서 다시 돌리세요.
retro가 제 파일을 바꾸나요? 제안만 합니다. 여러분이 후보를 고르기 전에는 아무것도 바뀌지 않습니다. s2 승인하면 변경이 구현되기는 해도 커밋되지 않을 수 있으니, 커밋은 직접 하세요. s23
이미 지운 세션에도 돌릴 수 있나요?
로그가 아직 디스크에 있다면 가능합니다. 트랜스크립트는 ~/.claude/projects/ 아래에 있고 기본적으로 30일 뒤에 정리되며, claude --resume <transcript-path>로 열 수 있습니다. s8
새 hook이 왜 제 커밋을 막았나요? 작업 트리에 대해 타입 체크와 테스트를 돌리기 때문입니다. 트리에 이제 테스트가 잡아내는 버그가 남아 있으면 커밋이 거부됩니다. 먼저 코드를 고친 다음 커밋하세요.
왜 CLAUDE.md에는 한 줄도 넣지 않나요? 우리의 두 번의 retro 모두 쓰기를 거절했습니다. 포인터는 매 턴 context를 쓰고 금방 낡는다는 이유였습니다. 문서는 200줄 미만을 목표로 하고, CLAUDE.md는 강제되는 설정이 아니라 context라고 말합니다. s4
AIDive