diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..2ed2a55
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,116 @@
+
+
+## What and why
+
+
+
+Closes #
+
+## Verification
+
+
+
+- [ ] `npm test` —
+- [ ] `npm run typecheck` — 0 errors (root + app)
+- [ ] `npm run lint` — 0 errors
+- [ ] `npm run test:electron:all` —
+- [ ] New tests actually run in CI (`.github/workflows/`)
+- [ ] No existing assertion was loosened
+- [ ] Every capability described above was exercised end to end from the
+ outermost entry point (including anything shown in a screenshot)
+- [ ] Re-ran the checks above on the current head, after the most recent merge
+
+## Deliberately out of scope
+
+
+
+---
+
+
+Renderer / Electron UI — expand if you touched app/src/renderer or row rendering
+
+- [ ] Reader-anchor assertion added — content settling above the viewport does
+ not move the row the user is looking at
+- [ ] Async media covered at mount, size-available, and load/error — not only the
+ final state
+- [ ] Tested both at rest and mid-scroll (`virtual-core` skips compensation for
+ measured rows when scrolling backward)
+- [ ] No `loading="lazy"` on virtualized rows
+- [ ] No hardcoded colors or type sizes in shadow DOM (`var(--muted)` etc.)
+- [ ] One visual treatment per user-visible concept
+- [ ] Library calling conventions either accept both shapes or are pinned in code
+- [ ] Every renderer probe has a deadline and an `error` → reject path
+- [ ] No assertion with sub-pixel headroom (self-calibrate instead)
+
+
+
+
+Provider adapter — expand if you touched packages/core/src/providers
+
+- [ ] Read `claude.ts`, `codex.ts`, and `kimi.ts` in full first
+- [ ] Session identity is composite (e.g. normalized cwd + header id), not the
+ source id alone
+- [ ] A test actually calls `discover()` against each supported directory layout
+- [ ] Directory layout verified against upstream source or format docs
+- [ ] Canonical transcript invariant holds: direct assembly == SQLite round-trip
+ (ADR-0007)
+- [ ] Text-less records (image-only, aborted-with-usage) still emit a row
+- [ ] `indexVersionMarker` bumped if uuid format, role normalization, or any
+ stored-row shape changed
+- [ ] "Should not be displayed" uses `visibility`, not a new meaning for
+ `is_sidechain`
+- [ ] Cursor detects same-millisecond rewrites (mtime + ctime + size + inode)
+- [ ] Unknown/newer versions are skipped and recorded, not thrown on
+
+
+
+
+Schema / migration — expand if you touched schema.sql or schema-migrations.ts
+
+- [ ] `schema.sql` hash updated in `tests/provider-schema-stability.test.mjs`,
+ with justification above
+- [ ] Destructive DDL runs inside `runWriteTransaction`
+- [ ] Completion is not inferred from the target state's own name — interrupting
+ at any line self-heals on the next start
+- [ ] Validation rejects anything SQLite would reject, before any mutation
+- [ ] Checked that no table carries its own arguments that a blanket write would
+ erase
+- [ ] External input spliced into DDL has an allowlist and an injection test
+
+
+
+
+Main process / untrusted input — expand if you touched app/src/main
+
+
+
+- [ ] Transcript-derived paths do not reach `shell.openPath` unguarded;
+ confirmation dialogs default to Cancel
+- [ ] File reads triggered by transcript content are allowlisted to known roots
+- [ ] No `innerHTML` / detached `
+
+
+Indexing / daemon — expand if you touched indexer.ts, provider-indexing.ts, or write coordination
+
+- [ ] Read-only while the daemon heartbeat is fresh — no write connection,
+ migration, PRAGMA change, checkpoint, or indexing
+- [ ] Anything needing periodic refresh has a refresh point that is genuinely
+ called repeatedly, including on existing installations
+- [ ] Configuration mismatch under `daemon_active` is an explicit error, not a
+ silent fall back to a stale index
+- [ ] Trigger vs. full-rebuild choice is justified above
+
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..7fe39fa
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,207 @@
+# Contributing to Obelisk
+
+Thanks for contributing. This document exists because most PRs that stall here
+are not low-quality code — they pass lint, typecheck, and their own tests. They
+stall on a small number of recurring failures that are easy to avoid once
+someone names them.
+
+Read the section for the area you are changing. The verification contract at the
+bottom applies to every PR.
+
+---
+
+## Six things that decide whether a PR lands
+
+**1. Run every sentence of your PR description end to end.**
+The single most common failure is a capability that is advertised but
+unreachable. If you describe a config option, use that option from the outermost
+entry point before submitting. If you post a screenshot, the input in that
+screenshot must be an input the code can actually handle.
+
+**2. Write assertions in the words of the requirement, not the shape of the
+implementation.**
+Copy the sentence from the issue into your test name. If the issue says "without
+causing reader-position jumps", the assertion has to measure reader position —
+not "the row got taller". If a test hits behavior you did not expect, decide
+whether it is a bug before you pin it as expected.
+
+**3. Anything destructive must converge when re-run after an interruption.**
+Validate to the point of actual executability before you mutate. Put the whole
+sequence in one transaction. Never use the name of the target state as the
+completion marker. The test is: if the process dies on any line, does the next
+start heal itself?
+
+**4. Read the neighbouring implementation first, and reuse the concepts that
+already exist.**
+Adding a provider means reading `claude.ts`, `codex.ts`, and `kimi.ts` in full
+first. Needing "don't display this row" means grepping for `visibility` before
+inventing a field. The burden of proof for a new concept, field, state, or file
+type is on the PR: say why the existing one is insufficient. The ADRs in
+`docs/adr/` are constraints, not suggestions.
+
+**5. Treat all transcript content as attacker-controlled.**
+It is written by third-party agents. Any path where a transcript value reaches
+`shell.*`, `fs.*`, `innerHTML`, or SQL/DDL is deny-by-default.
+
+**6. Re-run verification on the final head.**
+Merging main invalidates every claim in your PR description, including your own
+"known limitations". Run the suites that cover the line you touched, not only
+the test you added.
+
+---
+
+## Renderer / Electron UI changes
+
+Proving the new element renders correctly is one third of the job. You also owe
+evidence that it does not disturb virtual scrolling, async timing, existing
+interactions, or the full Electron suites.
+
+- Any change that can affect row height — new elements, async media, fonts,
+ spacing, the shape of `renderMarkdown` output — needs a **reader-anchor
+ assertion**: content above the viewport settling must not move the row the
+ user is looking at. Follow the existing pattern in
+ `app/tests/electron-session-virtualization.mjs`.
+- Cover **three states, not just the final one**: mount, size-available-but-not-
+ loaded, and load/error. Progressive images reach their final size long before
+ `load` fires; signalling only on `load` will miss it.
+- **Idle and mid-scroll are different scenarios.** `virtual-core`'s `resizeItem`
+ skips scroll compensation for already-measured rows when
+ `scrollDirection === 'backward'`, so drift can be zero at rest and large while
+ scrolling up. Test both.
+- Run `npm run test:electron:all` (all five suites), not only the suite you
+ added.
+- **Do not add `loading="lazy"` to virtualized rows.** Rows already mount near
+ the viewport; lazy only defers decode into the scroll itself.
+- **No hardcoded colors or type sizes inside shadow DOM.** Custom properties
+ pierce the shadow boundary — use `var(--muted)`, `var(--hairline-strong)`,
+ `var(--text-sm)`.
+- **One visual treatment per user-visible concept.** "Blocked source" and "failed
+ to load" are the same thing to a reader; they must not render two different
+ ways.
+- When you depend on a library's calling convention, either accept both shapes or
+ pin the assumption in code. A silent signature change that degrades every item
+ to a fallback is invisible to types and tests.
+- **Every renderer-side probe needs a deadline and an `error` → reject path.** A
+ promise with no rejection path plus a bare `await` turns a regression into a
+ hung CI job instead of a red one.
+- **No assertions with sub-pixel headroom.** Self-calibrate (e.g. take the median
+ gap of currently mounted rows as the baseline) instead of hardcoding a
+ threshold that a spacing tweak turns red.
+
+## Provider adapters
+
+- **Read `claude.ts`, `codex.ts`, and `kimi.ts` before writing a new adapter.**
+ The conventions there are earned: zero-padded ordinals in ids
+ (`parsing.ts` uses `padStart(6, '0')`), the
+ `___canonical_transcript_vN__` marker, how `git_branch` is handled.
+- **Session identity must not be the source id alone.** Use a composite such as
+ (normalized cwd, header id). Explicit session ids are usually project-local, so
+ two projects may legitimately collide — and the second one indexed will
+ overwrite the first.
+- **A test must actually call `discover()`.** Asserting the resolved root string
+ passes even when the directory-layout assumption is wrong.
+- **Verify directory layout against the upstream source or format docs**, not
+ against what your own machine happens to look like. A tool's default root and
+ its custom root often have different nesting.
+- **The canonical transcript invariant (ADR-0007) is a hard gate**: assembling
+ directly from your adapter must equal assembling after a SQLite round-trip. Any
+ design where duplicate ids merge or overwrite breaks it.
+- **Never drop a record just because it has no text.** Image-only messages and
+ aborted turns that carry usage must still emit a row (`text: null`,
+ `content_type: 'unknown'`), or token accounting and the timeline develop holes.
+- **Bump `indexVersionMarker` whenever you change uuid format, role
+ normalization, or anything else affecting already-stored rows.** Otherwise the
+ mtime short-circuit in discovery leaves old-format rows in the database
+ forever.
+- **Express "this should not be shown" with the existing `visibility` field**
+ (`providers/types.ts`), which is defined as provider-normalized display
+ eligibility and already has a consumer in the assembler. Do not add a third
+ meaning to `is_sidechain`.
+- **Cursors must detect same-millisecond rewrites**: mtime + ctime + size + inode,
+ not mtime alone. Reconcile moves, copies, deletes, and replacements.
+- **Version gates must tolerate the unknown.** Throwing on an unexpected higher
+ version makes one bad file trigger a full re-index every run, because the
+ provider's index markers are withheld while any unit fails. Skip and record
+ instead of poisoning the provider.
+
+## Schema and migrations
+
+- `schema.sql` is pinned by sha256 in `tests/provider-schema-stability.test.mjs`.
+ Changing it is an explicit decision plus a full re-index: justify it in the PR
+ and update the hash in the same commit. Prefer additive changes.
+- **Destructive DDL goes in one transaction.** The repository already has
+ `runWriteTransaction` (`packages/core/src/tx.ts`) and both SQLite adapters, and
+ the entry points already hold the writer lease — you do not need to invent a
+ migration marker.
+- **Do not use the name of the target state as the completion marker.** If the
+ process is interrupted after CREATE but before the rebuild finishes, comparing
+ the current setting against the requested one reports success forever and the
+ data is never backfilled.
+- **Validate to executability, not to lexical shape.** A regex that accepts a
+ string SQLite will reject means you drop the table and then fail.
+- **Before writing one value across every table, check whether any table carries
+ its own arguments.** Overwriting them leaves the migration looking complete,
+ so it never self-heals.
+- **Any external input spliced into DDL needs an allowlist and an injection test
+ case.**
+
+## Main process and untrusted input
+
+- **Transcript paths must not reach `shell.openPath` unguarded.** On macOS,
+ `.app` / `.command` / `.sh` are executed, not opened. Confirmation dialogs must
+ default to Cancel.
+- **Reading a file because a transcript said so requires an allowlist**, scoped to
+ the session's project cwd or known source roots.
+- **Do not decode untrusted text through `innerHTML` or a detached `