Claude · 26 · Part 12 · Capstone

Capstone: Ship "Delete a Book"

One real task, end to end, using the pieces from the whole guide: a project with conventions, the bookshelf-kit plugin, a hook that guards files, a subagent reviewer, and checks that do not depend on Claude's word. The task is feature #7 in the bookshelf project's own feature list, "delete book". The point is not that Claude wrote 20 lines of Java. It is the shape of the loop around it, and the fact that one of the reviewer's three findings turned out to be wrong.

2
Real Claude sessions
34
Lines added, 3 files
2 of 3
Review findings fully right
Information current as of: 2026-10-31. The implementation session used sonnet, the review session haiku with a subagent, both on Claude Code 2.1.316, in a fresh clone of the bookshelf project whose baseline tests passed before I started. Costs are Claude Code's client-side estimates.
01
The Loop

Five Steps, Only One of Them Is Claude

Where each earlier chapter fits
1 Briefconstraints,what not to touch 2 ImplementClaude, with theplugin loaded 3 Verifytests re-run anda real probe 4 Reviewread-onlysubagent 5 Decidea human mergesand flips status Ch 8, 16Ch 22, 24Ch 25Ch 20, 21Ch 13 steps 1, 2 and 4 use Claude; steps 3 and 5 deliberately do not

The project is the one used throughout: a Spring Boot REST API for books. Its feature_list.json marks two features failing, pagination and "delete book". Its CLAUDE.md states the conventions the work must follow, such as errors being ProblemDetail responses produced in ApiExceptionHandler with no try/catch in controllers, and a test that pins the list query to a single SQL statement.

02
Step 1

The Brief

The prompt applies the habits of Chapter 8: the outcome stated as observable behaviour, the convention to reuse, the places it must not touch, and a requirement to report a real test result. It is one paragraph:

Implement feature #7 'delete book' in this project: DELETE /api/books/{id} returns 204 when the book exists, and a 404 ProblemDetail through the existing BookNotFoundException handling when it does not. Follow CLAUDE.md. Add tests next to the existing ones. Do not edit pom.xml, .env or the Maven wrapper, and do not change feature_list.json (a human flips statuses). Run ./mvnw -B test and report the real result. Finish with the list of files you changed.

The session ran with --plugin-dir pointing at the bookshelf-kit plugin from Chapter 24, with the project's own settings loaded as well. The allow list was deliberately small: Read, Glob, Grep, Edit, Write, Bash(./mvnw *), Bash(git diff *) and Bash(git status), with the dontAsk mode from Chapter 25. The turn cap was 30 and the budget cap $1.20. Note the instruction about feature_list.json: a status flag is a claim about the world, and in this project a human is the one who makes it.

03
Step 2

The Session

Real run CAP-01: prompt, every step, and the answer (Checked)
Prompt as sent
Implement feature #7 'delete book' in this project: DELETE /api/books/{id} returns 204 when the book exists, and a 404 ProblemDetail through the existing BookNotFoundException handling when it does not. Follow CLAUDE.md. Add tests next to the existing ones. Do not edit pom.xml, .env or the Maven wrapper, and do not change feature_list.json (a human flips statuses). Run ./mvnw -B test and report the real result. Finish with the list of files you changed.
Who did what, in order · executed 2026-10-31
#WhoEventContent (shortened)
1Main sessionBash call{"command": "find src -name '*.java' | head -50 && cat src/main/java/**/BookController.java src/main/java/**/BookService.java 2>/dev/null"}
2Main sessionTool resultsrc/test/java/com/example/bookshelf/BookQueryCountTest.java src/test/java/com/example/bookshelf/BookSearchTest.java src/test/java/com/example/bookshelf/BookApiIntegrationTest.java src/test/java/com/ex
3Main sessionBash call{"command": "cd <project>/src && cat main/java/com/example/bookshelf/ApiExceptionHandler.java main/java/com/example/bookshelf/BookRepository.java test
4Main sessionTool resultpackage com.example.bookshelf; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ExceptionHandler; import org.s
5Main sessionBash call{"command": "cd <project>/src/main/java/com/example/bookshelf && python3 - <<'E'\np='BookService.java'\ns=open(p).read()\ni=s.rindex('}')\ns=s[:i]+'''
6Main sessionTool resultPermission to use Bash has been denied because Claude Code is running in don't ask mode. IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomp
7Main sessionTextI'll make the edits with the Edit tool instead.
8Main sessionRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookService.java", "offset": 30}
9Main sessionTool result30 return books.findById(id).map(BookDto::from).orElseThrow(() -> new BookNotFoundException(id)); 31 } 32 33 @Transactional 34 public BookDto create(CreateBookRequest request) { 3
10Main sessionRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java", "offset": 38}
11Main sessionTool result38 } 39 40 @PostMapping 41 @ResponseStatus(HttpStatus.CREATED) 42 public BookDto create(@Valid @RequestBody CreateBookRequest request) { 43 return service.create(request); 44
12Main sessionRead call{"file_path": "<project>/src/test/java/com/example/bookshelf/BookControllerTest.java", "offset": 40}
13Main sessionTool result40 .andExpect(status().isNotFound()) 41 .andExpect(jsonPath("$.detail").value("Book 99 not found")); 42 } 43 44 @Test 45 void blankTitleIsRejected() throws
14Main sessionEdit call{"replace_all": false, "file_path": "<project>/src/main/java/com/example/bookshelf/BookService.java", "old_string": " return BookDto.from(books
15Main sessionTool resultThe file <project>/src/main/java/com/example/bookshelf/Bo
16Main sessionEdit call{"replace_all": false, "file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java", "old_string": " return service.create(
17Main sessionTool resultThe file <project>/src/main/java/com/example/bookshelf/Bo
18Main sessionEdit call{"replace_all": false, "file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java", "old_string": "import org.springframework.web
19Main sessionTool resultThe file <project>/src/main/java/com/example/bookshelf/Bo
20Main sessionEdit call{"replace_all": false, "file_path": "<project>/src/test/java/com/example/bookshelf/BookControllerTest.java", "old_string": " .andExpect
21Main sessionTool resultThe file <project>/src/test/java/com/example/bookshelf/Bo
22Main sessionEdit call{"replace_all": false, "file_path": "<project>/src/test/java/com/example/bookshelf/BookControllerTest.java", "old_string": "import static org.mockito.
23Main sessionTool resultThe file <project>/src/test/java/com/example/bookshelf/Bo
24Main sessionTextNow the test run.
25Main sessionBash call{"command": "cd <project> && ./mvnw -B test 2>&1 | tail -30"}
26Main sessionTool result2026-10-31T14:11:26.094+05:30 INFO 2045217 --- [bookshelf] [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default' 2026-10-31T
27Main sessionText`DELETE /api/books/{id}` now returns 204 when the book exists and a 404 ProblemDetail when it doesn't. `./mvnw -B test` finished with BUILD …
Final answer shown to you · 13 turns, 68.6 s, estimated $0.176, models claude-sonnet-5-5
`DELETE /api/books/{id}` now returns 204 when the book exists and a 404 ProblemDetail when it doesn't. `./mvnw -B test` finished with BUILD SUCCESS: 9 tests run, 0 failures, 0 errors, 0 skipped. That includes `BookQueryCountTest`, so the one-SQL-statement list guarantee still holds. The service checks `existsById` first and throws `BookNotFoundException` if the book is missing, so the existing `ApiExceptionHandler` produces the 404. I added no try/catch and no new handler. The two new tests in `BookControllerTest` cover the 204 and the 404 (`detail` is "Book 99 not found"). They mock the service, so the real database delete is not tested. I did not touch `pom.xml`, `.env`, the Maven wrapper or `feature_list.json`. Files changed: - `src/main/java/com/example/bookshelf/BookController.java`: added the `DELETE /{id}` mapping with a 204 status. - `src/main/java/com/example/bookshelf/BookService.java`: added a transactional `delete(long id)`. - `src/test/java/com/example/bookshelf/BookControllerTest.java`: added the two tests.
Author's check: Checked. Every claim was checked after the session. I re-ran ./mvnw -B test myself: 9 tests, 0 failures, BUILD SUCCESS. I probed the real in-memory database with a temporary test (deleted afterwards): DELETE of an existing book returned 204, the book was then gone, and deleting it again returned 404 with application/problem+json. The two tests Claude wrote mock the service, as it said, so they do not exercise the database; my probe did. git status shows only the three files it listed; feature_list.json, pom.xml, .env and the wrapper are unchanged.
  • 13 turns, about 69 seconds, an estimated $0.18. That is the cost of the whole implementation, by the log's own estimate.
  • One denial, and a sensible recovery. Its third step tried to edit a file by running a Python script through Bash. The dontAsk mode refused, because only ./mvnw and two git commands were allowed. Claude said it would use the Edit tool instead and did. The narrow allow list pushed it onto the tool I wanted it to use.
  • It followed the conventions. The service throws the existing BookNotFoundException, so the existing handler produces the 404. No try/catch, no new handler.
  • It was honest about a limit. Its own report says the two new tests mock the service, "so the real database delete is not tested". That sentence is the reason step 3 exists.
  • What I cannot tell from the log: whether the project's Stop hook, which runs the test suite when Claude finishes, fired. The log has no hook events, so I do not claim it did.

The change itself, as git diff shows it in the project afterwards:

git diff of the project after the session
diff --git a/src/main/java/com/example/bookshelf/BookController.java b/src/main/java/com/example/bookshelf/BookController.java index ee262e7..9b27343 100644 --- a/src/main/java/com/example/bookshelf/BookController.java +++ b/src/main/java/com/example/bookshelf/BookController.java @@ -3,6 +3,7 @@ package com.example.bookshelf; import jakarta.validation.Valid; import java.util.List; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -42,4 +43,10 @@ public class BookController { public BookDto create(@Valid @RequestBody CreateBookRequest request) { return service.create(request); } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable long id) { + service.delete(id); + } } diff --git a/src/main/java/com/example/bookshelf/BookService.java b/src/main/java/com/example/bookshelf/BookService.java index 4ce94c1..7ddbb4f 100644 --- a/src/main/java/com/example/bookshelf/BookService.java +++ b/src/main/java/com/example/bookshelf/BookService.java @@ -35,4 +35,12 @@ public class BookService { Author author = authors.findByName(request.author()).orElseGet(() -> authors.save(new Author(request.author()))); return BookDto.from(books.save(new Book(request.title(), request.year(), author))); } + + @Transactional + public void delete(long id) { + if (!books.existsById(id)) { + throw new BookNotFoundException(id); + } + books.deleteById(id); + } } diff --git a/src/test/java/com/example/bookshelf/BookControllerTest.java b/src/test/java/com/example/bookshelf/BookControllerTest.java index b0809b3..e9fb427 100644 --- a/src/test/java/com/example/bookshelf/BookControllerTest.java +++ b/src/test/java/com/example/bookshelf/BookControllerTest.java @@ -1,6 +1,9 @@ package com.example.bookshelf; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -48,4 +51,20 @@ class BookControllerTest { .content("{\"title\":\"\",\"author\":\"Someone\",\"year\":2020}")) .andExpect(status().isBadRequest()); } + + @Test + void deleteExistingBookReturns204() throws Exception { + mvc.perform(delete("/api/books/1")) + .andExpect(status().isNoContent()); + verify(service).delete(1L); + } + + @Test + void deleteUnknownBookReturns404() throws Exception { + willThrow(new BookNotFoundException(99L)).given(service).delete(99L); + + mvc.perform(delete("/api/books/99")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.detail").value("Book 99 not found")); + } }
04
Step 3

Verify Without Asking Claude

Claude reported a green build. Three checks that do not rely on its report, all run by me after the session:

  1. Re-run the tests. Same command, my shell.
  2. Probe the behaviour Claude's tests do not cover. A temporary test, deleted afterwards, creates a book, deletes it through the real controller and in-memory database, and checks what a client sees.
  3. Check the boundaries. git status shows only the three files Claude named, and the files it was told not to touch are unchanged.
Actual output · executed 2026-10-31 · by the author, not by Claude
$ ./mvnw -B test (run by the author after the session, not by Claude) [INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS Temporary MockMvc probe against the real in-memory database (deleted afterwards): GET before -> 200 application/json body={"id":7,"title":"Probe","author":"A","year":2001} DELETE existing -> 204 (no content type) body= GET after -> 404 application/problem+json body={"detail":"Book 7 not found","instance":"/api/books/7","status":404,"title":"Not Found"} DELETE again -> 404 application/problem+json body={"detail":"Book 7 not found","instance":"/api/books/7","status":404,"title":"Not Found"} DELETE non-numeric -> 400 (no content type) body= Temporary test of the reviewer's first claim (deleted afterwards): books.deleteById(424242L) on a missing row -> no exception $ git status --short M src/main/java/com/example/bookshelf/BookController.java M src/main/java/com/example/bookshelf/BookService.java M src/test/java/com/example/bookshelf/BookControllerTest.java (feature_list.json, pom.xml, .env and the Maven wrapper are unchanged)
  • The build is green: nine tests, no failures. Claude's number matched.
  • The feature works against a real database. 204 on delete, then 404 with application/problem+json on a second read and a second delete. That is exactly the brief.
  • One gap that was not in the brief. A non-numeric id gets a 400 with no ProblemDetail body. That is not a defect in this change: Chapter 20 found the same behaviour on the existing GET endpoint. The new DELETE simply inherits it.
05
Step 4

A Second Pair of Eyes, and Its Mistake

The read-only reviewer from the plugin, bookshelf-kit:api-reviewer, was asked to review the new endpoint. The main session used haiku to coordinate and passed the subagent a full brief, as Chapter 20 advises:

Real run CAP-02: prompt, every step, and the answer (Partly correct)
Prompt as sent
Use the bookshelf-kit:api-reviewer subagent to review the new DELETE /api/books/{id} endpoint in BookController and BookService. Give it a complete brief including the file names. Then report its findings with file and line.
Who did what, in order · executed 2026-10-31
#WhoEventContent (shortened)
1Main sessionAgent callsubagent_type=bookshelf-kit:api-reviewer
2SubagentBash call{"command": "cd <project> && git diff -- src/main/java/com/example/bookshelf/BookController.java src/main/java/com/example/bookshelf/BookService.java
3SubagentRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/ApiExceptionHandler.java"}
4Main sessionTextThe `bookshelf-kit:api-reviewer` subagent is running in the background on the DELETE /api/books/{id} change. I haven't seen its findings yet…
5SubagentTextI have no Bash tool, so I can't run `git diff`. I'll read the three changed files directly.
6SubagentRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java"}
7SubagentRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookService.java"}
8SubagentRead call{"file_path": "<project>/src/test/java/com/example/bookshelf/BookControllerTest.java"}
9SubagentGlob call{"pattern": "src/main/java/com/example/bookshelf/*.java", "path": "<project>"}
10SubagentTextNext I'll check the entities for cascades and relations, plus the exception class and repository.
11SubagentRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/Book.java"}
12SubagentRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/Author.java"}
13SubagentRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookNotFoundException.java"}
14SubagentRead call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookRepository.java"}
15SubagentTextThe delete path looks clean so far. I'll check the query-count test and whether the list endpoint is untouched.
16SubagentGrep call{"pattern": "BookQueryCountTest|deleteById|existsById", "path": "<project>/src", "output_mode": "content", "-n": true}
17SubagentTextNo contract problems that would break the DELETE endpoint as specified. I could not run `git diff` (no Bash tool here), so I reviewed the cu…
18Main sessionGrep call{"pattern": "DeleteMapping|existsById|deleteById|@Transactional|void delete|BookNotFoundException\\(|class BookNotFoundException", "path": "<project>/
19Main sessionTextThe api-reviewer found no contract problems that would break DELETE /api/books/{id}. It raised three low-severity items. I checked the key l…
Final answer shown to you · 4 turns, 50.7 s, estimated $0.024, models claude-haiku-5-5, 2 result events (costs summed)
The api-reviewer found no contract problems that would break DELETE /api/books/{id}. It raised three low-severity items. I checked the key line numbers against the source with a grep, and they match. **Verified fine** - `BookController.java:47-49`: `@DeleteMapping("/{id}")` with a void `delete(@PathVariable long id)`. The reviewer reports a 204 on success with no body. - `BookService.java:39-44`: `@Transactional` delete that throws `BookNotFoundException` when `existsById` is false, so a missing id gives a 404 through `ApiExceptionHandler`. - No entity is returned, so nothing leaks. - `Book.author` is lazy with no cascade, so deleting a book does not affect `Author`. - The list path is unchanged, so `BookQueryCountTest` should not be affected. The reviewer did not read that test's body. **Findings** 1. **Low, `BookService.java:41-44`**: `existsById` followed by `deleteById` is not atomic. If a concurrent request deletes the same book in between, Spring Data throws `EmptyResultDataAccessException`, which no handler maps, so the client gets a 500 instead of a 404. A fix is `Book book = books.findById(id).orElseThrow(() -> new BookNotFoundException(id)); books.delete(book);`. This also drops one query. 2. **Low, `BookControllerTest.java:55-69`**: both delete tests mock `BookService`. Nothing checks that a real row is removed or that a second delete returns 404 against the database. A `@SpringBootTest` case that deletes a seeded book would cover it. 3. **Low, possibly pre-existing, `BookController.java:49`**: a non-numeric id such as `/api/books/abc` gets Spring's type-mismatch 400, not a ProblemDetail from `ApiExceptionHandler`. GET `/{id}` (`BookController.java:37`) behaves the same way. A `MethodArgumentTypeMismatchException` handler in `ApiExceptionHandler` would fix it for both. I did not confirm the default response body in Spring Boot 4. The reviewer could not run `git diff` because it has no Bash tool, so it reviewed the file contents directly. I have not run the test suite. If you want, I can change the service to the `findById` version, add the database-backed test, and run `verify-build`.
Author's check: Partly correct. Line references are correct (BookService.java:41-44, BookController.java:47-49, BookControllerTest.java:55-69). Finding 3 (a non-numeric id gives a 400 that is not a ProblemDetail) is confirmed: my probe showed 400 with no content type. Finding 2 (the new tests mock the service) is true. Finding 1 is wrong in its premise: the reviewer said a concurrent delete would make deleteById throw EmptyResultDataAccessException and return a 500, but a temporary test showed that deleteById on a missing row throws nothing in this project. The suggested findById-then-delete rewrite is still reasonable; the stated failure is not real. One of three findings was wrong.
FindingVerdict after checking
1. existsById then deleteById is not atomic; a concurrent delete makes deleteById throw EmptyResultDataAccessException, giving a 500Premise wrong. A temporary test showed deleteById on a missing row throws nothing here. A race would give an idempotent 204, not a 500. The suggested rewrite is still reasonable, but the reason given is not real
2. Both new tests mock the service, so nothing checks a real row is removedTrue. Claude said so itself, and the probe in step 3 covered the gap
3. A non-numeric id returns Spring's 400, not a ProblemDetailTrue. The probe showed 400 with no content type; the existing GET behaves the same

Rows 2 and 5 of the log show the tool list at work: the reviewer first reached for git diff through Bash, found it had no such tool, said so, and read the three files directly instead. The reviewer's cited line numbers are correct. The mistake is the kind a confident-sounding reviewer makes: a plausible failure mode stated as fact. Had I applied finding 1 without checking, I would have rewritten working code to fix a problem that does not exist. This is the strongest argument in the guide for the rule from Chapter 13: Claude's output is a claim until something you control confirms it.

06
Step 5

Close the Loop: A Human Decides

Nothing has been merged, and feature_list.json still says feature #7 is failing. That is on purpose. The project's own rule is that a human flips the status, and the MCP tool from Chapter 23 reads that same file, so asking it for the project status would still answer "5 of 7 passing". What remains for a person, in the order I would do it:

  1. Decide whether the two mocked tests are enough, or add a database-backed one (finding 2).
  2. Decide whether to add a handler for the type-mismatch case so both GET and DELETE return ProblemDetail (finding 3). It is a separate change.
  3. Read the diff above. It is 34 lines.
  4. Commit, merge, and only then change #7 to passing.
What this capstone did not do. I did not run the review-diff or verify-build skills, apply any of the review's suggestions, commit the change, or run Claude in CI. It also ran once: one successful session says little about reliability. And the whole task was small and well specified, which is when the loop works best; a vague brief against a large codebase would test it harder.
07
Summary

Key Takeaways, Exercises and Sources

∑ The capstone: what to remember
  • The model is one step of five. The brief before it and the verification after it do most of the work.
  • A narrow allow list steers behaviour. The refused script edit sent Claude to the tool I wanted.
  • Verify with something you control: re-run the tests, probe the behaviour the tests skip, check what changed.
  • Review output is a claim too. One of three findings had a false premise, and a ten-line test showed it.
  • Keep status in human hands. The agent proposes; a person marks the feature done.
Exercises

Exercise 1 Beginner

Pick a small feature in your own project and write the brief in the six-part shape used here: behaviour, convention to reuse, tests, files not to touch, a real test run to report, and a list of changed files. Run it in a throwaway branch and compare Claude's report with git status.

Learning outcome: write briefs that can be checked.

Exercise 2 Intermediate

After a session, write one temporary test for something Claude's own tests mock or skip. Run it, then delete it. Record whether it found anything.

Learning outcome: verify beyond the author's tests.

Exercise 3 Advanced

Turn this capstone into a script that runs the session with caps, then runs your verification and a read-only review, and exits non-zero if the tests fail, an unexpected file changed, or a review finding cites a line that does not exist. Use the gates from Chapter 25.

Learning outcome: automate the loop, not just the model call.

Sources (read on 2026-10-31; paraphrased)
  1. Anthropic documentation pages used by the earlier chapters this one builds on: headless mode, subagents, hooks, plugins. See each chapter for what was and was not read.

Executed 2026-10-31: the implementation session (CAP-01), the review session (CAP-02), and the author's own verification: a test re-run, a temporary database probe, a temporary test of the reviewer's first claim, and a git status check. The temporary tests were deleted. Not run: the review-diff and verify-build skills, committing, CI. The prompt, the diff and the verification output are in the examples repository under capstone/ and prompts/samples/.