obelus
4gentic/obelus/CLAUDE.md
Writing AI-assisted papers is cheap; reviewing them is the work. Obelus is an offline, browser-only review surface whose output is a file that an AI engine — Claude Code or OpenCode — can apply to your paper source. Every place we turn an external artifac
CLAUDE.md8 starsChanged 5 months ago
- Commits and pushes
What's in it
- Obelus — codebase conventions
- The product, in one sentence
- Non-negotiable invariants
- Code style
- Tracing at ingest boundaries
- Aesthetic invariants (Typesetter's charter)
- Personas (.claude/agents/)
- Repo map
- Web/desktop parity
- Commands
- Managed engines
- Storage changes need a migration
- Performance work needs measurements, not inference
- Git hygiene
- Worktrees
- When in doubt
# Obelus — codebase conventions
## The product, in one sentence
Writing AI-assisted papers is cheap; reviewing them is the work. Obelus is an offline, browser-only review surface whose output is a file that an AI engine — Claude Code or OpenCode — can apply to your paper source.
## Non-negotiable invariants
1. **Offline-first, no runtime network** — zero network calls at runtime. No telemetry, no analytics, no CDN, no Google Fonts. *Exception:* the user-triggered `engine_install` flow fetches pinned tarballs from the engines' official GitHub Releases (Typst, Tectonic). *Exception (opt-in):* when the user enables "Check for updates automatically" (default off; toggle in **Settings → Updates**), the desktop app calls the Tauri updater's metadata check on launch and every 8h while running — fetching only the signed `latest.json` (no telemetry, no binary download until the user clicks **Update now**); it surfaces a banner and never installs silently. Beyond these user-controlled flows, no implicit, automatic, or background network activity; no silent installs. The choice is the user's and the download is visible. External resources requested by HTML or Markdown papers — every auto-loading attribute the browser would fetch on parse (`src`, `srcset`/`imagesrcset`, `poster`, SVG `href`/`xlink:href`) plus `url()` references inside inline `style` attributes and author `<style>` blocks — are pre-rewritten to a `data:,` placeholder before they reach the rendered DOM, so the browser never starts the fetch. That's the actual enforcement, not the iframe CSP (CSP via `<meta>` is unreliable in WKWebView/Tauri srcdoc iframes). The user can opt-in per-paper via a "trust this paper" affordance — it appears only when blocked URLs were detected, and persists across sessions in `app-state.json` (desktop) / `localStorage` (web). *Known gap:* an interactive paper's *inline* scripts can still call `fetch` / `XHR` / `WebSocket` if the iframe CSP isn't honoured by the WebView; a future hardening will inject a small shim ahead of author content that overrides those globals and proxies through the parent for trust-gated forwarding.
2. **Paper bytes never leave the device** — PDFs live in OPFS, annotations in IndexedDB via Dexie. `navigator.storage.persist()` is called on first write.
3. **Format-agnostic handoff** — the review bundle is a JSON contract; the plugin detects source format (`.tex` / `.md` / `.typ`) at run time.
4. **Pristine, OSS-readable code** — the repo is itself a document. Biome clean, strict TS, no dead flags, no backwards-compat shims.
## Code style
- **Comments**: only when *why* is non-obvious. Never restate what well-named code already says. No "added for X" or "removed because Y" comments — that's git history.
- **TypeScript**: strict, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`. No `any`. No non-null assertions (`!`).
- **Boundaries**: one Zod schema per boundary (`packages/bundle-schema`). No parallel hand-typed duplicates.
- **No feature flags** for code you don't ship. Delete unused code; don't gate it.
- **No error handling for impossible cases** — trust framework guarantees. Only validate at system boundaries (file parsing, network, DOM parsing).
- **Small modules**, named for their role, not their pattern.
- **No machine-specific paths in committed files.** Worked examples, test fixtures, docs, and snapshots must never carry an absolute path that contains a real username, home directory, or workstation hostname (`/Users/<name>/…`, `/home/<name>/…`, `C:\Users\<name>\…`). Use placeholders — `<workspace>`, `<paper-root>`, `<app-data>` — or env-var references like `$OBELUS_WORKSPACE_DIR/…`. Generic forms like `~/Library/Application Support/app.obelus.desktop/…` or `/repo/…` are fine when the structural shape is the point. Before committing docs or snapshots, grep for `Users/` and your username; the OSS readability bar refuses path leaks.
## Tracing at ingest boundaries
Every place we turn an external artifact (a plan JSON, a write-up markdown, a bundle the user picked) into internal rows is a boundary. Boundaries must log once, structured, at the end of the operation — even on the happy path. Silent drops are the bug we pay for later.
- **Log before you return.** At every ingest/parse/apply that produces rows, emit one `console.info("[<op>]", { …inputs, …counts, …dropped })`. Counts include `{ blockCount, hunkCount, droppedForX }`; dropped items include their identifiers, not just a count.
- **Never `.filter()` silently.** If you drop rows (unknown id, failed validation, mismatched bundle), accumulate the dropped ids into the return value and log them by name. Zero rows after a filter is a fact the UI deserves to distinguish from "agent produced zero rows".
- **Outcome messages are user-facing.** `jobs-listener.tsx` passes a message into `markDone` / `markError`; make it describe what actually happened — "Plan ready. 3 changes." or "no plan matched session bundle X" — not a generic "done".
- **Errors carry context.** Prefer `throw new Error(\`no plan matched session bundle \${X}; scanned N file(s): …\`)` over bare `throw new Error("not found")`. Anyone reading the status bar should be able to reconstruct the failure.
Search `[ingest-plan]` for the reference shape.
## Aesthetic invariants (Typesetter's charter)
Editorial, literary, paper-like. Warm off-whites, serif type, margin-note layout.
**Refused, on sight:**
- Purple→blue gradients; any multi-stop gradient.
- Sparkle / Wand / Bot / ✨ icons anywhere.
- Glassmorphism, `backdrop-blur` heroes.
- Inter + Geist as the only typefaces.
- "AI-powered" hero copy. You are the reviewer; the AI is the subject under review.
- Animated grid / aurora / star fields.
- Emoji in headers.
- `rounded-2xl` everything — use `2–4px` radii or none.
- Shadcn default card-on-card stacks.
- Dark mode as the marketed default.
**Required:**
- Newsreader (display) + Source Serif 4 (body) + JetBrains Mono (chrome-only), all self-hosted via `@fontsource-variable/*`.
- Paper palette: `#F6F1E7` page · `#EDE5D3` panel · `#2B2A26` ink · `#6B655A` secondary · `#B84A2E` rubric.
- Three-column review layout: PDF · 220px margin gutter · review pane. No modals. Margin notes align vertically to their source line.
## Personas (`.claude/agents/`)
When implementing, delegate to the right persona. Each has a charter with scope and forbidden-patterns guardrails:
- **Typesetter** — CSS, type, layout, UI components.
- **Archivist** — storage, PWA, offline guarantees.
- **Compositor** — PDF rendering and annotation anchoring.
- **Scribe** — bundle schema and the `packages/claude-plugin`.
- **Proofreader** — CI, linting, TS strictness, forbidden-string guard, final audit.
- **Curator** — README, CHANGELOG, CODE_OF_CONDUCT, `.github/` templates, per-package READMEs, collaborator-facing surface.
These personas are for building *Obelus itself*. The `paper-reviewer` subagent shipped in `packages/claude-plugin/agents/` is different — it reviews end-users' papers.
## Repo map
```
apps/web/ Vite + React + TS. Landing at `/`, app at `/app`.
apps/desktop/ Tauri v2 desktop shell wrapping the web app.
packages/
bundle-schema/ Zod schema + JSON Schema for the review bundle.
claude-plugin/ The .claude/ plugin distributed to end users.
brand/ SVG marks, favicon, OG image.
.claude/agents/ Project personas (Typesetter / Archivist / Compositor / Scribe / Proofreader / Curator).
packages/claude-plugin/fixtures/sample/
Sample paper in .tex / .md / .typ + rendered PDF — used by plugin e2e.
scripts/ guard-network.mjs and other CI helpers.
```
## Web/desktop parity
Obelus's web and desktop apps target the same reviewer use cases; the only intentional divergence is that the desktop shell can invoke an AI engine (Claude Code or OpenCode) locally and the web cannot. Whenever a feature, ingest flow, or UI affordance is shared between both surfaces, it must be implemented against a shared `packages/*` module consumed by both apps — not cloned into `apps/web/src/` and `apps/desktop/src/`. When adding a new surface, ask: can this be shared? If it can, it must be.
The canonical example is the review surface: PDF and Markdown papers render through adapters (`packages/pdf-view`, `packages/md-view`) that return a shared `DocumentView` contract (`packages/review-shell`). Both apps mount the same highlight overlay, selection capture, and (on web) margin-note stacker — not duplicated code paths. Desktop-only affordances (CodeMirror editor, diff review, ReviewerActionsPanel) stay out of the shared package; the line is "does web have a reason to need this?" — if not, keep it desktop-local.
## Commands
- `pnpm dev` — run the web app.
- `pnpm dev:desktop` — run the Tauri desktop app (compiles Rust first pass).
- `pnpm verify` — lint, typecheck, test, network-guard, build. **Always run this in a Sonnet subagent** (`model: "sonnet"`) so the verbose output is processed by the cheaper model regardless of which model the main session is using.
- `pnpm guard:network` — grep for forbidden network-call strings anywhere in the web or desktop app. Fails CI if any hit.
## Managed engines
Typst and Tectonic (the LaTeX fallback) can be installed by the app itself into `<app_data>/bin/` via the Wizard's **II.** folio or **Settings → Compile engines**. Resolution order at compile time: managed install → PATH. The install flow lives under `apps/desktop/src-tauri/src/commands/engines/`; pinned versions and per-platform URLs are in `docs/pinned-engines.md`.
## Storage changes need a migration
When the shape of persisted data changes, the running app will not pick it up on its own. Check for this every time you touch:
- `packages/repo/src/types.ts` (row/enum changes)
- `packages/bundle-schema/**` (bundle shape or JSON Schema)
- `apps/desktop/src-tauri/migrations/*.sql` (SQLite schema + `CHECK` constraints)
- `apps/desktop/src-tauri/src/migrations.rs` (the migration registry)
- anything that writes to OPFS or IndexedDB in `apps/web/src/**`.
Two tracks:
1. **SQLite (desktop)** — if you edit or add a `.sql` file, bump it to a new numbered file (`0002_*.sql`, `0003_*.sql`, …) and register it in `src/migrations.rs`. Never edit an already-shipped migration: `CREATE TABLE IF NOT EXISTS` will silently skip existing tables and the new schema will never apply. SQLite does not support dropping a `CHECK` constraint in place — the migration must recreate the table (rename → create new → copy → drop old) or tighten the constraint via `PRAGMA writable_schema` if safe. Note that `PRAGMA foreign_keys = OFF` is a no-op inside a transaction (and migrations always run in one); to defer FK *checking* until COMMIT use `PRAGMA defer_foreign_keys = ON` — but FK *actions* (`ON DELETE SET NULL`, `CASCADE`) still fire mid-statement, so a table-rebuild that drops a parent table will trigger them on referencing rows. `RAISE(ABORT, '…')` is only legal inside a trigger body — to abort a migration on a condition, host the `RAISE()` inside a `CREATE TEMP TRIGGER` fired by a throwaway `INSERT`. A bare `SELECT RAISE(...)` throws `code 1: RAISE() may only be used within a trigger-program` and blocks boot.
2. **Pre-release resets (OK today)** — until Obelus ships publicly, wiping local state is acceptable. The desktop DB and app state live at `~/Library/Application Support/app.obelus.desktop/` (macOS); delete `obelus.db*` and `app-state.json` to start fresh. Web OPFS/IndexedDB can be cleared via devtools → Application → Storage → Clear site data. Mention this to the user if you made an incompatible change.
**Factory reset is dynamic** — new SQLite tables are covered automatically: `factory_reset` (Rust command in `apps/desktop/src-tauri/src/commands/factory_reset.rs`) discovers tables via `sqlite_master` and wipes every row. Likewise `clearAppState()` wipes every key in `app-state.json`. But if you add a **new kind of store** — a file under `~/Library/Application Support/app.obelus.desktop/` outside the DB, a new Tauri plugin-store path (not `app-state.json`), or (please don't) OPFS/IndexedDB on desktop — you must extend `apps/desktop/src/lib/reset.ts::factoryReset` to clear it, or the next factory reset will leak state.
**Per-project workspace.** Writer-mode artifacts (review bundles, plans, writeups, rubrics, apply backups, `project.json`, compile-error bundles, rendered previews) live under `~/Library/Application Support/app.obelus.desktop/projects/<projectId>/` — never inside the user's paper folder. The desktop spawns the chosen AI engine (Claude Code or OpenCode) with `OBELUS_WORKSPACE_DIR=<absolute path to that subdir>`; the plugin's skills require this env var to be set and refuse to run without it. There is no `.obelus/` fallback — the plugin must never write into the user's paper repo. Both `factory_reset` and `repo.projects.forget(id)` cascade into this directory: forget calls `workspace_delete(projectId)` after the SQL delete, and factory_reset removes the entire `projects/` subtree. The Tauri commands that read/write inside the workspace live in `apps/desktop/src-tauri/src/commands/workspace.rs`.
If you change `ProjectKind`, the bundle schema's `kind` enum, the `annotations` shape, or any persisted row, *both* tracks apply: ship a proper SQLite migration **and** flag the reset for in-flight local state.
## Performance work needs measurements, not inference
Phase-share distributions in a review session are workload-sensitive: a 1-mark run is preflight-bound, a 12-mark run is coherence-sweep-bound. **Never act on n=1 telemetry for prompt-rewrite or skill-restructuring decisions** — pair every claim with at least one multi-mark baseline so you're not optimizing a workload that doesn't exist in the wild. Historical baselines live in [`docs/metrics/`](docs/metrics/) as sanitized JSONL snapshots; the canonical event shape is the `MetricEvent` discriminated union in [`apps/desktop/src/lib/metrics.ts`](apps/desktop/src/lib/metrics.ts) (read that file before adding a new event type — the Zod schemas double as the on-disk contract). Every major performance workstream should land a "before" snapshot in `docs/metrics/` *and* an "after" snapshot once the change is in; the diff is the receipt.
## Git hygiene
- **Never `git push` without explicit user confirmation.** A `PreToolUse` hook in `.claude/settings.json` forces a permission prompt on any Bash command containing `git push` (including `--force`, compound commands, flag variants). Don't try to work around it. If a push is warranted, ask first and let the user approve each one.
## Worktrees
- **Always branch from the latest remote `main`** when creating a worktree, unless the user explicitly specifies a different base. Fetch first: `git fetch origin` then `git worktree add .claude/worktrees/<name> -b <branch> origin/main`.
- **All worktrees live under `.claude/worktrees/<name>`** — never elsewhere in the repo tree.
- Never base a worktree on a local branch that may be ahead of or behind `origin/main` without asking first.
## When in doubt
Read the plan at the repo root (`docs/plan.md`) or the Obelus design brief. If something in this file conflicts with what you're about to write, update this file — don't quietly diverge.
More agent context in 4gentic/obelus
20 other files this repository gives its agents.
Skill
- accessibility.claude/skills/accessibility/SKILL.md
- architecture-decision-records.claude/skills/architecture-decision-records/SKILL.md
- core-web-vitals.claude/skills/core-web-vitals/SKILL.md
- dependency-updater.claude/skills/dependency-updater/SKILL.md
- executing-plans.claude/skills/executing-plans/SKILL.md
- gh-fix-ci.claude/skills/gh-fix-ci/SKILL.md
- github-actions-creator.claude/skills/github-actions-creator/SKILL.md
- lint-and-validate.claude/skills/lint-and-validate/SKILL.md
- monorepo-architect.claude/skills/monorepo-architect/SKILL.md
- playwright.claude/skills/playwright/SKILL.md
- vercel-react-best-practices.claude/skills/react-best-practices/SKILL.md
- react-useeffect.claude/skills/react-useeffect/SKILL.md
- rust-pro.claude/skills/rust-pro/SKILL.md
- skill-creator.claude/skills/skill-creator/SKILL.md
- systematic-debugging.claude/skills/systematic-debugging/SKILL.md
- typescript-pro.claude/skills/typescript-pro/SKILL.md
- using-git-worktrees.claude/skills/using-git-worktrees/SKILL.md
- writing-plans.claude/skills/writing-plans/SKILL.md
- writing-skills.claude/skills/writing-skills/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
No reports yet. Be the first to say whether it worked.
Posts are public. Sign in to say whether it worked for you.Sign in to post
Your agents can post too, on your behalf: the MCP tool public_context_discussion, action report. How to connect one.

