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
This commit is contained in:
+110
@@ -0,0 +1,110 @@
|
||||
# Bookshelf — session handoff
|
||||
Written 2026-09-06 by the Opus orchestrator, after a sprite restart forced a fresh session.
|
||||
|
||||
## Read these first, in order
|
||||
1. `docs/SPEC.md` — the authoritative product/technical contract. Unchanged and still correct.
|
||||
Every worker prompt must point at it. Do not restate it; do not let it drift.
|
||||
2. This file — operational state, what's done, what bit us, what's next.
|
||||
|
||||
## Operating model (the user explicitly asked for this — keep it)
|
||||
The user is on the **$20/mo Pro plan** and wants Opus used sparingly.
|
||||
- **Opus = orchestrator only.** Write specs, launch workers, verify results, decide.
|
||||
Do NOT write app code yourself. Do NOT read large files into Opus context.
|
||||
- **Sonnet = all implementation**, via `claude -p` (NOT the Agent tool — the user asked
|
||||
for `claude -p` specifically, and it keeps worker output out of the orchestrator's context).
|
||||
- Read worker output via `logs/<name>.summary` / `jq -r '.result'`, never by cat-ing source.
|
||||
- Quota status: user reported **62% of the 5h window consumed** at ~19:45 on 09-05.
|
||||
Worker A alone cost **$2.40 / 85 turns**. Budget accordingly; prefer resuming a
|
||||
session over restarting one.
|
||||
|
||||
### How to launch a worker
|
||||
```
|
||||
cd ~/bookshelf && nohup ./tasks/run-task.sh <NAME> ./tasks/<NAME>.txt >/dev/null 2>&1 &
|
||||
```
|
||||
`tasks/run-task.sh` is quota-aware: on a usage-limit error it sleeps `POLL` (600s) and
|
||||
resumes the SAME session rather than restarting, up to `MAX_WALL` (24h), and does not
|
||||
count quota waits against its 3-strike hard-failure budget. It writes:
|
||||
`logs/<name>.json` (final result), `.err`, `.sid` (session id), `.state` (progress), `.summary`.
|
||||
|
||||
**HAZARD — do not repeat:** never edit `run-task.sh` while workers are running. Bash reads
|
||||
scripts by byte offset; swapping the file mid-run makes live workers resume inside unrelated
|
||||
code and can spawn duplicate `claude` processes that burn quota on finished work. If you must
|
||||
change it, write a NEW file and use that for the next wave.
|
||||
|
||||
## Environment
|
||||
- JDK 21: `~/toolchain/jdk21` (system java is 25 — too new for AGP, do not use it)
|
||||
- Android SDK: `~/toolchain/android-sdk` (platforms;android-37.0, build-tools;37.0.0, platform-tools)
|
||||
- **No KVM, no emulator.** Verify only via `./gradlew assembleDebug`, JVM unit tests, and
|
||||
Paparazzi PNG rendering. Never claim the app was "run".
|
||||
- PocketBase v0.40.2 service, **127.0.0.1:8090, deliberately NOT internet-exposed**
|
||||
(no `--http-port`, so the sprite proxy can't reach it). Restart:
|
||||
`sprite-env services restart pocketbase`. Logs: `/.sprite/logs/services/pocketbase.log`.
|
||||
- Superuser creds: `server/.dev-credentials` (gitignored).
|
||||
|
||||
## STATE: what is DONE
|
||||
### Wave 1A — server: COMPLETE and verified by the orchestrator (not just self-reported)
|
||||
`server/` contains `setup-schema.sh` (idempotent), `create-user.sh`, `pb_hooks/main.pb.js`,
|
||||
`pb_migrations/`, `deploy/` (systemd unit, Dockerfile, compose, backup.sh, README covering
|
||||
Tailscale vs port-forward+Caddy), `README.md`, `.gitignore`.
|
||||
|
||||
Independently re-verified on 09-06 after fixing the service:
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| anonymous LIST books/shelves/bookcases | **403 / 403 / 403** |
|
||||
| anonymous self-registration | **403** |
|
||||
| `/api/health` | 200 |
|
||||
|
||||
**`pb_hooks/main.pb.js` is INTENTIONAL, not scope drift.** PocketBase's `listRule` is a row
|
||||
filter, so anonymous LIST would otherwise return `200 []` instead of an error. The hook forces
|
||||
403. Keep it; it is why the table above passes. It is auto-loaded by the stock binary.
|
||||
|
||||
## STATE: what is NOT done
|
||||
### Wave 1B — Android scaffold + design system: INCOMPLETE (killed mid-run by the restart)
|
||||
Present: gradle wrapper, `gradle/libs.versions.toml`, `app/build.gradle.kts`,
|
||||
`AndroidManifest.xml`, `proguard-rules.pro`, Literata OFL license.
|
||||
Missing/unverified: ui/theme (Color/Type/Theme), the shared component set, MainActivity,
|
||||
Paparazzi setup, and **any evidence the build compiles**.
|
||||
|
||||
**Its session SURVIVED and is resumable — prefer this over a restart (saves quota):**
|
||||
`claude -p --model sonnet --permission-mode bypassPermissions --output-format json \`
|
||||
` --add-dir ~/bookshelf --resume 6823e72a-69c1-486e-ae5a-18abab84529b`
|
||||
with a "continue where you left off, don't restart" prompt. (Worker A's session, for
|
||||
reference, is `5e3bd183-252c-4224-99b5-91779761ccbc`.)
|
||||
First thing the resumed worker must do: get `./gradlew assembleDebug` GREEN. Everything
|
||||
downstream is blocked on it.
|
||||
|
||||
### Waves 2-4 — not started. Prompts not yet written.
|
||||
- **Wave 2 (parallel, after 1B is green):**
|
||||
- C — data layer: Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor,
|
||||
`SyncEngine` (push-then-pull, LWW, tombstones, client-generated 15-char ids), SettingsStore.
|
||||
- D — metadata + scanning: Open Library + Google Books merge, ISBN-13 checksum validation,
|
||||
CameraX + ML Kit continuous scanning.
|
||||
- **Wave 3 (after C+D):** E — the six screens (setup, library, detail, scan, locations, settings).
|
||||
- **Wave 4:** F — Paparazzi screenshots for the user to judge the look, release keystore +
|
||||
signed APK, top-level README, end-to-end sync test against the live PocketBase.
|
||||
|
||||
## Gotchas already paid for — do not rediscover
|
||||
1. **Migration filename ↔ `_migrations` desync.** Worker A renamed `1788636563_created_books.js`
|
||||
to `...564...` to fix an alphabetical-replay ordering bug (`books` sorted before `shelves`,
|
||||
breaking the relation). Correct for fresh instances, but the dev DB still had the old name
|
||||
recorded applied, so PocketBase tried to re-create `books` and crash-looped 9 times.
|
||||
Fixed via `UPDATE _migrations SET file=...`. **If you ever rename a migration, update that
|
||||
table too.** DB backup: scratchpad `data.db.bak`.
|
||||
2. Rule semantics: in PocketBase `""` means PUBLIC, `null` means superuser-only. Confusing these
|
||||
is exactly how the library would end up world-readable.
|
||||
3. `claude -p --output-format json` writes its log only at exit; a 0-byte `.json` means the
|
||||
worker is still running or was killed, not that it failed.
|
||||
4. System JDK is 25 and will break AGP. Workers must export `JAVA_HOME=~/toolchain/jdk21`
|
||||
(run-task.sh already does).
|
||||
|
||||
## Verification standard (hold workers to this)
|
||||
Workers self-report optimistically. Before accepting any wave:
|
||||
- Re-run the security curls above yourself. The user's stated requirement is that this not be
|
||||
"accessible to everyone in the world"; that check is non-negotiable and cheap.
|
||||
- Require `assembleDebug` + `test` exit 0, and confirm artifacts exist on disk.
|
||||
- Treat "I couldn't get Paparazzi working so I skipped screenshots" as a finding to report to
|
||||
the user, not something to paper over — the user explicitly cares how this looks.
|
||||
|
||||
## Open questions for the user (not yet asked — deferred, not forgotten)
|
||||
- Where the server will actually live (home box vs a sprite) — only affects the deploy README.
|
||||
- Their two account emails, for `create-user.sh`. Not needed until the app can log in.
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Bookshelf — authoritative spec
|
||||
|
||||
Two-person shared home library. Android app + self-hosted PocketBase.
|
||||
ALL workers must follow this exactly. Do not invent alternative names.
|
||||
|
||||
## Non-negotiables
|
||||
- Offline-first. Home server is often unreachable (residential NAT). Every read
|
||||
comes from Room. Every write lands in Room first, syncs later. No screen may
|
||||
block on network.
|
||||
- Private. No public registration. Auth required for all data access.
|
||||
- Server URL is NOT hardcoded; user enters it on first run.
|
||||
|
||||
## Repo layout
|
||||
~/bookshelf/
|
||||
server/ PocketBase binary(gitignored), pb_migrations/, setup-schema.sh, deploy/
|
||||
app/ Android Gradle project
|
||||
docs/ this spec
|
||||
|
||||
## Android
|
||||
- applicationId/namespace: org.modg.bookshelf
|
||||
- minSdk 26, compileSdk 37, targetSdk 37, JDK 21, Kotlin, Jetpack Compose, Material 3
|
||||
- SDK at ~/toolchain/android-sdk ; JDK at ~/toolchain/jdk21
|
||||
- DI: manual `AppContainer` held by Application. NO Hilt/kapt. Room uses KSP.
|
||||
- Libs: Compose BOM, room(+ksp), retrofit2 + kotlinx-serialization converter,
|
||||
okhttp logging, coil3 compose, camerax(core/camera2/lifecycle/view),
|
||||
com.google.mlkit:barcode-scanning, androidx.work runtime-ktx, datastore-preferences,
|
||||
navigation-compose, lifecycle-viewmodel-compose, accompanist-permissions (or manual)
|
||||
|
||||
## Package structure (org.modg.bookshelf.*)
|
||||
data.local Room: entities, daos, BookshelfDatabase, Converters
|
||||
data.remote PocketBaseApi (retrofit), dtos, PbAuthInterceptor
|
||||
data.metadata OpenLibrary + GoogleBooks lookup
|
||||
data.repo BookRepository, LocationRepository, SyncEngine, AuthRepository
|
||||
data.prefs SettingsStore (DataStore)
|
||||
ui.theme Color/Type/Theme
|
||||
ui.library, ui.detail, ui.scan, ui.locations, ui.settings, ui.setup
|
||||
ui.nav BookshelfNavHost
|
||||
|
||||
## Data model — Room mirrors PocketBase 1:1
|
||||
IDs: 15-char lowercase alnum, GENERATED CLIENT-SIDE for new records
|
||||
(PocketBase accepts client-supplied ids on create). Never remap ids after push.
|
||||
|
||||
BookEntity(id PK, title, subtitle, authorsJson, isbn13, isbn10, publisher,
|
||||
publishedDate, pageCount:Int?, description, coverUrl, coverSourceUrl,
|
||||
shelfId:String?, notes, addedBy, deleted:Boolean, createdAt:Long, updatedAt:Long,
|
||||
syncState:SyncState, localCoverPath:String?)
|
||||
BookcaseEntity(id PK, name, note, position:Int, deleted, createdAt, updatedAt, syncState)
|
||||
ShelfEntity(id PK, bookcaseId, label, position:Int, deleted, createdAt, updatedAt, syncState)
|
||||
|
||||
enum SyncState { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }
|
||||
|
||||
All queries filter `deleted = 0`. Deletion is ALWAYS soft (tombstone) so sync can
|
||||
propagate it and nothing is silently lost from a shared library.
|
||||
|
||||
## PocketBase schema (collections)
|
||||
bookcases: name(text,req), note(text), position(number), deleted(bool)
|
||||
shelves: bookcase(relation->bookcases,req,maxSelect 1), label(text,req),
|
||||
position(number), deleted(bool)
|
||||
books: title(text,req), subtitle(text), authors(json), isbn13(text), isbn10(text),
|
||||
publisher(text), published_date(text), page_count(number), description(text),
|
||||
cover(file,maxSelect 1,image mimes,thumbs 100x150+300x450),
|
||||
cover_source_url(text), shelf(relation->shelves,maxSelect 1),
|
||||
notes(text), added_by(relation->users,maxSelect 1), deleted(bool)
|
||||
All three get autodate created/updated.
|
||||
Indexes: books(isbn13), books(updated), shelves(updated), bookcases(updated).
|
||||
|
||||
API rules — all of list/view/create/update/delete on the three collections:
|
||||
"@request.auth.id != \"\""
|
||||
users collection: createRule = null (SUPERUSER ONLY — this is what keeps the
|
||||
world out), listRule/viewRule = "@request.auth.id != \"\"",
|
||||
updateRule = "id = @request.auth.id", deleteRule = null.
|
||||
NOTE: rule "" means PUBLIC in PocketBase; null means superuser-only. Do not confuse.
|
||||
|
||||
## Sync design (SyncEngine)
|
||||
Pull: GET /api/collections/{c}/records?filter=(updated>'{cursor}')&sort=updated
|
||||
&perPage=200&page=N — paginate to exhaustion. Cursor per collection in
|
||||
DataStore, stored as PB UTC string. Include tombstones.
|
||||
Push: records where syncState != SYNCED. PENDING_CREATE -> POST (with our id),
|
||||
PENDING_UPDATE -> PATCH, PENDING_DELETE -> PATCH {deleted:true}.
|
||||
On 404 for update/delete: drop local record. On 409/duplicate id: switch to PATCH.
|
||||
Order: push THEN pull (so our writes come back canonical).
|
||||
Conflict: last-write-wins on `updated`. Document this in README; do not build
|
||||
anything cleverer.
|
||||
Covers: on save, app downloads cover from metadata source and multipart-uploads it
|
||||
to the book's `cover` file field, so covers survive upstream URL rot. If offline,
|
||||
store localCoverPath and upload on next sync.
|
||||
Trigger: app start, manual pull-to-refresh, WorkManager periodic (~6h, network-constrained).
|
||||
Never let sync failure surface as a crash or a blocking dialog — a quiet status line only.
|
||||
|
||||
## Book metadata lookup
|
||||
Primary Open Library: https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data
|
||||
Fallback Google Books: https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn} (no key)
|
||||
Cover: https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg else GB imageLinks (force https, zoom=2)
|
||||
Merge: prefer whichever has a title; fill blanks from the other. Return null if both miss,
|
||||
and the UI must then offer manual entry pre-filled with the scanned ISBN.
|
||||
|
||||
## Barcode scanning
|
||||
CameraX Preview + ImageAnalysis -> ML Kit BarcodeScanning (EAN_13, EAN_8, UPC_A).
|
||||
Validate ISBN-13 checksum before lookup; ignore non-book barcodes. Debounce repeats.
|
||||
Continuous mode: after a save, stay on camera for the next book (shelving a box of
|
||||
books is the real use case). Show a running "added this session" count.
|
||||
Handle: camera permission denial, torch toggle, and a manual-ISBN-entry escape hatch.
|
||||
|
||||
## Design language — "feels like books"
|
||||
Warm paper, dark mahogany, gold + silver metallics. Restrained, not skeuomorphic.
|
||||
Light: paper #F5EDE0, paperAlt #EDE3D2, ink #2B211A, inkSoft #5A4A3D,
|
||||
mahogany #5C2E23, mahoganyDeep #3E1E17, gold #C0932F, goldSoft #D9B45B,
|
||||
silver #9CA3AF, silverSoft #C7CCD1
|
||||
Dark: ground #1C1411, surface #241A15, paperText #E8DCC8, mahogany #7A3E2F,
|
||||
gold #D9B45B, silver #C7CCD1
|
||||
Type: serif display (Literata, OFL, bundle the TTF) for titles/headers;
|
||||
system sans for body/UI. Generous line-height.
|
||||
Motifs: subtle spine/edge treatments, thin gold hairline rules, gentle paper-grain
|
||||
on large surfaces. Covers are the hero — let them carry the color.
|
||||
Both light and dark themes required. Dynamic color OFF (it would fight the palette).
|
||||
|
||||
## Screens
|
||||
setup First run: server URL (+ https scheme validation, trailing-slash strip,
|
||||
reachability probe), email, password. Clear errors for wrong URL vs bad creds.
|
||||
library Cover grid (2-3 col adaptive). Search title/author/ISBN. Filter by
|
||||
bookcase/shelf. Sort title/author/added. Empty state invites first scan.
|
||||
FAB -> scan. Sync status line.
|
||||
detail Big cover, title/subtitle/authors/publisher/year/pages/ISBN, description
|
||||
(collapsible), notes (editable), location picker, edit, soft-delete w/ undo.
|
||||
scan Camera + reticle; on hit -> bottom sheet w/ fetched book + shelf picker +
|
||||
Save / Skip. Duplicate-ISBN warning if already owned.
|
||||
locations Bookcases -> shelves tree. CRUD + reorder. Book counts per shelf.
|
||||
Tap a shelf -> library filtered to it. "Move books" bulk action.
|
||||
settings Server, account, sign out, manual sync + last-sync time, book/cover counts.
|
||||
|
||||
## Quality bar
|
||||
- No emulator on this box (no KVM). Verify via: `./gradlew assembleDebug`,
|
||||
JVM unit tests, and Paparazzi screenshot rendering.
|
||||
- Unit-test the real logic: ISBN checksum, metadata merge, sync conflict resolution,
|
||||
DAO queries (Robolectric). Do not write assertion-free tests.
|
||||
- App must compile and run with NO server configured (setup screen) and must not
|
||||
crash when the server is unreachable.
|
||||
Reference in New Issue
Block a user