Commit Graph
37 Commits
Author SHA1 Message Date
Spriteandclaude 4695731f91 docs: wave 11 accepted; SPEC records auth refresh + why the 404 rule needs it
HANDOFF: expired-token incident, local data loss, verification incl. live
sync against the public URL, phone recovery path, HAZARD #12 (slow storage).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:57:47 +00:00
Spriteandclaude 009cb9a7cc Wave 11 (M-authexpiry): refresh auth token every sync; recover rows lost to the expired-token sync
Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = 355 tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/M-authexpiry.txt. Worker: $4.886013000000001, 123 turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:54:41 +00:00
Spriteandclaude 81f63bffc0 M-authexpiry prompt: measured baseline 337 tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:58:57 +00:00
Spriteandclaude 62cd61e707 Plan wave 11 (M-authexpiry): refresh token each sync, recover lost rows
wave-chain.sh: commit subject now comes from tasks/<task>.subject instead
of being hard-coded for waves 9/10; drop a stale session trailer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:39:41 +00:00
Spriteandclaude ab1d294657 server: user auth tokens valid 180 days (was PocketBase default 5)
Phone sync broke 2026-09-17 with HTTP 400: its 5-day token had expired and
the app never refreshes it, so writes arrived anonymous and the rules
rejected them. User chose 180 days. App-side refresh follows in wave 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:28:12 +00:00
Spriteandclaude 3c30a20324 Review fixes for waves 9-10; preload covers so Save stops waiting on them
Orchestrator review of b600df6 and cf82cbc (the chain's gate only proved they built).

Fixed:
- Search: "In your library" now shows the merged set (owned editions reached only via
  an online ISBN were removed from "Online" and shown nowhere); the shelf filter applies
  to it and hidden owned matches are counted.
- "Edit details" closes the online save sheet (left open, it invited a duplicate save).
- A failed remembered-shelf write after a successful insert no longer reports
  "Couldn't save" (a retry would duplicate the book).
- Library search no longer normalizes every book on every keystroke on the main thread.
- Add-by-hand duplicate check and the scan sheet's save are guarded (no crash, no
  double-tap duplicate, CancellationException rethrown).

Covers (design decided with the user): createBook used to download the cover before
writing the row, so every save waited on the image host, and a failed download silently
saved no cover file. The cover now downloads as soon as a sheet has its URL; Save waits
for it if needed and copies the file into place. A failed download shows on the sheet
with Retry, and Save becomes "Save without cover". Unsaved preloads are discarded, and
leftovers from a killed process are swept at startup.

337 tests (was 308), 2 skipped, 0 failures; assembleDebug and verifyPaparazziDebug green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:01:54 +00:00
Spriteandclaude cf82cbc484 Wave 10 (L-search): search own library + Open Library + Google Books, deduplicated
Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = 308 tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/L-search.txt. Worker: $10.452535, 157 turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
2026-09-15 18:28:37 +00:00
Spriteandclaude b600df69c3 Wave 9 (K-manual): add a book by hand, no ISBN required
Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = 241 tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/K-manual.txt. Worker: $5.051114400000003, 108 turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
2026-09-15 11:58:37 +00:00
Spriteandclaude 9b74f50565 Plan waves 9-10: manual entry, then online search; chained runner
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
2026-09-13 17:30:38 +00:00
Spriteandclaude 0c0b8466cc docs: first-command check no longer counts its own shell as a worker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQb3d68LqWD4EdND3C8jkD
2026-09-13 11:38:26 +00:00
Spriteandclaude 007a1f426d wave-guard: match workers by argv shape; accept wave 8; record HAZARD #10
The guard's `pgrep -f 'run-task\.sh'` matched the orchestrator's own stale
launching shell, whose command line contains that text, so it kept the sprite
awake for 17 hours after J-crashsafe finished. workers_running() now requires
argv[1] to be the runner script and argv[2] to be one of this wave's tasks.

Also commits the wave-7 sid/sentinel that were never added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQb3d68LqWD4EdND3C8jkD
2026-09-13 11:33:21 +00:00
Spriteandclaude 34097269c9 Lookup path can't crash the app; persist crash reports for off-device reading
The wave-7 build crashed on the first scan on a real phone and never reproduced.
A live off-device run of the real lookup (93546ed) found nothing wrong with the
parse/merge code. What it did find: nothing on that path was exception-safe. Both
clients caught only IOException, classify caught only serialization errors, and
ScanViewModel.runLookup had no guard at all, so any other throwable — platform
TLS, an OkHttp internal, anything this JVM can't reproduce — killed the process
instead of surfacing on the scan sheet.

- OpenLibraryClient and GoogleBooksClient catch Throwable -> new
  FailureKind.UNEXPECTED, never retried. The reason names the exception CLASS
  only; its message can carry the request URL and therefore the API key.
- ScanViewModel.runLookup guards the same way, which also covers its Room call.
- CancellationException is rethrown ahead of every catch-all: dismissing the
  sheet cancels the lookup, and that must not render as a failure.
- diagnostics.CrashReporter persists uncaught stack traces and chains to the
  previously installed handler in a finally, so the process still dies normally
  even if writing the report fails.
- Settings gains a Diagnostics section: last crash, View trace, Share.

Not fixed, flagged by the worker: performSave/performSaveManualEntry make the
same unguarded Room calls on the save path.

Verified by the orchestrator: assembleDebug (--rerun-tasks) exit 0;
testDebugUnitTest exit 0, 205 tests (was 190), 2 skipped, 0 failures, counted
from TEST-*.xml; verifyPaparazziDebug exit 0; 0 "always 'false'" warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQb3d68LqWD4EdND3C8jkD
2026-09-13 11:31:00 +00:00
Spriteandclaude 1295d88d6e docs: record wave 8 (J-crashsafe) in flight
The user's first scan on the wave-7 build crashed and never reproduced. Their
theory fits structurally — the Google Books Found path had never executed in
production before the key landed — but nine live lookups through the real
repository, including both of their ISBNs, threw nothing, so the parse/merge
code is exonerated by evidence rather than by argument. Written down so no
future worker "fixes" code that was measured working.

What the wave fixes is the defect found while looking: the lookup path catches
only IOException and the ViewModel catches nothing, so any other throwable kills
the process instead of reaching the user as "couldn't be reached" — and the app
has no crash capture at all, which is why one crash left no evidence.

Committed with an explicit pathspec: a wave is in flight (HAZARD #7).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:51:13 +00:00
Spriteandclaude 0455794603 server: anonymous LIST of users returns 403, not 200 with an empty array
Found while re-verifying the security posture against the public hostname now
that the server is internet-exposed. pb_hooks/main.pb.js covered bookcases,
shelves and books but never users, so an anonymous GET of the accounts
collection answered 200 with an empty array.

Nothing leaked: two real accounts exist and the declarative listRule filtered
both out, so no account, email or id was ever visible to an anonymous caller.
But "200 with []" is the exact wrong signal this hook exists to remove — some
clients read it as an allowed request — and the accounts collection is the last
place to leave it. Defensible while the server was localhost-only; not now.

Re-verified over the internet after restarting the service: books, shelves,
bookcases and users all 403 anonymous, self-registration 403, health 200.

Also re-verified that login still works, since this hook now runs on a
collection the app authenticates against: auth-with-password returns 200 with a
token, and an authenticated LIST of all four collections still returns 200. The
hook rejects unauthenticated list/search only, and auth-with-password is not a
list request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:47:43 +00:00
Spriteandclaude 93546ed724 Opt-in live test: the real metadata lookup, against both real sources
Investigating the crash the user hit on the first scan after the Google Books
key landed. The structural suspicion was sound: keyless Google Books 429'd every
caller, so GoogleBooksClient's 2xx branch, toBookMetadata(), normalizeCoverUrl()
and the two-source merge had never once executed in production before 486f6eb.
First exercise of a code path is where a first crash belongs.

It does not reproduce here. Nine live lookups through the real
MetadataRepository.lookup — both of the user's previously-failing ISBNs plus a
control, three times each, with the real key — all returned Found with cover
art, 254ms to 4.7s, nothing thrown. So it is not a parse, merge or cover-URL
bug in any form this machine can provoke, which is worth knowing before anyone
spends a wave rewriting that code.

The test asserts the contract rather than the content: that lookup RETURNS
instead of throwing. That is what both clients' KDoc claims ("Never throws")
and what nothing currently enforces.

Gated on LIVE_METADATA=1 like LiveSyncTest, so the normal suite stays offline
and deterministic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:36:11 +00:00
Spriteandclaude 1385ad286f docs: the server is on the sprite and internet-exposed
The long-standing open question "where will the server actually live" is
answered for now: it stays on this sprite, bound to 0.0.0.0:8090 and published
over HTTPS by the sprite proxy. Several docs asserted the opposite — HANDOFF
said "127.0.0.1:8090, deliberately NOT internet-exposed (no --http-port, so the
sprite proxy can't reach it)", which is flatly wrong today.

The consequence is the part worth writing down: PocketBase's API rules are now
the only thing between this library and the internet. There is no NAT, no VPN,
no reverse proxy. So the anonymous-access curls stop being a formality, and they
have to run against the PUBLIC hostname — localhost cannot tell you what the
world can reach. Re-verified that way: books/shelves/bookcases LIST all 403,
self-registration 403, health 200.

One gap found while re-verifying, recorded but NOT fixed: users LIST answers 200
with an empty array instead of 403. Nothing is disclosed — two real accounts
exist and the listRule filters both out — but it is the same wrong-signal quirk
pb_hooks/main.pb.js exists to close, and that hook never listed the users
collection.

SPEC's offline-first rationale is amended rather than its rule: the reason is no
longer residential NAT but a sprite that suspends when idle and wakes on
request. The rule is unchanged and does not depend on which.

server/deploy/ still documents systemd/Docker/Tailscale on home hardware; it now
says up front that this is the intended end state, not what is running.

Also corrected, since it was adjacent and plainly false: README still claimed the
app had never run on a physical device. It has, since 2026-09-09. What is true is
that no *automated* test runs on a device — there is no emulator on this box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:32:17 +00:00
Spriteandclaude 26f76bf0f7 run-task.sh: stop reading the worker's own prose as a quota error
The quota detector grepped `cat "$LOG" "$ERR"` for strings like "429" and "usage
limit". $LOG holds the worker's final JSON, including its .result prose — so a
worker whose TASK was about HTTP 429 finished successfully, wrote a report saying
so, and the runner read its own worker's words, logged "QUOTA hit", slept 600s
and was about to --resume a session that had already succeeded. That would have
burned quota redoing finished work with a fresh worker loose on a completed tree.

Two independent defences, because either alone suffices:
  - the blob is now stderr plus the result text ONLY when is_error is true,
    falling back to the whole log when it isn't valid JSON (a hard crash writes
    no JSON, and has no prose to be confused by).
  - the success check runs BEFORE the quota and session-vanished checks. A
    finished worker is finished regardless of what strings its output contains.

Regression-checked against the real logs/I-gbkey.json that triggered this: the
old logic matches the quota pattern, the new logic yields SUCCESS.

Recorded as HAZARD #9 in docs/HANDOFF.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:08:05 +00:00
Spriteandclaude 486f6ebc48 Use the Google Books API key: the fallback source actually answers now
The user obtained a restricted Google Books key. Keyless requests 429 for every
caller on the internet — all anonymous traffic bills to one shared Google Cloud
project whose daily quota is permanently exhausted — so the documented fallback
has never once answered. Because MetadataRepository.combine turns "a source
failed, none found" into Unavailable, that standing failure meant every Open
Library hiccup reached the user as "couldn't be reached". The app has been
effectively single-sourced since it was written.

Build plumbing reads GOOGLE_BOOKS_API_KEY from local.properties (gitignored),
falling back to the environment and then to empty. A blank key is a supported
state: a fresh clone still builds a working app that falls back to the keyless
endpoint, rather than failing to build.

GoogleBooksClient appends the key only when non-blank, building the URL with
HttpUrl.Builder in a pure requestUrl() so it is testable without a socket. The
key is scrubbed from SourceResult.Failed.reason before that string can reach the
scan sheet — it is rendered to the user and is our only diagnostic channel from a
real phone, and some okhttp/JDK IOExceptions embed the full request URL in their
message. Defensive, not a response to an observed leak.

Resolves the RATE_LIMITED decision parked in RetryPolicy's KDoc: a keyed 429 is
the short per-user rate limit and gets exactly one retry, honouring Retry-After
capped at 2s. A keyless 429 is still the dead daily quota and is still never
retried.

Verified against the live API, not only offline: both ISBNs that failed on the
phone (9781883937386, 9781883937676) plus a control return HTTP 200, in the
percent-encoded URL shape HttpUrl actually produces. Both books are in Google
Books, so the restored fallback now covers precisely the Open Library TLS-reset
failure that broke those scans.

189 unit tests (was 172), 0 failures; Paparazzi unchanged; release APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7WHnTx2Cso4VV245WDAJY
2026-09-11 23:34:43 +00:00
Spriteandclaude d6d02f788c Second on-device feedback round: eight fixes, and a measured retry policy
The ghost bookcase was an inset bug, not a data bug. LocationsScreen's list
branch dropped the Scaffold's innerPadding while its empty-state branch applied
it, so the first bookcase row rendered under the top app bar and was invisible.
Both of the user's bookcases were always real; they just could not see the first
one, so they made a second. Every other screen was checked for the same class of
bug — Locations was the only one.

Metadata lookup is now designed against a measurement rather than a guess
(docs/METADATA-SOURCES.md § "Measured again 2026-09-09"):

  - Google Books keyless is dead for everyone, permanently. The user's
    residential-IP test returned a quota error naming a shared anonymous PROJECT,
    not an IP, so the earlier "your phone may well get answers" guess is wrong and
    is now marked CORRECTED in place. Because combine() turns any Failed-with-no-
    Found into Unavailable, that standing failure meant every Open Library hiccup
    surfaced as "one or more sources couldn't be reached". The API key is
    deliberately deferred by the user; this commit leaves the source broken.

  - Our own timeouts were manufacturing failures. Over 30 live requests, 13%
    failed — all fast TLS resets under 2.5s — while successes ran to a median of
    4.3s and a max of 22.0s. Two of 26 successes exceeded the old 12s callTimeout,
    so ~8% of lookups that were about to work were cancelled and reported as
    unreachable. Timeouts are now 25s/20s/20s.

That asymmetry (cheap failures, expensive successes) is what RetryPolicy encodes.
It retries TRANSPORT and SERVER_ERROR with a 250ms/750ms jittered backoff, and
deliberately does not retry TIMEOUT (the budget is already spent) or RATE_LIMITED
(hammering a quota is how an intermittent block becomes a permanent one — this
project's IP has already been refused outright once during research).

SourceResult.Failed now carries a FailureKind alongside its human reason, and the
reason names the specific failure ("tls connection reset, 3 attempts") instead of
a generic "network error". That string was already threaded to the UI and dropped
on the floor; LookupFailedSheet now renders it. It is the only diagnostic channel
we have from a real phone, so nothing may parse it.

Also from the same feedback round:
  - Grouped ModalBottomSheet shelf picker, replacing two near-duplicate flat
    dropdowns that listed every bookcase x shelf pair. Sections per bookcase,
    empty bookcases say so, and the most recently used shelf is pinned on top.
  - The recent shelf persists across sessions (SettingsStore.LAST_SHELF_ID) and is
    cleared on sign-out. It is offered, never pre-selected: the user weighed that
    and chose one tap over the risk of silently mis-shelving a book.
  - Locations dialogs and the manual-ISBN dialog auto-focus their first field.
  - The library filter menu offers "Add a bookcase to enable filtering" instead of
    a lone "All books" that is already the active state and cannot be changed.
  - The Locations button is Material Symbols' "shelves" (a bookcase) instead of
    Warehouse (a barn). material-icons-extended 1.7.8 has no bookcase glyph.
  - The scan sheet drops "you can lower the book" — the ISBN echo already says it.

assembleDebug exit 0; testDebugUnitTest 172 tests, 1 skipped, 0 failures (was
138); verifyPaparazziDebug exit 0; assembleRelease exit 0, signed with the real
release key; zero "always 'false'" warnings on a --rerun-tasks rebuild.

Three soft spots are recorded in docs/HANDOFF.md and are NOT verified: the
ghost-bookcase Paparazzi snapshot renders a lookalike of the screen rather than
the screen, the auto-focus calls swallow their own failure and no emulator exists
here, and the picker opens as a sheet stacked on the save sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSnVqWdiQNEcPFRq1hGZAi
2026-09-09 17:45:09 +00:00
Spriteandclaude dd3a61fc33 docs: accept wave 5; record HAZARD #8 (guard can die without its sentinel)
The wave-guard was killed after its 11:13 renewal and never wrote
logs/WAVE5-DONE, despite the worker succeeding at 11:21. That makes this
file's own first-command heuristic actively misleading — "no sentinel +
no processes -> workers were KILLED" would have thrown away a completed,
verified wave. Recorded the reliable signals instead: the size of
logs/<name>.json and the tail of logs/<name>.state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:46:49 +00:00
Spriteandclaude 93f972b7d1 Distinguish can't-scan, can't-look-up, and genuinely-not-found
Implemented by a Sonnet worker (tasks/G-diagnostics.txt) against the
three-way contract added to SPEC in 1969b74; independently re-verified by
the orchestrator rather than accepted on the worker's own report.

The app previously conflated three outcomes. A barcode that failed the
ISBN-13 checksum produced NOTHING — no sheet, no message — which is
indistinguishable from a dead camera. A lookup that failed at the
transport layer (Google Books answers HTTP 429 to keyless callers) was
reported as "No match found", telling the user a book does not exist when
the app never managed to ask. Only the third case was ever honest.

Now: each client returns SourceResult{Found,NotFound,Failed} from a pure
classify() so the status-code matrix is testable offline without a
MockWebServer; MetadataRepository.combine folds those into
LookupResult{Found,NotFound,Unavailable}, where NotFound requires EVERY
source to have answered authoritatively. A rejected barcode raises a
throttled banner on the camera screen, sharing ScanCodeFilter's existing
debounce so a non-book barcode sitting in frame shows the message once
instead of flickering per analyzed frame. A failed lookup gets its own
sheet with Retry / Enter by hand / Skip that never claims the book is
unknown. The metadata OkHttpClient finally has a call timeout.

Orchestrator's own addition: the manual-ISBN dialog silently discarded an
unparseable entry — the same silent failure on the same screen, missed
because it sat just outside the worker's brief. It now marks the field in
error and disables Look up until the checksum passes.

assembleDebug + verifyPaparazziDebug exit 0; 138 tests, 1 skipped, 0
failures (was 107). Boundary check clean: no build files, no data/local,
data/remote, data/repo, ui/settings, ui/locations or ui/detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:44:29 +00:00
Spriteandclaude 0aff56f97e docs: record wave 5 (G-diagnostics) in flight
Explicit pathspec only — a worker is writing to the tree right now
(hazard #7: `git add -A` mid-wave has swept half-finished source into
commits twice on this project).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:43:27 +00:00
Spriteandclaude 1969b74cc9 SPEC: lookup outcome is three-way; wave 5 worker prompt
SPEC now states that a source which could not be reached must never be
reported as a book that does not exist: Found / NotFound / Unavailable,
where NotFound requires every source to have answered authoritatively.
Also records that a rejected barcode must not be silent, and that the
by-ISBN cover URL is not evidence a cover exists.

run-task.sh exports CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0, per the wave-4
post-mortem: `claude -p` otherwise kills background tasks at 600s, so a
worker that backgrounds a Gradle build can never report on it. Safe to
edit now — no workers are running (hazard: never edit it while they are).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 10:42:32 +00:00
Spriteandclaude 1ba22a9f36 The two books that failed are in Open Library after all
The user supplied the failing ISBNs: 9781883937386 (The Hittite Warrior)
and 9781883937676 (Shadow Hawk), both Bethlehem Books. Both resolve
against the source the app already queries — title, author, publisher,
page count, cover art — and both real responses parse correctly through
the app's own OpenLibraryClient. Captured as fixtures with a regression
test, because these are the specific books that motivated the research.

That kills the coverage hypothesis for these two and demotes Harvard,
which holds neither: its 93% in the sample table is inflated by
construction (the sample was drawn from Harvard) and misleading in
exactly the direction that matters — it is a research library and does
not carry small-press children's historical fiction.

The failure is upstream of the metadata sources. Documented the three
candidates; the leading one is that the barcode never decoded into a
valid ISBN-13, which ScanCodeFilter drops silently with no UI feedback
whatsoever. Revised the recommendation accordingly: make the app say
what happened before adding any source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 10:36:33 +00:00
Spriteandclaude bd24ecbafd docs: first on-device test results and metadata-source research
Records the coil3 StateFlow trap (a Kotlin warning, not an error, that
silently blanked every book cover) so a future session greps for it, and
the two Open Library cover defects behind it.

METADATA-SOURCES.md answers the user's research question about
alternative lookup sources: measurements over a 60-ISBN sample drawn
from a third-party catalogue, the options with costs, and a
recommendation to fix diagnosis before buying coverage. Nothing there is
implemented — the user asked to be consulted first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 10:13:36 +00:00
Spriteandclaude 356f639cdd Fix six on-device issues found in the first real phone test
The cover one is the interesting bug. BookCover branched on
`painter.state`, but in coil3 that is a StateFlow<State>, not a State —
so every `is AsyncImagePainter.State.X` arm was always false. A `when`
used as a statement needs no else, so it compiled clean and drew
NOTHING: no cover, no placeholder, no error icon. That is why a
successfully looked-up book showed as a bare title floating in space.
Collect the flow before matching on it.

Two more cover defects sat behind that one:
- OpenLibraryDtos synthesized covers.openlibrary.org/b/isbn/{isbn}-L.jpg
  unconditionally. For an edition with no art that URL answers 200 with a
  43-byte 1x1 transparent GIF (verified against the live service), which
  an image loader calls a successful load. So even with the state bug
  fixed it would have painted an invisible cover and never fallen back.
  Now the URL comes from OL's own `cover` object, which is present only
  when art actually exists.
- Because that URL was never blank, MetadataMerger's "fill blanks from
  the other source" rule could never reach Google Books' thumbnail. With
  OL reporting null, the fallback works, and MetadataRepository adds the
  by-ISBN URL as a genuine last resort — with `default=false`, so a miss
  is a 404 the loader can report instead of a blank image.

The placeholder itself is now drawn in every non-success state (loading
included, where it previously drew an empty box) and reads as a book:
mahogany spine strip, gold hairline, letterpress panel.

Also:
- SyncStatusBar owns its navigation-bar inset and takes wider horizontal
  padding, so it clears a phone's rounded display corners; it moves into
  Scaffold's bottomBar slot so its height reaches the content padding.
- SetupScreen takes safeDrawingPadding outside verticalScroll, so the IME
  shrinks the viewport instead of covering Password, plus Next/Next/Done
  IME actions.
- Library card titles drop to a 20sp line height with the author given
  its own 4dp gap: at titleSmall's 24sp leading a wrapped title left the
  author closer to the last title line than the title lines were to each
  other, so the byline read as part of the title.
- The scan sheet now names the ISBN it just read and says "Searching…",
  so a decoded barcode is legible as decoded and the user can lower the
  book instead of holding it to the camera at a bare spinner.

assembleDebug + assembleRelease exit 0; 106 unit tests, 0 failures;
verifyPaparazziDebug green against re-recorded snapshots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 09:55:53 +00:00
Spriteandclaude 5fbc597cbc docs: record wave 4 acceptance and what it left undone
Wave 4 is verified complete. Also records the two mechanisms that stopped F3
from reporting (the 600s background-task ceiling in `claude -p`, then the quota
wait) so the next worker launch does not repeat it, and flags the one thing
wave 4 owed and never delivered: nobody has looked at the fourteen new
screenshots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
2026-09-08 21:28:43 +00:00
Spriteandclaude 0f47ee917f orchestration: run workers as a sprite service; record wave 4 completion
setsid nohup did not survive orchestrator teardown on 09-06 (uptime was
continuous, so this was a teardown kill, not the hazard-#5 suspend).
tasks/service-worker.sh runs a worker under the sprite service supervisor
instead, which both outlives the orchestrator and holds the box awake, making
the task-lease guard redundant. It is sentinel-guarded so a supervisor restart
does not re-run a finished wave, and it stops its own service afterwards so the
sprite can suspend.

logs/WAVE4-DONE records that F3's files were all complete but the worker was
stuck re-running verification it could not finish, so the orchestrator ran the
verification itself and accepted the work. F3's own written report — including
the design critique of the rendered screens it was asked for — was never
produced; that gap is recorded in the sentinel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
2026-09-08 21:28:18 +00:00
Spriteandclaude 5455df2d61 Wave 4 (F3): settings email, five screens' screenshots, release signing, README
Completes wave 4. Verified by the orchestrator, not self-reported:
assembleDebug / testDebugUnitTest / assembleRelease all exit 0;
102 tests, 1 skipped, 0 failures, 0 errors.

- Settings showed the PocketBase user id instead of the signed-in email,
  because login never persisted the email. AuthRepository now writes it to
  SettingsStore on success and sign-out clears it; SettingsUiState carries
  userEmail in place of userId. AuthRepositoryTest asserts both directions.
- Paparazzi coverage for the five screens library was missing: setup, detail,
  scan, locations, settings, each light + dark, populated rather than empty.
  Scan cannot show a live camera under Paparazzi, so its tests render the
  reticle overlay and the result bottom sheet over a static backdrop.
- Release signing via an optional gitignored app/keystore.properties. Without
  it assembleRelease still works and comes out debug-signed, so the build is
  not owner-only. R8 deliberately left off; nothing has proven Room, Retrofit,
  kotlinx-serialization and ML Kit survive it.
- Top-level README: shared-library model, offline-first architecture, the
  push-then-pull last-write-wins conflict rule SPEC requires be documented
  here, build/deploy/install steps, and honest current limitations.

The signed APK and the keystore are intentionally not committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
2026-09-08 21:28:11 +00:00
claude 36b46b4644 orchestration: verify lease acquisition in wave-guard; F3 prompt for wave 4 finish
wave-guard.sh post-mortem: lease_hold() did DELETE-then-POST with both results
discarded and logged "lease renewed" unconditionally, so a failed re-POST left
the box with no lease while the log claimed it was protected. That matches the
wave-4 loss exactly (last "renewal" 11:39, workers dead 11:46, reboot 12:55).

Now: every acquire is verified against GET /v1/tasks before it is believed, a
failed acquire retries and is logged as a failure, and the lease is re-checked
every POLL rather than only every RENEW so a lease lost between renewals is
caught in seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mTs3kQXQsQwonXpEq7aEw
2026-09-06 19:00:11 +00:00
claude cf36b27457 Wave 4 (partial): live-sync harness + Library screenshots; fix authors null decode
Both wave-4 workers hit the 5h session limit ~11 minutes in and were then lost
to a sprite suspend. This commit preserves the work that landed before that,
independently verified green (assembleDebug + testDebugUnitTest, 94 tests).

F1-livesync:
- LiveSyncTest + server/live-sync-test.sh: end-to-end exercise against a real
  PocketBase (auth, push with client ids, pull, tombstones, cover round-trip).
  Gated behind LIVE_SYNC=1 so the normal test task stays green with no server.
- Fix: BookDto.authors must be nullable. PocketBase serializes an unset `json`
  field as literal null (unlike text/number, which come back ""/0), so decoding
  any real response with empty authors threw. Found by the live test; no fake
  had ever reproduced it.
- Fix: cover upload derived its media type from the filename instead of
  hardcoding image/jpeg.

F2-release:
- ScreenFixtures + LibraryScreenPaparazziTest: library populated and empty,
  light and dark (4 PNGs).

Still owed by wave 4: screenshots for the other five screens, release keystore
+ signed APK, top-level README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mTs3kQXQsQwonXpEq7aEw
2026-09-06 18:56:55 +00:00
claude 0088fda095 orchestration: wave 4 prompts; decouple guard poll from lease renewal
Guard now polls every 30s but renews every 15 min. Coupling them meant a wave that
finished just after a renewal sat undetected for a full interval with the sprite
pinned hot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:54:03 +00:00
claude 9bf5ff4d6c docs: record wave 3 acceptance, hazard #7, and gaps carried into wave 4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:49:23 +00:00
claude a9d4ad8096 Wave 3: all six screens (E1 shell/setup/locations/settings, E2 library/detail/scan)
Nav graph, setup with distinct URL-vs-credential errors, locations tree with reorder
and bulk move, settings; library grid with search/sort/filter, detail with soft-delete
undo, continuous scan with duplicate-ISBN warning.

Verified by orchestrator: assembleDebug exit 0; testDebugUnitTest exit 0,
91 tests, 0 failures, 0 errors (68 -> 91).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:48:54 +00:00
claude c18649c726 orchestration: sprite task lease + durable wave-completion record
The wave-3 loss was caused by sprite auto-suspend, not nohup process-group
semantics. /.sprite/llm.txt: 'When idle, sprites pause automatically. Services and
sessions keep sprites alive.' Detached processes are on neither list, so setsid is
necessary but not sufficient.

tasks/wave-guard.sh holds a /v1/tasks lease (max 3600s, renewal is DELETE+POST since
re-POST returns 409), renews every 15 min while workers run, writes logs/WAVE<N>-DONE,
then releases the lease so the sprite can suspend rather than idle hot.

Also documents hazard #6 (pgrep -f / pkill -f matching the orchestrator's own shell)
and adds the wave-3 worker prompts and shared screen contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:48:53 +00:00
claude d1a73a1193 Wave 2: data layer (C) + metadata/scanning (D)
Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor, SyncEngine
(push-then-pull, LWW, tombstones, client-generated ids), SettingsStore, AppContainer.
Open Library + Google Books merge, ISBN validation, CameraX + ML Kit scanner plumbing.

Verified by orchestrator: assembleDebug exit 0; testDebugUnitTest exit 0, 68 tests,
0 failures, 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
2026-09-06 03:24:04 +00:00
claude 6c17e42037 Baseline: wave 1A server complete, wave 1B Android scaffold + design system green
assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
2026-09-06 01:58:37 +00:00