From 9b74f5056526e096ecffff43b093d09466263a86 Mon Sep 17 00:00:00 2001 From: Sprite Date: Sun, 13 Sep 2026 17:30:38 +0000 Subject: [PATCH] Plan waves 9-10: manual entry, then online search; chained runner Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ --- docs/HANDOFF.md | 29 +++++ tasks/K-manual.txt | 162 ++++++++++++++++++++++++++++ tasks/L-search.txt | 252 ++++++++++++++++++++++++++++++++++++++++++++ tasks/wave-chain.sh | 162 ++++++++++++++++++++++++++++ 4 files changed, 605 insertions(+) create mode 100644 tasks/K-manual.txt create mode 100644 tasks/L-search.txt create mode 100755 tasks/wave-chain.sh diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 4d7de42..7cfe00e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -737,3 +737,32 @@ non-matching task name), and after killing it. Only the real worker counts as ru **When checking a wave, compare the guard log with the worker's `.state`:** a `workers still running` renewal timestamped AFTER `.state` says SUCCESS means the guard is stuck. It is not a slow worker. + +## Waves 9 + 10 — K-manual then L-search, CHAINED: launched 2026-09-13 +The Wave-8 crash never recurred and the user has parked it. Two user-requested features, +run IN SEQUENCE (they share `ui/library`, `ui/nav`) with NO orchestrator between them — +the user asked for wave 10 to start straight after wave 9 without waiting on them. + +- **Wave 9 `tasks/K-manual.txt`** — add a book by hand with no ISBN. New `ui/add/` + screen + `Routes.addBook(title, isbn)`, entry points on the library FAB/empty state and + the scan screen's manual-ISBN dialog. Before this there was NO way to add a book with no + barcode (`saveManualEntry` required an ISBN). +- **Wave 10 `tasks/L-search.txt`** — library search bar also searches Open Library + `search.json` + Google Books `volumes?q=`, on SUBMIT only; "In your library" on top; + deduped across all three sources. User decisions recorded in the prompt: submit-only, + "in your library" = owns SOME edition, ISBN filled only when unambiguous (OL work with + exactly one distinct ISBN-13, else exactly one GB ISBN-13, else none). + Live-verified API fact: **OL search omits `isbn` unless `fields=` asks for it.** + +**Runner: `tasks/wave-chain.sh 9:K-manual 10:L-search`** (new). Holds the lease itself +(wave-guard would release it during the between-wave gate), runs each worker in the +foreground, then a MECHANICAL gate (assembleDebug, testDebugUnitTest, verifyPaparazziDebug, +XML test count up, no build files, no `always 'false'`). Red -> one fresh `-fix` +worker -> re-gate -> still red = sentinel says FAILED and the chain STOPS. Green -> +`git add -A` commit (message says "ORCHESTRATOR REVIEW STILL PENDING") + push, then next wave. +Logs: `logs/wave-chain.log`, `logs/gate-.log`, `logs/WAVE9-DONE`, `logs/WAVE10-DONE`, +`logs/CHAIN-DONE`. Baseline: 205 tests, 2 skipped. + +**The gate is not acceptance.** Still owed by the orchestrator for BOTH waves: read each +`.summary`, read the diff, eyeball the new Paparazzi PNGs, check CancellationException is +rethrown in every new catch, and check wave 10's dedupe judgement call in its report. diff --git a/tasks/K-manual.txt b/tasks/K-manual.txt new file mode 100644 index 0000000..1a03f19 --- /dev/null +++ b/tasks/K-manual.txt @@ -0,0 +1,162 @@ +You are implementing ONE feature in the Bookshelf Android app at ~/bookshelf. + +READ FIRST, in this order: + 1. ~/bookshelf/docs/SPEC.md — the authoritative contract, especially "Non-negotiables", + "Design language" and "Screens". Do not contradict it and do not edit it. + 2. The files named below, and their neighbours, before you write anything. + +## The gap + +There is currently NO way to add a book that has no barcode. Every path into +"save a book" starts from an ISBN: + - The scan screen's keyboard button (`ui/scan/ScanScreen.kt`, `showManualEntry`) + only accepts an ISBN and then runs a metadata lookup on it. + - `ManualEntrySheet` (ScanScreen.kt) only appears after a lookup comes back + NotFound / LookupFailed, and `ScanViewModel.saveManualEntry(isbn13: String, ...)` + REQUIRES an ISBN. +Older books, pamphlets, self-published and foreign books often have no ISBN at all. +The data layer already supports this: `BookRepository.createBook(isbn13: String? = null, ...)` +takes every field as optional except title. This wave is UI + navigation work. + +A follow-up wave (online search, runs right after you) will navigate into what you +build here with a pre-filled title, and possibly other pre-filled fields. Design for +that; do not build the search. + +## What to build + +### 1. An "Add a book by hand" screen + +New package `org.modg.bookshelf.ui.add` — `AddBookScreen` + `AddBookViewModel`. +A full screen (not a sheet: the library has no camera context, and this form is +longer than the scan sheet's two fields). Fields, all optional except Title: + Title (required), Subtitle, Authors (comma-separated, same convention as + ManualEntrySheet), Publisher, Published (free text — SPEC's `publishedDate` is a + text field; "1960" and "1999-04" are both legitimate), Pages (digits only), + ISBN, Description, and a shelf picker. + +- **Pre-fill.** Model the form as a plain data class (e.g. `BookDraft`) and let the + ViewModel take an initial draft. Add a route in `ui/nav/Routes.kt` with OPTIONAL + query arguments for at least `title` and `isbn`, e.g. `add?title={title}&isbn={isbn}` + plus a helper `Routes.addBook(title: String? = null, isbn: String? = null)` that + URL-encodes its arguments. (Titles contain `&`, `?`, `/`, `#` — test that the + helper round-trips them.) The next wave may extend the draft; keep the draft → + form mapping in one place so that is a small change. +- **ISBN is optional but never silently discarded.** Blank = no ISBN. Non-blank must + parse via `IsbnUtils.toIsbn13` (accepts ISBN-10 or -13, with or without hyphens); + if it does not, mark the field in error with a message and disable Save. Wave 5 + fixed exactly this silent-discard bug in the manual-ISBN dialog — do not + reintroduce it. Store the normalized ISBN-13; also store the ISBN-10 when the user + typed a valid ISBN-10. +- **Duplicate warning.** If the parsed ISBN-13 is already owned + (`BookRepository.findByIsbn13`), show the same kind of warning the scan sheet + shows (`DuplicateCheck` / `DuplicateStatus` in `ui/scan/ScanModels.kt` — reuse, + do not duplicate). It is a warning, not a block: two copies of a book is legal. +- **Shelf picker.** Reuse the existing shelf picker exactly as the scan sheet uses it + (`ui/components/ShelfPickerSheet.kt` and the `ShelfPicker` call in ScanScreen.kt), + including pre-selecting the remembered last shelf from `SettingsStore`, and + remembering the chosen shelf on save the same way `ScanViewModel.rememberShelf` does. +- **Two save actions**, because SPEC's real use case is shelving a box of books: + "Save" — saves, then navigates to the new book's detail screen, removing the add + screen from the back stack (so Back from detail returns to the library). + "Save & add another" — saves, clears the form but KEEPS the shelf, shows a short + confirmation naming the saved title, and a running count, and puts focus back in + Title. +- **Saving must not crash or lose the form.** Wave 8 made the lookup path + exception-safe but flagged that SAVE paths still make unguarded Room calls. Guard + yours: catch `Throwable` around the save, RETHROW `CancellationException` first + (`kotlinx.coroutines.CancellationException`), and on failure keep the typed form + and show an error naming the exception class (not its message). Disable the save + buttons while a save is in flight so a double-tap cannot create two books. +- Offline-first (SPEC): Room only. Nothing on this screen touches the network. +- IME: Next between fields, sensible keyboard types (Number for pages, capitalize + words for title/authors), and make sure the focused field is not hidden behind + the keyboard — the setup screen hit this; see how it was fixed there + (`safeDrawingPadding` outside `verticalScroll`, `adjustResize` is already set). +- **Split a stateless `AddBookContent(state, callbacks)` out of the screen** so the + Paparazzi snapshot renders the REAL composable. Wave 6 shipped a hand-rolled + lookalike snapshot for Locations and it is recorded in HANDOFF.md as a known + soft spot; do not repeat that. + +### 2. Entry points + +- **Library screen** (`ui/library/LibraryScreen.kt`): add an entry point next to the + scan FAB. Use a Material 3 pattern that keeps Scan as the primary action — e.g. a + `SmallFloatingActionButton` ("Add by hand", edit/pencil-style icon) stacked above + the main FAB. Pick what looks right against SPEC's design language; justify it in + your report. Also give the empty state a secondary "Add by hand" action next to + "Scan a book". New `LibraryScreen` parameter, wired in `BookshelfNavHost`. +- **Scan screen**: the manual-ISBN dialog (the keyboard button) gets a clear + secondary action like "No ISBN? Enter the details by hand" that navigates to the + add screen. New `ScanScreen` callback parameter, wired in `BookshelfNavHost`. +- Leave the in-scan `ManualEntrySheet` (post-lookup NotFound/LookupFailed) as it is — + it keeps the scanned ISBN and continuous-scan flow. You may give it a + "More fields…" action that opens the add screen pre-filled with that ISBN and the + title/authors typed so far, if it is small; otherwise leave it and say so. + +## Constraints — these are hard + +- Kotlin, Compose, Material 3. Match surrounding code's style, naming and comment + density (this codebase comments the evidence for a decision, not a restatement of + the code). Read neighbouring files first. +- You MAY touch: new `ui/add/`, `ui/nav/`, `ui/library/`, `ui/scan/` (entry point + and optional "More fields…" only), `ui/components/` (only if a shared piece must + move there to be reused), and tests. Nothing else. +- DO NOT touch: any build file (`app/app/build.gradle.kts`, `gradle/libs.versions.toml`, + etc.), `data/**`, `server/`, `docs/`. If you believe you need a dependency or a + data-layer change, STOP and say so in your report. Everything above is doable with + what exists (`createBook` already takes all these fields). +- DO NOT read, print or grep `app/local.properties`. +- DO NOT git commit, add or push. Leave the work in the working tree. +- Build with `./tasks/gw ` — NEVER `./gradlew` directly (flock-serialized wrapper). +- RUN BUILDS IN THE FOREGROUND. Never background a Gradle build and end your turn + saying you will report later — you will never get to. Several previous workers on + this project did exactly that. A build takes 1-5 minutes; wait for it. +- No emulator on this box. You cannot run the app. Do not claim you did. + +## Definition of done + +Run these yourself, in the foreground, and paste the real output tails: + + ./tasks/gw assembleDebug -> exit 0 + ./tasks/gw testDebugUnitTest -> exit 0. **205 tests today, 2 skipped** (both + opt-in live tests). Count must go UP; nothing + may regress. Sum the TEST-*.xml files + (app/app/build/test-results/testDebugUnitTest/) + — do not read the console. + ./tasks/gw recordPaparazziDebug -> exit 0 + ./tasks/gw verifyPaparazziDebug -> exit 0 against the re-recorded snapshots + +Required tests with REAL assertions (assertion-free tests violate SPEC): + - `Routes.addBook` round-trips titles containing `&`, `?`, `/`, `#`, spaces and + non-ASCII (e.g. "Hänsel & Gretel / Part 1?"), and omits absent args. + - Draft validation: blank title disables save; blank ISBN is valid (saves null); + ISBN-10 is normalized to ISBN-13 and the ISBN-10 kept; a bad checksum is an + error, not a silent drop; non-digit pages rejected. + - ViewModel: save calls `createBook` with every field mapped (authors split and + trimmed, empty entries dropped); duplicate ISBN produces the AlreadyOwned + warning; "save & add another" clears fields but keeps the shelf and increments + the count; a repository that throws leaves the form intact with an error and + does not throw; a `CancellationException` is NOT swallowed; a second save while + one is in flight does not create a second book. + - Paparazzi (light + dark, in `ui/screens/` alongside the others): the empty form, + a filled form with a duplicate-ISBN warning, and the ISBN-error state. Plus the + library screen with the new FAB, re-recorded. + +Also grep the build output for Kotlin warnings on files you touched. "Check for +instance is always 'false'" is NOT cosmetic — it hid a bug that blanked every book +cover in this app for months. + +## Report + +End with a plain report: + - what you changed, file by file + - verbatim tails of each gradle command above + - test count before and after, summed from the XML + - the exact route string and `Routes.addBook` signature, and how `BookDraft` is + passed in — the next wave's worker will read this report to build on it + - judgement calls you made (FAB pattern, "More fields…" yes/no) and why + - anything you could NOT do or did differently, and why + - anything you noticed that looks wrong but was out of scope + +Be honest. Workers here have over-claimed before, and everything is re-verified, so +an inflated report only wastes a round trip. diff --git a/tasks/L-search.txt b/tasks/L-search.txt new file mode 100644 index 0000000..18e7ba3 --- /dev/null +++ b/tasks/L-search.txt @@ -0,0 +1,252 @@ +You are implementing ONE feature in the Bookshelf Android app at ~/bookshelf. + +READ FIRST, in this order: + 1. ~/bookshelf/docs/SPEC.md — the authoritative contract, especially "Non-negotiables", + "Book metadata lookup" (the three-way outcome rule), "Design language", "Screens". + Do not contradict it and do not edit it. + 2. ~/bookshelf/docs/METADATA-SOURCES.md — "Measured again 2026-09-09" and "The key + landed". Open Library is slow and long-tailed (median ~4s, max 22s) and fails + ~13% of the time with fast TLS resets; Google Books is keyed at 1,000 req/day. + 3. `logs/K-manual.summary` and `git show --stat HEAD` — the PREVIOUS wave (just + committed) added a manual "Add a book by hand" screen (`ui/add/`) with a route + that takes a pre-filled title/ISBN and a `BookDraft`. You will build on it. + Read its code, not just the report. + 4. `data/metadata/` in full (it is small), `ui/library/`, `ui/nav/`. + +## The feature + +Today the library search bar filters only the user's own books, as they type. Extend +it to also search Open Library and Google Books, so a user can find and add a book by +title and/or author — including "book name + author name" when the title is common. + +The user's design, and the decisions they have already made — do not relitigate: + 1. User types a query into the existing library search bar. + 2. Own library filters AS THEY TYPE (instant, offline, as today). + Online search runs ONLY ON SUBMIT (IME Search action, or an explicit button) — + never per keystroke. Decided by the user: per-keystroke would burn the 1,000/day + Google Books quota and results would thrash at OL's latency. + 3. Results show an "In your library" section on top and online results below. + 4. Results are DEDUPLICATED across the three sources (local, OL, GB): a book appears + once. "In your library" means the user owns SOME edition of that book — decided + by the user; an online hit that is a different edition of an owned book belongs + in the library section, not the online one. + 5. Tapping an online result opens a save sheet (same idea as the scan screen's Found + sheet: book summary + shelf picker + Save / Skip). ISBN is filled in ONLY when it + is unambiguous — see "ISBN rule" below. + 6. At the bottom of the online results: "Can't find it? Enter it by hand" -> the + previous wave's add screen, pre-filled with the query as the title. + +## Verified API facts (orchestrator tested these live on 2026-09-13 — trust them) + +Both APIs treat a free-text `q` as matching across title AND author, so +"hittite warrior williamson" and "the hobbit tolkien" both return the right book first. + +**Open Library** — `https://openlibrary.org/search.json?q=&limit=20&fields=` + - Results are WORKS (`key` = "/works/OL2028038W"), not editions. + - **`isbn` is NOT in the default response. You MUST pass `fields=`** or you will get + no ISBNs at all. Use: + `fields=key,title,subtitle,author_name,first_publish_year,edition_count,cover_i,isbn,publisher,number_of_pages_median` + - `isbn` is a flat list mixing ISBN-10 and ISBN-13 of ALL editions, e.g. + ["1883937388","9781883937386"] (one edition) or dozens for a popular work. It may + be absent (pre-ISBN books). `cover_i` may be null. + - Cover by id: `https://covers.openlibrary.org/b/id/{cover_i}-M.jpg`. That is a cover + the source REPORTED, so it is legitimate under SPEC's cover rule. Never synthesize a + by-ISBN cover URL. + - Observed latency 3-6.5s. Real example of a near-duplicate: the same book returned as + two works, authors "Joanne S. Williamson" and "Joanne Small Williamson". +**Google Books** — `https://www.googleapis.com/books/v1/volumes?q=&maxResults=20&printType=books&key=` + - Results are EDITIONS (volumes). `volumeInfo.industryIdentifiers` may be null. + `imageLinks` may be absent. `publishedDate` can be garbage ("101-01-01" observed). + - Popular titles return several editions of the same work ("The Hobbit" x5, author + strings "J.R.R. Tolkien", "J. R. R. Tolkien", "John Ronald Reuel Tolkien"). + - Observed latency ~0.7s. Same key and same redaction concerns as `GoogleBooksClient`. + - Returns loosely-related noise after the real hits. Do not try to filter it; keep + source order. + +## What to build + +### 1. Search clients (`data/metadata/`) + +A `search(query): SearchSourceResult` per source — add to the existing clients or add +sibling classes, your call, but REUSE their machinery rather than copying it: + - the same `OkHttpClient`, `withRetry` / `RetryPolicy`, `FailureKind`, + `SourceResult.fromException` / `fromHttpCode`, and for GB the key handling, + `requestUrl`-style pure URL construction, and `redact`. + - three-way per source, never a bare null: Found(list) / NotFound (authoritative + empty) / Failed(kind, reason). SPEC: a source that could not be reached must never + be presented as "no results". + - the wave-8 exception safety, exactly: never throws, `Throwable` -> `UNEXPECTED` + with the exception class name only, `CancellationException` RETHROWN FIRST. + - URL-encode the query via `HttpUrl.Builder` (not string concatenation). + - GB cover URLs through the existing https+zoom normalization in `GoogleBooksDtos.kt` + (it is `private` today; make it `internal` rather than duplicating it). + - Wire into `AppContainer` (one or two lines; you may edit that file for this). + +### 2. Local matching + +The current `BookDao.search` matches the whole string as one LIKE, so "hobbit tolkien" +matches nothing. Replace it for this screen with token matching: normalize the query +and the book (lowercase, strip diacritics, treat punctuation as space so "J.R.R." ~ +"j r r"), split the query on whitespace, and require EVERY token to appear in at least +one of title, subtitle, authors, isbn13, isbn10. A home library is hundreds to low +thousands of books, so a pure Kotlin matcher over the already-observed book list is +fine and far more testable than SQL — prefer that. If you do change `BookDao.search` +instead, its Robolectric DAO tests must be updated, not deleted. + +### 3. Merge + dedupe (pure, no Android, heavily tested) + +A pure object (e.g. `SearchResultMerger`) taking the local books and each source's +result list, returning something like +`SearchResults(inLibrary: List, online: List)`. + +Identity — two records are the same book if EITHER: + a) they share any ISBN after normalizing everything to ISBN-13 + (`IsbnUtils.toIsbn13`) — this is what joins a GB edition to its OL work, or + b) normalized title AND first author's surname match. Normalized title: lowercase, + strip diacritics and punctuation, drop a leading "the/a/an", drop anything after + the first ':' (subtitles). Surname: last alphabetic token of the first author. + ("Joanne S. Williamson" ~ "Joanne Small Williamson"; "J.R.R. Tolkien" ~ + "John Ronald Reuel Tolkien".) +Identity is transitive: group with union-find (or equivalent), not pairwise. + +Placement: + - Any group containing a local book -> "In your library", shown as the local book(s). + This includes local books reached ONLY via an online hit (the online record matched + by ISBN/title but the local tokens did not). + - Every other group -> ONE `OnlineBook` in the online section. Order by the group's + best rank in either source (min position), so relevance is preserved and neither + source is buried under the other. + - Field fill for a merged OnlineBook: title/authors from OL if present (work-level, + cleaner), else GB; year from OL `first_publish_year` else the year GB's + `publishedDate` starts with IF it is a plausible 4-digit year; cover from OL + `cover_i` else GB thumbnail; remember which sources contributed. + +**ISBN rule** (decided by the user — implement exactly): + - If the group has an OL work whose ISBNs normalize to EXACTLY ONE distinct ISBN-13 + -> use it. (A single-edition work, or one whose only ISBN-bearing edition is that + one — ISBN-10 and ISBN-13 of the same edition are ONE ISBN.) + - Else if the group's GB volumes carry exactly one distinct ISBN-13 -> use it (GB + returned that specific edition for this query). + - Otherwise -> no ISBN. Never guess among several. + +### 4. Library screen UI (`ui/library/`) + +- IME action Search on the existing field triggers the online search. Because an IME + action is invisible, ALSO show, whenever the query is non-blank and no online search + has run for it, a clearly tappable row below the local results: + "Search Open Library & Google Books for """. Minimum query 2 characters. +- Sections: "In your library" (existing card style) then "Online" results as rows + (small cover, title, authors, year). If there are no local matches, say so in one + quiet line rather than hiding the section header — the user must be able to tell + "not owned" from "didn't look". +- Online states: loading (with the local section fully usable meanwhile — SPEC: no + screen may block on network); results; authoritative no-results; per-source failure + as a quiet line naming the source and its `reason` (e.g. "Google Books couldn't be + reached — tls connection reset"), with Retry, while still showing the OTHER source's + results; both failed -> a retry affordance that does NOT say "no results". +- Editing the query after a submit clears the online section (and cancels any in-flight + search): stale results for a different query are misleading. +- Shelf/bookcase filter keeps applying to the local section only. +- Tap an online result -> save sheet (bottom sheet on the library screen): cover, + title, authors, year, ISBN if the rule gave one, shelf picker (reuse + `ui/components/ShelfPickerSheet.kt` / `ShelfPicker` the way the scan sheet does, + remembered last shelf included), buttons Save / "Edit details" / Skip. + * Save -> `BookRepository.createBook` with everything known, `coverSourceUrl` = + the cover URL (the repository already downloads it best-effort). Guard it: catch + Throwable, rethrow CancellationException, show an error, keep the sheet. No + double-tap double-save. After a save the book now appears in "In your library" + by itself (Room flow) — verify that in a test rather than hand-moving it. + * If an ISBN is known, ENRICH in the background: call the existing + `MetadataRepository.lookup(isbn)` when the sheet opens and fill blanks + (publisher, pages, description, better cover) via the same fill-blanks idea as + `MetadataMerger` when it lands. Save must NEVER wait for this — if the user saves + first, save what is known. Cancel it when the sheet closes. + * "Edit details" -> the previous wave's add screen, pre-filled with the draft + (title, subtitle, authors, publisher, year, pages, ISBN, description, cover URL if + the draft supports it). If the add route cannot carry the full draft, extend it + in `ui/add/` + `ui/nav/` minimally and consistently with how that wave built it + (e.g. a serialized, URL-encoded draft argument, or a small draft holder). Say + which you chose. +- Keep the online search state in the ViewModel so it survives rotation. +- Split stateless content composables so Paparazzi renders the REAL UI (see + HANDOFF.md wave-6 "soft spots": a hand-rolled lookalike snapshot is not coverage). + +## Constraints — these are hard + +- Kotlin. Match the surrounding code's style, naming and comment density — this + codebase comments the evidence for a decision, not a restatement of the code. +- You MAY touch: `data/metadata/`, `ui/library/`, `ui/add/`, `ui/nav/`, + `ui/components/` (sharing only), `AppContainer.kt` (wiring only), and if you choose + the SQL route, `data/local/BookDao.kt` + `data/repo/BookRepository.kt` search + methods only. Plus tests. Nothing else. +- DO NOT touch any build file. Every library you need is already declared + (OkHttp, kotlinx-serialization, coil3, Room, Compose). If you think you need + something else, STOP and say so. +- DO NOT touch `data/remote`, `data/prefs`, sync code, `server/`, `docs/`. +- DO NOT read, print or grep `app/local.properties`; never put a real API key in code, + tests or your report. Tests use "test-key-123". Do NOT make live network calls from + unit tests (an opt-in live test gated on an env var like the existing + `LiveMetadataLookupTest` is welcome but not required). +- DO NOT git commit, add or push. +- Build with `./tasks/gw ` — NEVER `./gradlew`. +- RUN BUILDS IN THE FOREGROUND. Never background a build and end your turn promising a + later report — you will never get to give it. Previous workers did exactly this. +- No emulator. You cannot run the app. Do not claim you did. + +## Definition of done + +Run in the foreground and paste real output tails: + ./tasks/gw assembleDebug -> exit 0 + ./tasks/gw testDebugUnitTest -> exit 0. Count MUST GO UP from whatever the + previous wave left (sum TEST-*.xml under + app/app/build/test-results/testDebugUnitTest/ + BEFORE you change anything, and again at the end). + ./tasks/gw recordPaparazziDebug -> exit 0 + ./tasks/gw verifyPaparazziDebug -> exit 0 + +Required tests with REAL assertions: + - OL parse from a fixture shaped like the real responses above (mixed ISBN-10/13, + missing isbn, null cover_i); GB parse with null industryIdentifiers and no + imageLinks; the OL request URL contains `fields=` including `isbn`; the query is + URL-encoded ("a&b c" -> not a broken URL); GB URL carries the key only when + non-blank; GB failure reasons are redacted. + - Each search client: HTTP error -> Failed not NotFound; empty docs -> NotFound; + throwing interceptor -> Failed(UNEXPECTED), class name only; CancellationException + propagates. + - Local matcher: "hobbit tolkien" matches The Hobbit by J.R.R. Tolkien; + "jrr tolkien" and "tolkien hobbit" too; diacritics ("bronte" ~ "Brontë"); + a query token matching nothing excludes the book. + - Merger: GB edition joins OL work by shared ISBN; "Joanne S. Williamson" and + "Joanne Small Williamson" works collapse; "The Hobbit" x5 GB editions + OL work -> + ONE online entry; an online hit of a DIFFERENT edition of an owned book lands in + inLibrary, not online; transitivity (A~B by ISBN, B~C by title -> one group); + ranking interleaves sources by best rank; distinct books with the same title but + different authors stay separate. + - ISBN rule, each branch: OL ["1883937388","9781883937386"] -> that ISBN-13; OL with + many ISBNs + one GB ISBN -> GB's; several GB ISBNs and no single OL ISBN -> null; + OL with no isbn field -> falls to GB or null. + - ViewModel: typing does NOT trigger an online search; submit does; editing the query + clears online results and cancels the in-flight search; one source failing still + shows the other's results with a failure line; both failing is not reported as + "no results"; save maps fields to createBook; a throwing save keeps the sheet with + an error and does not throw. + - Paparazzi light + dark: search with both sections populated; online loading while + local results show; one source failed; the online-result save sheet. + +Grep the build output for Kotlin warnings on files you touched; "Check for instance is +always 'false'" in particular is a real bug signal on this project, not noise. + +## Report + +End with a plain report: + - what you changed, file by file + - verbatim tails of each gradle command + - test count before and after, summed from the XML + - how the draft reaches the add screen, and any change you made to the previous + wave's route + - your own judgement: where will dedupe get it WRONG on a real shelf (false merges + and missed merges)? Name concrete cases. That is worth more than a clean report. + - anything you could NOT do or did differently, and why + - anything that looks wrong but was out of scope + +Be honest. Workers here have over-claimed before, and everything is re-verified. diff --git a/tasks/wave-chain.sh b/tasks/wave-chain.sh new file mode 100755 index 0000000..57d5efb --- /dev/null +++ b/tasks/wave-chain.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# wave-chain.sh : [: ...] +# e.g. wave-chain.sh 9:K-manual 10:L-search +# +# Runs waves IN SEQUENCE with no orchestrator attached (2026-09-13: the user asked for +# wave 10 to start straight after wave 9 without waiting on them). Between waves it +# runs a mechanical acceptance gate, because starting a worker on a red tree wastes a +# whole session. This gate is NOT the orchestrator's review — it is the floor below it. +# +# Per wave: +# 1. run-task.sh in the FOREGROUND (quota-aware, resumable) +# 2. gate: assembleDebug, testDebugUnitTest, verifyPaparazziDebug, test count UP from +# the XML, 0 failures, no build files touched, no "always 'false'" warning +# 3. gate red -> ONE fresh fix worker (-fix) pointed at the gate log, re-gate +# 4. green -> commit + push; red -> write sentinel as FAILED and STOP the chain +# +# Holds the sprite lease itself (HAZARD #5). wave-guard.sh cannot be used: its +# workers_running() sees nothing during the 5-10 min gate between workers and would +# release the lease mid-chain. Liveness here is `kill -0` on this script's own PID, +# never pgrep -f (HAZARD #6/#10). +set -u +cd "$HOME/bookshelf" || exit 1 +L="$HOME/bookshelf/logs"; mkdir -p "$L" +CLOG="$L/wave-chain.log"; LEASE="bookshelf-wave" +RESULTS="app/app/build/test-results/testDebugUnitTest" + +log() { echo "[$(date -Is)] $*" >> "$CLOG"; } + +lease_present() { sprite-env curl /v1/tasks 2>/dev/null | grep -q "\"$LEASE\""; } +lease_acquire() { + local a + for a in 1 2 3; do + sprite-env curl -X DELETE "/v1/tasks/$LEASE" >/dev/null 2>&1 + sprite-env curl -X POST /v1/tasks -H 'Content-Type: application/json' \ + -d "{\"name\":\"$LEASE\",\"expire\":\"3600s\"}" >/dev/null 2>&1 + lease_present && return 0 + sleep 5 + done + return 1 +} +lease_release() { sprite-env curl -X DELETE "/v1/tasks/$LEASE" >/dev/null 2>&1; } + +CHAIN_PID=$$ +( + last=$(date +%s) + lease_acquire && log "lease acquired and VERIFIED" || log "FATAL: lease acquire failed" + while kill -0 "$CHAIN_PID" 2>/dev/null; do + sleep 30 + now=$(date +%s) + if [ $((now - last)) -ge 900 ] || ! lease_present; then + lease_acquire && log "lease renewed" || log "ERROR: lease renewal FAILED" + last=$now + fi + done +) & +RENEWER=$! +trap 'kill $RENEWER 2>/dev/null; lease_release; log "chain exiting, lease released"' EXIT + +count_tests() { + python3 - "$RESULTS" <<'PY' +import glob, sys, xml.etree.ElementTree as ET +t = s = f = 0 +for p in glob.glob(sys.argv[1] + "/TEST-*.xml"): + r = ET.parse(p).getroot() + t += int(r.get("tests")); s += int(r.get("skipped")); f += int(r.get("failures")) + int(r.get("errors")) +print(t, s, f) +PY +} + +# gate