From 560c27c04d1ab1032cde5f8b8d5bff6dd40c1cb8 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:06:58 +0100 Subject: [PATCH] chore: bootstrap agent-ready docs, rules and Bun workspace Adds the approved v1 design spec, UI mockups, AGENTS.md (with CLAUDE.md and GEMINI.md pointers), architecture/decisions/todo/permissions docs, agent permission files, and an empty Bun workspaces skeleton. Co-Authored-By: Claude Opus 5.5 --- .claude/settings.json | 23 ++ .codex/config.toml | 2 + .codex/rules/project.rules | 19 + .gemini/settings.json | 18 + .gitignore | 25 ++ AGENTS.md | 99 +++++ CLAUDE.md | 9 + DESIGN.md | 45 +++ GEMINI.md | 7 + PRODUCT.md | 23 ++ README.md | 6 + bun.lock | 26 ++ docs/ARCHITECTURE.md | 44 +++ docs/DECISIONS.md | 48 +++ docs/PERMISSIONS.md | 39 ++ docs/TODO.md | 26 ++ .../mockups/round1-seven-directions.html | 293 ++++++++++++++ .../mockups/round2-app-scrapbook-river.html | 215 ++++++++++ docs/plans/README.md | 11 + docs/specs/001-sttracker-v1-design.md | 368 ++++++++++++++++++ docs/specs/README.md | 10 + package.json | 14 + tsconfig.json | 22 ++ 23 files changed, 1392 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .codex/config.toml create mode 100644 .codex/rules/project.rules create mode 100644 .gemini/settings.json create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 DESIGN.md create mode 100644 GEMINI.md create mode 100644 PRODUCT.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DECISIONS.md create mode 100644 docs/PERMISSIONS.md create mode 100644 docs/TODO.md create mode 100644 docs/design/mockups/round1-seven-directions.html create mode 100644 docs/design/mockups/round2-app-scrapbook-river.html create mode 100644 docs/plans/README.md create mode 100644 docs/specs/001-sttracker-v1-design.md create mode 100644 docs/specs/README.md create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7553e5c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,23 @@ +{ + "permissions": { + "allow": [ + "Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", + "Bash(git branch --list:*)", "Bash(git rev-parse:*)", "Bash(git add:*)", "Bash(git commit:*)", + "Bash(bun install --frozen-lockfile:*)", "Bash(bun test:*)" + ], + "ask": [ + "Bash(git push:*)", "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git switch:*)", + "Bash(git restore:*)", "Bash(git commit --amend:*)", "Bash(rm:*)", + "Bash(bun add:*)", "Bash(bun remove:*)", "Bash(bun update:*)", "Bash(bun upgrade:*)", + "Bash(bunx:*)", "Bash(npx:*)", "Bash(docker:*)" + ], + "deny": [ + "Bash(git push --force:*)", "Bash(git push -f:*)", "Bash(git push --force-with-lease:*)", + "Bash(git reset --hard:*)", "Bash(git clean:*)", "Bash(git add -f:*)", "Bash(git add --force:*)", + "Bash(git commit --no-verify:*)", "Bash(git commit -n:*)", + "Bash(rm -r:*)", "Bash(rm -R:*)", "Bash(rm -rf:*)", "Bash(rm -fr:*)", "Bash(rm -Rf:*)", + "Bash(rm -fR:*)", "Bash(rm --recursive:*)", "Bash(sudo:*)", + "Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)" + ] + } +} diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..5542eff --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +sandbox_mode = "workspace-write" +approval_policy = "on-request" diff --git a/.codex/rules/project.rules b/.codex/rules/project.rules new file mode 100644 index 0000000..5f3bdd2 --- /dev/null +++ b/.codex/rules/project.rules @@ -0,0 +1,19 @@ +# Deny +prefix_rule(pattern = ["git", "push", ["--force", "-f", "--force-with-lease"]], decision = "forbidden", + justification = "Force-push rewrites shared history.") +prefix_rule(pattern = ["git", "reset", "--hard"], decision = "forbidden", justification = "Discards work.") +prefix_rule(pattern = ["git", "clean"], decision = "forbidden", justification = "Deletes untracked files.") +prefix_rule(pattern = ["git", "add", ["-f", "--force"]], decision = "forbidden", + justification = "Stages ignored files, e.g. secrets.") +prefix_rule(pattern = ["git", "commit", ["--no-verify", "-n"]], decision = "forbidden", justification = "Skips hooks.") +prefix_rule(pattern = ["rm", ["-r", "-R", "-rf", "-fr", "-Rf", "-fR", "--recursive"]], decision = "forbidden", + justification = "Recursive delete.") +prefix_rule(pattern = ["sudo"], decision = "forbidden", justification = "No privilege escalation.") +# Ask +prefix_rule(pattern = ["git", ["push", "merge", "rebase", "switch", "restore"]], decision = "prompt") +prefix_rule(pattern = ["git", "commit", "--amend"], decision = "prompt") +prefix_rule(pattern = ["rm"], decision = "prompt") +prefix_rule(pattern = ["bun", ["add", "remove", "update", "upgrade"]], decision = "prompt") +prefix_rule(pattern = ["bunx"], decision = "prompt") +prefix_rule(pattern = ["npx"], decision = "prompt") +prefix_rule(pattern = ["docker"], decision = "prompt") diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..5df314b --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,18 @@ +{ + "tools": { + "allowed": [ + "run_shell_command(git status)", "run_shell_command(git diff)", "run_shell_command(git log)", + "run_shell_command(git show)", "run_shell_command(git branch --list)", + "run_shell_command(git add)", "run_shell_command(git commit)", + "run_shell_command(bun install --frozen-lockfile)", "run_shell_command(bun test)" + ], + "exclude": [ + "run_shell_command(git push --force)", "run_shell_command(git push -f)", + "run_shell_command(git push --force-with-lease)", "run_shell_command(git reset --hard)", + "run_shell_command(git clean)", "run_shell_command(git add -f)", "run_shell_command(git add --force)", + "run_shell_command(git commit --no-verify)", "run_shell_command(git commit -n)", + "run_shell_command(rm -r)", "run_shell_command(rm -R)", "run_shell_command(rm -rf)", + "run_shell_command(rm -fr)", "run_shell_command(rm -Rf)", "run_shell_command(sudo)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9dcf521 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +node_modules/ +dist/ +*.tsbuildinfo +coverage/ + +# local data (SQLite db, attachments, backups, snapshots) +data/ +*.db +*.db-shm +*.db-wal + +# secrets +.env +.env.* +!.env.example + +# agent scratch +CLAUDE.local.md +.superpowers/ +.worktrees/ +.claude/settings.local.json + +.DS_Store +.idea/ +.vscode/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ec53041 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,99 @@ +# STTracker + +Self-hosted meta media tracker: the user's own permanent record of books, games, film/TV, manga, podcasts +and music, pulled from Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm and more. Bun 1.3, TypeScript 5, +React, Kysely over SQLite (`bun:sqlite`) or Postgres (`Bun.sql`). Bun workspaces. + +`AGENTS.md` is the single source of truth for agents here. `CLAUDE.md` and `GEMINI.md` only point here. +Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TODO.md`. +Product spec: `docs/specs/001-sttracker-v1-design.md`. Read it before any feature work. + +## Commands + +Install: `bun install --frozen-lockfile` +Test: `bun test` | Single test: TODO(maintainer): confirm `bun test ` once tests exist +Typecheck: TODO(maintainer): confirm `bun run typecheck` (fails until source files exist) +Lint: TODO(maintainer): confirm once a linter is chosen +Dev / Build: TODO(maintainer): confirm once `apps/server` exists + +## Workflow + +Required skills: `superpowers`, `impeccable`. Check at session start; install if missing. +Specs → `docs/specs/NNN-slug.md` (permanent). Plans → `docs/plans/NNN-slug.md` while in progress; +`git rm` them before opening a merge request. The spec is the record. +(Overrides the superpowers default paths.) + +Anything beyond a trivial fix: brainstorm → spec (sign-off) → plan (sign-off) → TDD → review. +TDD means failing test first, watch it fail, minimal code, watch it pass, commit. +Tick plan checkboxes as you go so an interrupted session is resumable. +Trivial changes (typo, version bump, one-line config): say "trivial, skipping spec" and proceed. + +Use impeccable for user-facing UI: `craft` for new UI, `audit`/`polish` on existing UI, +`harden` before shipping. Design context: `PRODUCT.md`, `DESIGN.md`, `docs/design/mockups/`. + +## Rules + +- Bun-native only: + - `Bun.serve()` with HTML imports for the web app. No Express, no Vite. + - `bun:sqlite` and `Bun.sql`. No `better-sqlite3`, `pg` or `postgres.js`. + - `bun test`. No Jest or Vitest. + - `Bun.password`, `Bun.file`. + - Bun loads `.env` itself. No dotenv. +- Domain code queries through Kysely from `packages/db`. Dialect-specific SQL lives only in `packages/db`. + Every DB-touching test must pass on both SQLite and Postgres. +- Syncs never overwrite or delete user data directly. Source records go through the change engine. + It applies a value only if `field_sources` says the user never edited the field; anything else + becomes a Changes-inbox event. Deletes are soft (`deleted_at`). +- Plugins (`plugins/*`) import only `@sttracker/plugin-sdk`. They never import `packages/db` or core + internals, and they return `SourceRecord`s instead of writing. +- `packages/*` and `plugins/*` never import from `apps/*`. +- IDs are ULIDs generated in app code. Ratings are integers 0–100. Dates carry a precision + (`day | month | year | unknown`). +- Tests never call live APIs. Plugin tests use recorded fixtures through the SDK test harness. +- Write-back (`push`) is opt-in per connection and off by default. No plugin pushes in Phase 1. + +## Architecture + +apps/server -> Bun.serve: REST API `/api/v1`, `/ingest/`, job worker, serves the web app +apps/web -> React app via HTML imports +packages/core -> domain services: entries, matching, change engine, history, export +packages/db -> Kysely setup, SQLite/Postgres dialects, migrations +packages/plugin-sdk -> plugin contract, SourceRecord types, http/rate-limit helpers, test harness +plugins/ -> one source integration each + +Full picture: `docs/ARCHITECTURE.md`. Read it before your first change in a session. + +## Boundaries + +Never touch: `docs/design/mockups/` (reference only), `node_modules/`, `data/`. +Never commit `.env`, secrets, API keys, or recorded fixtures containing real tokens. +Never force-push, rewrite pushed history, or commit directly to `main`. + +Ask first: new runtime dependencies · public API, export schema (`sttracker-export/v1`) or +migration changes after release · auth, token and credential-encryption code · anything destructive +(`rm -rf`, `git reset --hard`, DB resets) · work the approved plan does not cover. + +If the task turns out much larger than the plan assumed: stop, report, re-plan. + +## Docs duties + +Same commit as the code, not "later": +- `docs/ARCHITECTURE.md`: when a module, boundary, or data flow changes. +- `docs/DECISIONS.md`: append-only; any choice a future maintainer would reverse-engineer. +- `docs/TODO.md`: anything noticed and deliberately not fixed. No `TODO` comments in source. +- `docs/PERMISSIONS.md`: together with `.claude/settings.json`, `.codex/` and `.gemini/settings.json`, in one commit. + +## Git + +Branches: `feat|fix|chore/`. Commits: Conventional Commits. Keep them small, each leaving tests green. + +## Done means verified + +Do not report success on work you have not run. Before saying done, all of these must hold: +- tests run *in this session* and seen passing, on both databases for DB code; +- typecheck clean; +- matches the approved spec; +- docs duties complete; +- `git status` clean and the diff is only what you intended. + +"It should work" is not verification. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8e99cf7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# CLAUDE.md + +**`AGENTS.md` is the single source of truth for this repository.** Read it now and follow it exactly. + +@AGENTS.md + +This file exists only so Claude Code finds the rules, and for Claude-specific configuration that +AGENTS.md cannot express. It contains no project rules of its own. Add those to `AGENTS.md`. +If anything here ever conflicts with `AGENTS.md`, `AGENTS.md` wins. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..ad90c82 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,45 @@ +# Design + +Reference mockups: `docs/design/mockups/round2-app-scrapbook-river.html`. These were approved by the owner on 2026-10-30. +Parked theme ideas (shelf, card catalogue): `round1-seven-directions.html`. + +## App (everyday use): "Hardcover-style" +Dark, calm, dense. Three panes on desktop (nav · library list · entry detail); stacks on mobile. + +| Token | Value | Use | +|---|---|---| +| `--surface-0` | `#17152b` | sidebar | +| `--surface-1` | `#1e1b36` | page | +| `--surface-2` | `#262247` | boxes in detail | +| `--surface-3` | `#2d2852` | selected row, pills, tabs | +| `--text` | `#ece9ff` | primary text | +| `--text-muted` | `#a9a3d6` | meta text | +| `--text-faint` | `#8d87c2` | box headings, placeholders | +| `--accent` | `#6d5dfc` | active tab, in-progress status | +| `--rating` | `#fbbf24` | stars, logo, draft badge | +| `--warn` | `#f97316` | source-change warnings and the Changes count only | +| `--ok` | `#34d399` | source synced | + +- Type: Inter for the UI; Gelasio for long-form review text. +- Radii: 8px for controls, 10px for boxes, 999px for pills, 3–5px for covers. +- Orange means "a platform changed something". Never use it decoratively. +- Drafts (entries with no catalogue link) get a small amber outlined "Draft" badge and a striped placeholder cover. + +## Media type colours (shared by both surfaces) +| Type | Colour | +|---|---| +| Books | `#6b3fa0` | +| Audio/podcasts | `#2f6b58` | +| Games | `#c1652f` | +| Film & TV | `#344b7a` | +| Music | `#a33b62` | + +## Public page: "scrapbook + river" (Phase 2) +- **Scrapbook:** + - Kraft background `#d8c3a0`. Polaroid cards `#fffdf7` with tape, captions in Caveat, body in Gelasio. + - Note cards `#fffbe6`, ticket cards `#efe0bd` with a dashed border. + - A grid underneath, with slight rotations of ±2° at most. Must work on phones. +- **River:** + - Navy `#142033`. Instrument Serif headings, Instrument Sans body. + - Streams per media type in soft tones: books `#b9a6f2`, audio `#ffd27a`, games `#f2a1a1`, film/TV `#9ad0c2`. Stream width is time spent. + - Covers sit on streams. Threads are dotted white curves with an italic serif label. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..d8175e0 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,7 @@ +# GEMINI.md + +**`AGENTS.md` is the single source of truth for this repository.** Read `./AGENTS.md` before doing +anything else and follow it exactly. + +This file exists only so Gemini CLI finds the rules. It contains no project rules of its own. Add +those to `AGENTS.md`. If anything here ever conflicts with `AGENTS.md`, `AGENTS.md` wins. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..baf3dac --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,23 @@ +# Product + +**STTracker** is a self-hosted meta tracker for everything a person reads, watches, plays and listens to. +It is the user's own permanent record. Platforms (Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm…) are sources it watches, not the source of truth. +Full spec: `docs/specs/001-sttracker-v1-design.md`. + +## Who it's for +1. The owner, self-hosting for themselves: currently juggling several trackers, spreadsheets and Obsidian notes. +2. A few friends on the same instance. +3. Maybe, later, a small public instance. + +## What it must feel like +- **The user is in control.** They never conform to the data. A title alone is a valid entry. No sync silently overwrites what they wrote. +- **One interface for all media.** Books, games, shows and music use the same features; a media type is just a label. +- **About stories and things you love,** not an admin stats dashboard. + +## Two surfaces +- **The app:** everyday library, logging, sources, and a Changes inbox. Calm, dense, Hardcover-like. +- **The public page:** a shareable, personal page with a scrapbook layout and a river timeline view. Phase 2. + +## Voice +Plain, warm, direct. Sentence case. Name things by what users understand: "Changes", "Sources", "Owned", "Quick add", "Draft". Never "records", "connectors" or "webhooks" in user-facing copy. +Errors say what happened and how to fix it, without apologising. diff --git a/README.md b/README.md new file mode 100644 index 0000000..981d88c --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# STTracker + +A self-hosted meta tracker for everything you read, watch, play and listen to. It keeps your own permanent copy of your data from Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm and more. + +- Product spec: [`docs/specs/001-sttracker-v1-design.md`](docs/specs/001-sttracker-v1-design.md) +- Contributing (humans and agents): [`AGENTS.md`](AGENTS.md) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..61e4e46 --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "sttracker", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], + + "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], + + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..71502e3 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,44 @@ +# Architecture + +How STTracker is built today. Update in the same commit as any structural change. + +## Overview +Status 2026-10-30: only the workspace skeleton exists (root `package.json`, `tsconfig.json`). +The target design is in `docs/specs/001-sttracker-v1-design.md` §11. Replace each "planned" marker below as the code lands. + +One Bun process serves the REST API, the React web app and plugin ingest endpoints, and runs a +database-backed job worker. Source plugins pull data from platforms and return normalised `SourceRecord`s. +The core snapshots them, diffs against the previous run, matches them to works, and either applies them to the +user layer or files a Changes-inbox event. + +## Map +| Path | Responsibility | State | +|---|---|---| +| `apps/server` | `Bun.serve`: `/api/v1`, `/ingest/`, job worker, static web | planned | +| `apps/web` | React app (HTML imports) | planned | +| `packages/core` | domain services: entries, matching, change engine, history, export | planned | +| `packages/db` | Kysely, SQLite/Postgres dialects, migrations | planned | +| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned | +| `plugins/` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned | +| `docs/design/mockups` | approved UI mockups (reference only) | exists | + +## Data flow +Planned sync lifecycle: +1. The scheduler enqueues a job. +2. The worker loads the plugin and calls `pull` or `pullChanges` with an SDK context. +3. The plugin returns `SourceRecord[]`. +4. The core stores a new `source_record_versions` row for each changed hash and diffs the records into `change_events`. +5. Each event is either auto-applied (new record, or a field never edited by the user) or sent to the inbox. +6. Every user-layer write appends to `user_history`. + +## External dependencies +- Database: SQLite file under `DATA_DIR` (default) or Postgres via `DATABASE_URL`. If unavailable, nothing works. +- Platform APIs (Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm). If unavailable, only that source's sync fails; the run is logged and retried. + +## Invariants +- A sync never overwrites or hard-deletes user-entered data (spec §8.3). +- Plugins never touch the database. +- All domain behaviour is identical on SQLite and Postgres. + +## Known rough edges +None yet. See `docs/TODO.md`. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..12a102a --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,48 @@ +# Decisions + +Append-only. Never edit or delete an entry. Supersede it with a new one that links back. +One entry per decision a future maintainer would otherwise have to reverse-engineer. +No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number). + +## 2026-10-30: Product design decisions recorded in spec 001 +**Status:** accepted +**Context:** Brainstorming with the owner produced the v1 design. +**Decision:** See `docs/specs/001-sttracker-v1-design.md` §13 for the designer's calls. Owner decisions: +- Phase 1 is read-only. +- Write-back is opt-in per connection. +- Conflicts between sources go to the Changes inbox. +- Backloggd waits for its official export. +- The app UI is Hardcover-style; the public page is scrapbook + river. +**Alternatives:** Recorded in the spec. +**Consequences:** Phase 1 has no `push` implementations. + +## 2026-10-30: Bun workspaces monorepo with Kysely for both databases +**Status:** accepted +**Context:** The owner requires Bun with SQLite and Postgres options. +**Decision:** +- Bun workspaces (`apps/*`, `packages/*`, `plugins/*`). +- Kysely is the single query layer, with portable migrations. +- SQLite runs through `bun:sqlite` and Postgres through `Bun.sql`. + +**Alternatives:** +- Drizzle needs a separate schema per dialect. +- Prisma does not fit Bun-native drivers. +- Raw SQL would duplicate every query per dialect. + +**Consequences:** We may need a small custom Kysely dialect for `Bun.sql` or `bun:sqlite`. Every DB test runs on both databases. + +## 2026-10-30: Adopt AGENTS.md as single source of truth +**Status:** accepted +**Context:** Agents had no durable project context and no consistent workflow between sessions. +**Decision:** +- `AGENTS.md` is canonical; `CLAUDE.md` and `GEMINI.md` are pointers. +- Specs are permanent in `docs/specs/`. +- Plans live in `docs/plans/` only while work is in progress and are removed before the merge request. +- Both locations override the superpowers default paths. + +**Alternatives:** +- Per-tool rule files: they drift and create synchronisation debt. +- No rules file: agents re-explore the repo every session, which is measurably slower and costs more tokens. + +**Consequences:** Rule changes happen in one file, and the pointer files must stay thin. Plans are working +artefacts: kept after merge, they go stale and contradict the code, so the spec is the record. diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md new file mode 100644 index 0000000..be4d69b --- /dev/null +++ b/docs/PERMISSIONS.md @@ -0,0 +1,39 @@ +# Agent permissions + +Source of truth for what AI coding agents may run here. The tool files are renderings of this page: +`.claude/settings.json`, `.codex/config.toml`, `.codex/rules/project.rules`, `.gemini/settings.json`. +Change this page and every tool file in the same commit. + +## Verified commands + +Run on 2026-10-30. Only `worked` rows may appear in a tier, a tool file, or `AGENTS.md` Commands. + +| Command | Result | +|---|---| +| `bun install --frozen-lockfile` | worked | +| `bun test` | worked (runner starts; reports "0 test files" until tests exist) | +| `bun run typecheck` | failed: TS18003, no source files yet | +| `bunx tsc --noEmit -p tsconfig.json` | failed: same as above | + +TODO(maintainer): confirm every row above that is not `worked` once Phase 1 code lands. + +## Tiers + +| Tier | Commands | Why | +|---|---|---| +| Allow | `git status/diff/log/show/branch --list/rev-parse/add/commit`, `bun install --frozen-lockfile`, `bun test` | Inspection and the verified dev loop; no prompt needed. | +| Ask | `git push/merge/rebase/switch/restore`, `git commit --amend`, `rm`, `bun add/remove/update/upgrade`, `bunx`, `npx`, `docker` | Leaves the machine, rewrites work, changes dependencies, or downloads and runs code. A human decides. | +| Deny | force pushes, `git reset --hard`, `git clean`, `git add -f`, `git commit --no-verify/-n`, recursive `rm`, `sudo`; reading `.env`, `.env.*`, `secrets/**` | Destroys work, skips checks, or exposes secrets. | + +## Known gaps +- Allowlists save prompts; they are not a security boundary. Use the tool's sandbox for isolation. +- Prefix rules are bypassable: `git -C dir push`, flags placed later (`git push origin main --force`), + and `+refspec` pushes. Claude Code's `:*` matches at a word boundary, so each flag cluster must be + listed. That is why `git clean` is denied whole and `rm` always asks. +- Allowed test commands run code from the repo. An agent that can edit `package.json` or the tests + can run anything through them. +- Denying reads of `.env` does not stop a script that opens it itself, and git can print staged or + committed secrets. `git add -f` is denied so an agent cannot stage an ignored secret. +- Codex: `approval_policy` in `.codex/config.toml` can loosen a stricter user setting. Codex has no `allow` rules here, + because in Codex `allow` means "run outside the sandbox". Codex cannot deny file reads. +- Gemini: `tools.exclude` is deprecated but is the only project-level deny. Gemini has no Ask tier and cannot deny file reads. diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..d737840 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,26 @@ +# TODO + +Durable backlog. Agents append here instead of leaving `TODO` comments in source. +Small, specific, independently actionable. Remove only when actually done. + +## Now +- [ ] Choose a linter/formatter (e.g. Biome), then add `lint` to `package.json`, `AGENTS.md` and `docs/PERMISSIONS.md`. +- [ ] Add CI on Gitea Actions: install, typecheck, and test on SQLite and Postgres. Add a guard that fails if `AGENTS.md` is over 150 lines. + +## Next +- [ ] Phase 2 and Phase 3 plans (spec §12). + +## Someday + +## Recurring +- [ ] Maintain `AGENTS.md`: + - **Add a rule only in response to a real failure.** When an agent repeatedly gets something wrong, + that is a gap; add one line, ideally with a code example. Do not pre-empt hypothetical mistakes. + - **Update in the same PR as the convention change.** A stale rules file is worse than none, because + it actively misleads. + - **Prune quarterly.** Delete rules whose convention has changed or whose mistake no longer occurs. + - **Optional, cheap validation:** run the same task twice in clean sessions, with and without the + file, and compare steps and token use. + +## Known issues +- [ ] `bun run typecheck` fails with TS18003 until source files exist under `apps/`, `packages/` or `plugins/`. diff --git a/docs/design/mockups/round1-seven-directions.html b/docs/design/mockups/round1-seven-directions.html new file mode 100644 index 0000000..530d9bb --- /dev/null +++ b/docs/design/mockups/round1-seven-directions.html @@ -0,0 +1,293 @@ + + + + +STTracker — style round 1 + + + + + + +
+

Style round 1: seven directions

+

Each mockup shows the same content: your shareable page with favourites from four kinds of media, plus recent activity. A–D are new looks. E–G borrow from Hardcover, Letterboxd and Trakt. Mark the ones you like (more than one is fine), then tell me in the terminal what works and what doesn't in each.

+
+ + +
+

A. Card catalogue

Every entry is an index card in your own library. Personal, archival, a little bookish.

+
+

The collection of Lucas W.

1,284 entries · catalogued since 2014
+
+
BK · 823.92 LEI

From the Library of Jurgen Leitner

book, read twice
"Don't read it in the dark."
★ 5 / 5
+
AU · 791.44 MAG

The Magnus Archives

podcast, 200 episodes
Series rated separately
★ 5 / 5
+
GM · 794.8 VOI

Voices of the Void

game, 41 h played
Bought: itch.io, 2023
★ 4 / 5
+
TV · 791.45 TWI

The Twilight Zone

series, 1959
Watching s3, slowly
IN PROGRESS
+
+
+
28 Oct 2026Annihilation — finishedbook★★★★½
+
26 Oct 2026Outer Wilds — 3rd playthroughgame★★★★★
+
25 Oct 2026Severance s2e04tv—
+
+
+
+ + +
+

B. The shelf

Everything sits as a spine on one shelf, whatever the medium. Colour shows the kind of media; width shows how long it took.

+
+

Lucas's shelf

Things that stayed with me. Books, games, shows and records on one shelf.

+
+
From the Library of Jurgen Leitner 2025
+
The Magnus Archives 200 ep
+
Voices of the Void 41 h
+
The Twilight Zone s1–s3
+
Annihilation 2026
+
Outer Wilds 3×
+
Lateralus LP
+
Severance s2
+
Box setSilent Hill, everything
12 entries, my order
+
+
BooksAudioGamesFilm & TVMusic
+
+
+ + +
+

C. Scrapbook

A personal page that feels made by hand: your screenshots, notes and tickets pinned up. Leans hard into "my page, my way."

+
+

stuff that got under my skin

+

Lucas W. — updated last Tuesday

+
+
Voices of the Void, night 12
+
Twilight Zone, s1e8 ★★★★★
+
The Magnus Archives —
finished all 200 episodes. The series gets 5 stars even though season 2 dragged. Episodes rated on their own.
+
From the Library of Jurgen Leitner
Bought: Waterstones, Leeds, £14
Read twice · ★★★★★
+
Outer Wilds, 3rd time
+
+
+
+ + +
+

D. River

Time is the organiser. Everything you consumed flows by month, and threads link related things across media.

+
+

Lucas, the last three months

+

43 entries across 5 kinds of media

+
+
Oct
+
+
BKAnnihilation★ 4.5
+
GMOuter Wilds3rd run★ 5
+
TVSeverances2 · 4 ep
+
+
Sep
+
+
AUThe Magnus Archivesfinished★ 5
+
BKFrom the Library of Jurgen Leitner★ 5
+
+
Aug
+
+
GMVoices of the Void41 h★ 4
+
TVThe Twilight Zones1–s3
+
+
+

Thread: "Cosmic dread"

Leitner → Magnus Archives → Voices of the Void → Twilight Zone

+
+
+ + +
+

E. Hardcover-inspired

Hardcover's dark, calm library app, stretched to every kind of media. Shelves by status, series rated as a whole.

+
+
Library
Log
Lists
Owned
Sources
Changes
+
+

Library

1,284 entries · 6 sources connected · last sync 4 min ago

+
All mediaBooksGamesFilm & TVAudioMusic
+
The Magnus Archives — series · 200 of 200 episodes★★★★★
+

From the Library of Jurgen Leitner

Book · Hardcover · read twice
★★★★★
Read
+

Voices of the Void

Game · Steam · 41 h
★★★★☆
Played
+

The Twilight Zone

Show · Trakt · season 3 of 5
☆☆☆☆☆
Watching
+
+
+
+ + +
+

F. Letterboxd-inspired

Poster-first profile with favourites up top and a diary underneath. Very shareable; covers carry the page.

+
+
ProfileDiaryListsLibrary
+

Lucas W.

Reads, plays, watches, listens.
312books
188games
640films & shows
14ktracks
+

Favourites

+
+
BOOKFrom the Library of Jurgen Leitner
+
PODCASTThe Magnus Archives
+
GAMEVoices of the Void
+
SERIESThe Twilight Zone
+
+

Diary

+
OCT 28Annihilation★★★★½♥
+
OCT 26Outer Wilds · replay★★★★★♥
+
+
+
+ + +
+

G. Trakt-inspired

Stats-heavy dashboard with progress bars, totals and history. Great for "what am I in the middle of", less personal.

+
+

Lucas W.

Joined 2014 · 6 sources connected

+
+
+

This year

38books
612 hplayed
214episodes
9.1kscrobbles
+

In progress

+
The Twilight Zone58%
+
Severance s24 / 10
+
Children of Timep. 212 / 600
+
+
+

History

+
Annihilation
finished · book
2 d ago
+
Outer Wilds
played 3 h · game
4 d ago
+
Severance s2e04
watched · tv
5 d ago
+
Tool — Lateralus
full album · music
5 d ago
+
+
+
+
+ + + diff --git a/docs/design/mockups/round2-app-scrapbook-river.html b/docs/design/mockups/round2-app-scrapbook-river.html new file mode 100644 index 0000000..92aaef3 --- /dev/null +++ b/docs/design/mockups/round2-app-scrapbook-river.html @@ -0,0 +1,215 @@ + + + + +STTracker — style round 2 + + + + + + +
+

Style round 2: the app, and two public pages

+

1 is the everyday app, a fuller Hardcover-style take showing a real entry: your reads, where you bought it, screenshots, linked sources, and a warning that a platform changed something. 2 and 3 are the shareable public page: a tidier scrapbook, and a river that actually does something. In the river, the card for the purple book is shown open, as it would appear when you hover over or tap a cover.

+
+ + +
+

1. The app

Library on the left, entry on the right. Quick entries without a source show as drafts you can fix later.

+
+
+ + + +
Sources
+ +
Your page
+ +
+
+ +
+ Quick add: type a title, sort it out later
+

From the Library of Jurgen Leitner

Book · read twice
+

The Magnus Archives

Podcast series · 200 ep
★★★★★
+

that weird ARG youtube series

Video · added from phone
Draft
+

Voices of the Void

Game · 41 h
★★★★☆
+

The Twilight Zone

Show · season 3 of 5
+

Annihilation

Book · Southern Reach #1
★★★★½
+
+
+
+
+
+

From the Library of Jurgen Leitner

+
Book · Read twice · last finished 14 Sep 2026
+
★★★★★
+
cosmic horrorre-readRecommended by: SamMood: 3am
+
+
+

Hardcover changed this book. On 12 Oct it was merged into another edition. Your rating, reads and review here are unchanged.

+
+
Reads
2nd read2 Sep – 14 Sep 2026
1st readMar 2025 (no exact date)
+
Your copy
BoughtWaterstones, Leeds
When / priceFeb 2025 · £14
FormatHardback
+
Attachments
+
Linked sources
Hardcover · synced 4 min ago
Obsidian note · Books/Leitner.md
+
Your review
Better the second time. Knowing what the books do makes every throwaway line land differently. Don't read it in the dark.
+
+
+
+
+ + +
+

2. Scrapbook, tidied

Same hand-made feel, but on a grid, so it holds up with lots of entries and reads well on a phone.

+
+
+

stuff that got under my skin

Lucas W. Books, games, shows, sounds.

+ +
+
+
+
Voices of the Void, night 12. Something knocked.
Game · 41 h · ★★★★☆ · my screenshot
+
From the Library of Jurgen LeitnerBought at Waterstones, Leeds, Feb 2025
Read twice · ★★★★★
+
+
+

The Magnus Archives

All 200 episodes. The series gets five stars even though season 2 dragged; the ending earns it. Episodes are rated on their own if you want to argue.

★★★★★ the whole thing
+
Twilight Zone, "Time Enough at Last"
Episode · ★★★★★
+
+
+
Outer Wilds, 3rd time and I still cried at the end
Game · ★★★★★
+

If you liked these…

Leitner → Magnus Archives → Voices of the Void → Twilight Zone. One long thread of cosmic dread across four media.

+
+
+
right now:📖 Children of Time, p. 212📺 Severance s2e05🎧 Lateralus, on repeat
+
+
+ + +
+

3. River, take two

Each kind of media is a stream; its width is how much time you spent on it. Covers sit where you finished things, and threads link related things across streams.

+
+

Lucas, summer into autumn

Summer was mostly games. Then September turned into one long, dark read-and-listen.

+
+ + + + + + JulyAugustSeptemberOctober + + + + + + + + + + cosmic dread + +
Leitner
+
TMA
+
VotV
+
TZ
+
Annih.
+
OW
+
From the Library of Jurgen Leitner★★★★★ · finished 14 Sep · 2nd read
"Better the second time."
+
+
BooksPodcasts & audioGamesFilm & TV
+
+
+ + + diff --git a/docs/plans/README.md b/docs/plans/README.md new file mode 100644 index 0000000..fd45fe8 --- /dev/null +++ b/docs/plans/README.md @@ -0,0 +1,11 @@ +# Plans + +Implementation plans from the superpowers writing-plans workflow. **Transient:** a plan lives only on +its feature branch and is removed with `git rm` before the merge request is opened. This README stays. + +- Naming: `NNN-short-slug.md`, matching its spec number. +- Each plan links back to its spec and has bite-sized checkbox tasks with exact file paths, test commands, and + commit checkpoints. +- Write it for an engineer with zero context for this codebase. Spell out the obvious. +- Tick checkboxes as you go so another agent can resume an interrupted session. +- Front matter: `status: draft | approved | in-progress`. There is no `done`, because a finished plan is deleted. diff --git a/docs/specs/001-sttracker-v1-design.md b/docs/specs/001-sttracker-v1-design.md new file mode 100644 index 0000000..8247ad8 --- /dev/null +++ b/docs/specs/001-sttracker-v1-design.md @@ -0,0 +1,368 @@ +--- +status: approved +--- + +# STTracker: design + +Date: 2026-10-30 +Status: approved 2026-10-30 + +## 1. Intent + +People track what they read, watch, play and listen to across many platforms (Hardcover, Trakt, Backloggd, MangaBaka, Last.fm/ListenBrainz…), plus spreadsheets and notes apps. Each platform: + +- can silently delete, merge or alter entries; +- may have weak or missing export/backup; +- has no bridge to the others, so moving between services is manual; +- demands a "real" catalogue link (TMDB id, ISBN…) before you can log anything; +- only covers one kind of media, with its own arbitrary limits. + +STTracker is a self-hosted **meta-tracker** that sits above all of them. It is the user's own, permanent copy of everything they've consumed and what they thought of it. Platforms become *sources* it pulls from (and sometimes pushes to); none of them is trusted as the record. + +**The one thing it must do well: put the user in control.** The user should never have to conform to the data. Anything a spreadsheet or an Obsidian note could hold, STTracker can hold, and no platform sync may overwrite or drop something the user wrote. + +### Audience + +1. The owner (single user, self-hosted). This is who v1 is for. +2. A few friends on the same instance. Multi-user accounts exist from day one. +3. Later, possibly a small public instance. Not designed for now, but nothing in v1 should block it. + +## 2. Goals + +- **One model for all media.** Books, comics/manga, games, films, shows (seasons, episodes), podcasts/audio dramas, music (artists, albums, tracks) and "other" share one entry model and one set of features: status, rating, review, dates, re-reads/re-plays, lists, tags, custom fields, attachments. A media type is a label, not a separate product. +- **Hierarchy where every level is first-class.** A series contains books; a show has seasons and episodes; an album has tracks; a game has DLC. Every level can carry its own rating, review, tags and attachments. A parent's rating is the user's own, not an average (an optional computed average may be *shown* alongside). +- **Quick logging without IDs.** A title alone is a valid entry ("draft"). It can be linked to catalogue IDs later, or never. +- **Watchdog.** Each source is snapshotted on a schedule. Additions, edits, deletions and merges made *on the platform* are detected and shown in a **Changes** inbox with a clear before/after. Nothing destructive is applied to the user's data automatically. +- **Backup and export.** Scheduled full backups. Exports in open formats (JSON, CSV, Markdown) that are easy for people and AI tools to work with. +- **Portability.** Move data between services, via write-back where a platform allows it, or via generated import files. +- **Private owned library.** What you *own or have access to* (Steam games, a Jellyfin server, physical books) is tracked separately from what you've *consumed*. It is private, never shown on profiles, and used to match plays/reads and to browse for something to start next. Each copy can record where/when it was acquired, price, format and notes. +- **Sources as plugins.** Each integration is a self-contained plugin against a small, stable contract, so plugins can be fixed, added or updated without changing the core. +- **Usable by other apps.** STTracker has its own API with per-user, per-device tokens / app passwords, so other software (Jellyfin, Navidrome, KOReader, scripts, AI assistants) can read from and report to it. +- **A personal public page.** A shareable page of the things that matter to the user (see §7). + +## 3. Non-goals (for now) + +- Social features (following, feeds, comments). Nice-to-have, later. +- A public catalogue that competes with TMDB/IGDB/OpenLibrary. STTracker caches metadata it needs; it is not a database for others. +- A heavy recommendation engine. Cross-media suggestions start from the user's own links, tags and threads (and possibly AI help), later. +- OAuth *provider* (third-party apps signing in with STTracker). App tokens cover v1; OAuth later with the public instance. +- Scraping Backloggd. It has no API, is behind bot protection, and its ToS/robots discourage it. Backloggd support waits for its official CSV export. +- Writing history *into* Last.fm (the API silently drops anything older than ~14 days). + +## 4. Anti-goals (failure even if it "works") + +- A platform sync silently overwrites or deletes something the user entered. +- The user can't log something because there's no catalogue match. +- The user's data is stuck: export is lossy, proprietary or incomplete. +- Media types feel like separate apps with different features. +- The public page looks like an admin stats dashboard instead of something about stories and things the user loves. +- Adding or fixing one integration requires touching the core. + +## 5. Constraints + +- **Runtime:** Bun (1.3+). Follow Bun-native conventions: `Bun.serve()` with HTML imports for the frontend (no Vite/Express), `bun:sqlite` / `Bun.sql`, `bun test`, `Bun.password`, `Bun.file`, Bun's automatic `.env` loading. +- **Databases:** SQLite (default, zero-config) and PostgreSQL (option). One code path for the domain logic; both dialects tested in CI. +- **Frontend:** React. +- **Deployment:** Docker image + compose file. All state under one data volume (`/data`: SQLite db if used, attachments, backups, raw snapshots, external plugins). +- **Source terms that shape the design:** + - Each self-hoster registers their own API credentials for Trakt, Last.fm, IGDB (Twitch), and optionally OAuth apps on Hardcover/MangaBaka. No shared keys are shipped. + - Trakt requires the credit line "STTracker uses Trakt but is not endorsed or certified by Trakt." MangaBaka data is CC BY-NC-SA (attribution, non-commercial). IGDB/Last.fm require attribution, non-commercial. Show attributions in the app footer/about page. + - Respect rate limits (Hardcover 60/min; MangaBaka 180/min; Trakt 500 GET/5 min, 1 write/s; IGDB 4/s; ListenBrainz ≈1/s with `X-RateLimit-*` headers; Last.fm ≈5/s). + - Hardcover's API is beta and may break; its plugin must fail loudly and safely. +- **Repo:** Gitea remote `https://gitea.lucaswinther.info/lucasw89/STTracker.git`. + +## 6. Domain model + +All IDs are ULIDs (sortable, generated in app code so both dialects behave the same). All user-facing rows have `created_at`, `updated_at`, and soft-delete `deleted_at`. Ratings are stored as integers 0–100. Display scale (5 stars with halves, 10-point, 100-point) is a user preference. + +### 6.1 Catalogue layer: `works` + +A **work** is a thing that can be consumed: a book, an edition-agnostic series, a game, a show, a season, an episode, an album, a track, a podcast, a manga series, etc. + +- `media_type` comes from a built-in set (book, comic, manga, game, movie, show, season, episode, podcast, podcast_episode, audio_drama, artist, album, track, video, other). Users can add their own types. A user-defined type behaves like `other` but has its own name, icon and default custom fields. +- `parent_id` → `works`, forming the hierarchy (series → book; show → season → episode; album → track; game → DLC). A work may also belong to user groupings via lists (§6.5), which are separate from this structural parent. +- Display fields: title, original title, year/date, creators (JSON list of `{name, role}`), cover (attachment or remote URL cached locally), description, extra metadata (JSON). +- `owner_user_id` is null for shared catalogue works (created from sources) and set for private works a user created by hand (quick add). Private works are visible only to their owner. +- `external_ids` table: (`work_id`, `namespace`, `value`), unique on (`namespace`, `value`). Namespaces include `isbn13`, `isbn10`, `asin`, `hardcover_book`, `hardcover_edition`, `tmdb_movie`, `tmdb_show`, `tvdb`, `imdb`, `trakt`, `igdb`, `steam`, `mangabaka`, `anilist`, `mal`, `musicbrainz_recording/release/artist`, etc. Plugins may register new namespaces. +- **Work merges** (user-initiated, or confirmed from a suggestion) keep a `merged_into_id` tombstone, so old references and external IDs still resolve. + +#### Who controls the shared catalogue + +Shared works are a **metadata cache**, not user data. +- Source plugins may update a shared work's display fields directly. Every change is recorded in `work_history`, so it can be inspected and reverted by an admin. +- What a user sees can always be pinned with their own **overrides** (§6.2). Overrides are per user and never touched by syncs. +- A platform merge (e.g. Hardcover dedupes a book) never merges shared works on its own. It produces a change event in each affected user's inbox. "Follow the merge" moves *that user's* entry to the target work. +- A user merging two works is also per user: their entry moves to the target, and an alias is kept so their old references still resolve. Only an admin can merge shared works globally, which leaves a `merged_into_id` tombstone. +- A private draft work stays private. When the user links it to a shared work, their entry moves to the shared work, and the draft is tombstoned and points to it. +- User-defined media types are a per-user label stored on the entry (`media_type_override`). Shared works keep a built-in type, defaulting to `other`. +- Search shows shared works plus the user's own private works. + +### 6.2 User layer: `entries` + +An **entry** is one user's relationship with one work. It is unique per (user, work). + +- `status`: a small fixed set mapped across all media: planned, in_progress, paused, completed, dropped, plus "none" for owned-but-untouched or rated-only. Each media type can show its own wording ("Want to read", "Watching", "Played"). Users can rename the labels. +- `rating` (0–100, nullable), `review` (Markdown), `private_notes` (Markdown), `favorite` flag, `tags`. +- `custom_fields` (JSON): values for user-defined fields (§6.6). +- `visibility`: private | friends | public. The default is a user preference, initially private. +- **Overrides:** a user can override display fields (title, cover, year…) for themselves without changing the shared work. +- **Draft:** an entry whose work has no external IDs and no confirmed match is shown as a "Draft" so the user can fix it later. This is a derived state, not a separate table. + +### 6.3 `engagements`: each read, watch or play + +One row per time through: `entry_id`, `started_at`, `finished_at`, `date_precision` (day | month | year | unknown, so "I read this in 2019" is valid), `progress` (JSON: page/percent/chapter/episode/hours), `platform` (free text, e.g. "Switch", "Kindle"), `rating` and `notes` for that particular pass (optional), `source_ref` (which source reported it, if any). + +#### How deep imports go + +- Imports create shared works at every level the source reports, since works are cheap catalogue rows. For example, Trakt episode plays create show, season and episode works. +- **Entries** are created only at the level where the source reports user data. A Trakt episode watch creates an episode entry plus an engagement on it. The show gets an entry too, holding the status and rating Trakt reports for the show. +- The library list shows top-level entries by default, with children rolled up. For example: "season 3 of 5, 41 episodes watched". +- Parent status is never derived automatically. The rollup is shown, and the user decides. +- Tracks are the exception: plays stay in `listens` (§6.4), and track or album entries exist only when a source reports a rating/love or the user adds one. + +### 6.4 `listens` + +Music scrobbles are high-volume (hundreds of thousands) and get their own append-only table: `user_id`, `listened_at`, `artist`, `track`, `album`, `duration`, optional MBIDs, `work_id` (nullable, filled in by background matching), `source`, `source_key` (ListenBrainz `listened_at`+`recording_msid`, or Last.fm `ts`+artist+track). Unique on (user, source, source_key). Albums and tracks can still have normal entries for ratings and reviews. + +**Matching listens** only links to existing works, or creates shared artist/album/track works when the listen carries MusicBrainz IDs. Listens with no IDs and no match stay unmatched (`work_id` null). Listens never create draft works. They remain fully visible in Log and in exports either way. + +### 6.5 Lists + +User-created ordered collections of works, mixed media allowed. Each item has an optional note. Fields: name, description, visibility, `kind`: +- `list`: a plain list. +- `grouping`: the user's own "box set" / reading order, e.g. "Silent Hill, everything" or "my Cosmere order". It can be rated and reviewed like an entry (it has its own entry-like fields). +- `thread`: a cross-media chain of related things, e.g. "cosmic dread: Leitner → Magnus Archives → Voices of the Void → Twilight Zone". Threads feed the public page's river view and future recommendations. + +### 6.6 Custom fields + +`custom_field_definitions`: per user, `name`, `type` (text, long text, number, date, boolean, single select, multi select, url, rating), `applies_to` (all media types or a chosen subset), `level` (entry | engagement | copy). Values live in the JSON column of the matching row. Examples: "Recommended by", "Mood", "Translator". + +### 6.7 Owned library: `copies` + +Private to the user, never public. Fields: `user_id`, `work_id` (nullable until matched), `title_hint` (raw title from the source when unmatched), `format` (hardback, ebook, Steam, disc, file on Jellyfin…), `acquired_from`, `acquired_at` (+ precision), `price` + `currency`, `notes`, `custom_fields`, `source_ref`. Copies are linked to entries through the shared work. Library-only sources (Steam, Jellyfin, Kavita…) create copies, not entries. + +### 6.8 Attachments + +Content-addressed files (sha256) stored on disk under `/data/attachments`. The storage backend is behind an interface so S3-compatible storage can be added later. Attachments can be linked to an entry, engagement, copy, list or work (covers). Each has a caption, `taken_at`, and visibility (inherits from its parent by default). Thumbnails are generated for images. Attachments are included in backups. + +### 6.9 History of the user's own data + +Every change to a user-layer row (entries, engagements, copies, lists, custom field values) writes a row to `user_history`: entity, id, before/after JSON diff, actor (user | plugin: | api token:), timestamp. Users can view an entry's history and restore an earlier version. Deletes are soft deletes. This, together with snapshots, is what makes "nothing gets lost" true. + +### 6.10 Users and auth + +- `users`: username, display name, password hash (`Bun.password`, argon2id), role (admin | member), preferences JSON. The first registered user becomes admin. After that, registration is invite-only (admin creates invite links). +- Sessions: secure HTTP-only cookie, server-side session table. +- **App tokens** (also called "app passwords"): per user, named, scoped (read, write, ingest), revocable, last-used tracking, hashed at rest. Used by the API, plugins' inbound endpoints, scripts and AI tools. + +## 7. Surfaces (UI) + +The mockups from brainstorming (round 2) are the reference. They are in `docs/design/mockups/` (`round2-app-scrapbook-river.html` is the reference; `round1-seven-directions.html` holds the parked themes). + +### 7.1 The app (everyday use), "Hardcover-style" + +Dark, calm, dense. Indigo/near-violet surfaces, amber for ratings, orange reserved for change warnings. Three panes on desktop: navigation sidebar, library list, entry detail. Collapses to a stack on mobile. + +- **Navigation:** Library, Log (chronological engagements and listens), Lists, Owned, Changes (with an unread count), Sources (one item per connection), Your page, Settings. +- **Library list:** search, filters (media type, status, rating, tag, source, draft), sort. "Quick add" box at the top: type a title, optionally pick a media type, done. The entry is created as a draft. +- **Entry detail:** cover, title, rating, status, tags, custom fields; a change warning when a source changed something; engagements (reads/plays) with dates and precision; your copy (owned library); attachments; linked sources with sync status; review; hierarchy (parent and children, each with its own rating); history. +- **Changes inbox:** grouped by source run. Each change shows before/after and actions: accept (apply to my data), ignore, or "keep mine". Merge events show "X was merged into Y on " with an option to follow the merge. +- **Matching:** for drafts and unmatched copies/listens, show candidate works (by external ID, then fuzzy title+year+creator) and let the user confirm, pick another, or keep as-is. +- **Sources:** connect/disconnect, credentials, schedule, last run, run now, per-run log, capability toggles (import / watch / write-back). +- **Export & backup:** download exports, configure scheduled backups, and restore. + +### 7.2 The public page, "scrapbook + river" + +A shareable URL per user (`/u/`), showing only what the user marks public. + +- **Scrapbook layout (default):** a grid-based collage of the user's chosen highlights. Polaroid-style cards for entries with screenshots/covers and handwritten-style captions, note cards for reviews, "ticket" cards for copies/provenance (only if the user explicitly chooses to show it), thread cards, and a "right now" strip of in-progress entries. Must read well on phones. +- **River view:** a timeline where each media type is a stream whose width is time spent (from engagement dates/durations, listens and progress). Covers sit where things were finished, and threads draw links across streams. Tapping/hovering a cover shows its rating, date and a review excerpt. +- **The user curates it:** pick highlights, order them, choose captions and which threads to show. +- **Themes later:** the "shelf" and "card catalogue" directions from round 1 are parked as optional alternative themes. + +## 8. Sources: the plugin system + +### 8.1 Shape + +A plugin is a folder with a `manifest` and a TypeScript entry module exporting an object that implements the plugin contract from the `@sttracker/plugin-sdk` package. Plugins are loaded at startup from: +- `plugins/` in the repo (built-ins, shipped in the image), and +- `/data/plugins/` (external plugins dropped in by the instance admin). + +The admin can reload plugins from the UI without restarting the server, by re-importing with a cache-busting query. If hot reload proves unreliable in Bun, a restart is acceptable; it must not need a rebuild of the core. + +Plugins are **trusted code** running in-process. Only the instance admin installs them. Sandboxing is out of scope. + +### 8.2 Contract (capabilities) + +The manifest declares `id`, `name`, `version`, `sdkVersion`, the media types covered, a config/credential schema (rendered as a form in Sources), and which capabilities it implements: + +| Capability | What it does | +|---|---| +| `pull` | Full fetch of the user's data from the source (backfill and periodic full snapshot). | +| `pullChanges` | Incremental fetch using a cursor or "last activity" signal, if the source supports it. | +| `push` | Write changes back to the source (status, rating, engagement, list…). | +| `ownedLibrary` | Fetch owned/available items, which become `copies`. | +| `ingest` | Expose HTTP endpoints under `/ingest//…` for other apps to push events (webhooks, ListenBrainz-compatible API, kosync). Authenticated with app tokens. | +| `lookup` | Search/resolve catalogue metadata (used by matching). | +| `importFile` / `exportFile` | Parse an uploaded file, or generate a file in the source's import format. | + +**Plugins never touch the database.** They receive a context (`http` client with rate limiting, retries and `Retry-After` handling; `log`; `credentials` get/refresh/save; `cursor` get/set; `now`) and return **normalised source records**: + +``` +SourceRecord { + key: string // stable id of this record on the source + kind: "entry" | "engagement" | "list" | "listItem" | "copy" | "listen" | "work" + raw: unknown // exact payload, stored in the snapshot + normalized: { work hints (title, media type, year, creators, external ids, parent hint), + entry/engagement/copy/listen fields mapped to the STTracker model } + mergedInto?: string // the platform says this record was merged into another + deleted?: boolean // the platform reports deletion (else inferred from absence in a full pull) +} +``` + +The core owns everything else: snapshot storage, diffing, change events, matching, applying changes to the user layer, history, scheduling and backups. + +### 8.3 Snapshots and change detection + +- `source_connections`: user, plugin id, config, encrypted credentials (AES-GCM with a key from the `STTRACKER_SECRET` env var), schedule, cursor, status. +- `source_records`: (connection, key) → current hash, `first_seen`, `last_seen`, `deleted_at`, `merged_into`, and the link to the STTracker row it maps to. +- `source_record_versions`: (record, hash, raw payload compressed, `seen_at`). A new version is stored only when the hash changes. +- `source_runs`: one per pull, with counts and a log. +- After each run the core diffs against the previous state and emits `change_events`: added, changed (field-level diff), removed (missing from a full pull, or reported deleted), merged. +- **Applying changes:** + - New records are applied automatically. They create entries, engagements, copies or listens, matched to works. + - A change to a field the user has never edited in STTracker is applied automatically and logged. + - A change to a field the user has edited, every removal and every merge goes to the Changes inbox as a pending decision. Nothing the user wrote is overwritten or deleted without their say. + - Per-field provenance (`field_sources` JSON on user rows: field → "user" | "plugin:@