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.
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.Five Steps, Only One of Them Is Claude
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.
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:
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.
The Session
Real run CAP-01: prompt, every step, and the answer (Checked)
| # | Who | Event | Content (shortened) |
|---|---|---|---|
| 1 | Main session | Bash call | {"command": "find src -name '*.java' | head -50 && cat src/main/java/**/BookController.java src/main/java/**/BookService.java 2>/dev/null"} |
| 2 | Main session | Tool result | src/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 |
| 3 | Main session | Bash call | {"command": "cd <project>/src && cat main/java/com/example/bookshelf/ApiExceptionHandler.java main/java/com/example/bookshelf/BookRepository.java test |
| 4 | Main session | Tool result | package com.example.bookshelf; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ExceptionHandler; import org.s |
| 5 | Main session | Bash 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]+''' |
| 6 | Main session | Tool result | Permission 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 |
| 7 | Main session | Text | I'll make the edits with the Edit tool instead. |
| 8 | Main session | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/BookService.java", "offset": 30} |
| 9 | Main session | Tool result | 30 return books.findById(id).map(BookDto::from).orElseThrow(() -> new BookNotFoundException(id)); 31 } 32 33 @Transactional 34 public BookDto create(CreateBookRequest request) { 3 |
| 10 | Main session | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java", "offset": 38} |
| 11 | Main session | Tool result | 38 } 39 40 @PostMapping 41 @ResponseStatus(HttpStatus.CREATED) 42 public BookDto create(@Valid @RequestBody CreateBookRequest request) { 43 return service.create(request); 44 |
| 12 | Main session | Read call | {"file_path": "<project>/src/test/java/com/example/bookshelf/BookControllerTest.java", "offset": 40} |
| 13 | Main session | Tool result | 40 .andExpect(status().isNotFound()) 41 .andExpect(jsonPath("$.detail").value("Book 99 not found")); 42 } 43 44 @Test 45 void blankTitleIsRejected() throws |
| 14 | Main session | Edit call | {"replace_all": false, "file_path": "<project>/src/main/java/com/example/bookshelf/BookService.java", "old_string": " return BookDto.from(books |
| 15 | Main session | Tool result | The file <project>/src/main/java/com/example/bookshelf/Bo |
| 16 | Main session | Edit call | {"replace_all": false, "file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java", "old_string": " return service.create( |
| 17 | Main session | Tool result | The file <project>/src/main/java/com/example/bookshelf/Bo |
| 18 | Main session | Edit call | {"replace_all": false, "file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java", "old_string": "import org.springframework.web |
| 19 | Main session | Tool result | The file <project>/src/main/java/com/example/bookshelf/Bo |
| 20 | Main session | Edit call | {"replace_all": false, "file_path": "<project>/src/test/java/com/example/bookshelf/BookControllerTest.java", "old_string": " .andExpect |
| 21 | Main session | Tool result | The file <project>/src/test/java/com/example/bookshelf/Bo |
| 22 | Main session | Edit call | {"replace_all": false, "file_path": "<project>/src/test/java/com/example/bookshelf/BookControllerTest.java", "old_string": "import static org.mockito. |
| 23 | Main session | Tool result | The file <project>/src/test/java/com/example/bookshelf/Bo |
| 24 | Main session | Text | Now the test run. |
| 25 | Main session | Bash call | {"command": "cd <project> && ./mvnw -B test 2>&1 | tail -30"} |
| 26 | Main session | Tool result | 2026-10-31T14:11:26.094+05:30 INFO 2045217 --- [bookshelf] [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default' 2026-10-31T |
| 27 | Main session | Text | `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 … |
- 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. ThedontAskmode refused, because only./mvnwand two git commands were allowed. Claude said it would use theEdittool 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:
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:
- Re-run the tests. Same command, my shell.
- 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.
- Check the boundaries.
git statusshows only the three files Claude named, and the files it was told not to touch 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+jsonon 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.
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)
| # | Who | Event | Content (shortened) |
|---|---|---|---|
| 1 | Main session | Agent call | subagent_type=bookshelf-kit:api-reviewer |
| 2 | Subagent | Bash call | {"command": "cd <project> && git diff -- src/main/java/com/example/bookshelf/BookController.java src/main/java/com/example/bookshelf/BookService.java |
| 3 | Subagent | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/ApiExceptionHandler.java"} |
| 4 | Main session | Text | The `bookshelf-kit:api-reviewer` subagent is running in the background on the DELETE /api/books/{id} change. I haven't seen its findings yet… |
| 5 | Subagent | Text | I have no Bash tool, so I can't run `git diff`. I'll read the three changed files directly. |
| 6 | Subagent | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java"} |
| 7 | Subagent | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/BookService.java"} |
| 8 | Subagent | Read call | {"file_path": "<project>/src/test/java/com/example/bookshelf/BookControllerTest.java"} |
| 9 | Subagent | Glob call | {"pattern": "src/main/java/com/example/bookshelf/*.java", "path": "<project>"} |
| 10 | Subagent | Text | Next I'll check the entities for cascades and relations, plus the exception class and repository. |
| 11 | Subagent | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/Book.java"} |
| 12 | Subagent | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/Author.java"} |
| 13 | Subagent | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/BookNotFoundException.java"} |
| 14 | Subagent | Read call | {"file_path": "<project>/src/main/java/com/example/bookshelf/BookRepository.java"} |
| 15 | Subagent | Text | The delete path looks clean so far. I'll check the query-count test and whether the list endpoint is untouched. |
| 16 | Subagent | Grep call | {"pattern": "BookQueryCountTest|deleteById|existsById", "path": "<project>/src", "output_mode": "content", "-n": true} |
| 17 | Subagent | Text | No 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… |
| 18 | Main session | Grep call | {"pattern": "DeleteMapping|existsById|deleteById|@Transactional|void delete|BookNotFoundException\\(|class BookNotFoundException", "path": "<project>/ |
| 19 | Main session | Text | The api-reviewer found no contract problems that would break DELETE /api/books/{id}. It raised three low-severity items. I checked the key l… |
| Finding | Verdict after checking |
|---|---|
1. existsById then deleteById is not atomic; a concurrent delete makes deleteById throw EmptyResultDataAccessException, giving a 500 | Premise 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 removed | True. Claude said so itself, and the probe in step 3 covered the gap |
| 3. A non-numeric id returns Spring's 400, not a ProblemDetail | True. 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.
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:
- Decide whether the two mocked tests are enough, or add a database-backed one (finding 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.
- Read the diff above. It is 34 lines.
- Commit, merge, and only then change #7 to passing.
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.Key Takeaways, Exercises and Sources
- 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.
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.
- 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/.