# CLAUDE.md Guidance for Claude Code working in this repo. Specs in `openspec/specs/` are the authoritative contract; this file is the must-know-fast index. ## Quick reference | Need | Command / path | | ---------------- | ---------------------------------------------------------------------------- | | Install | `pnpm install` (run `corepack enable` first) | | Typecheck / lint | `pnpm run typecheck` · `pnpm run lint` | | Tests | `pnpm test` (single file: `pnpm vitest run path/to/file.test.ts`) | | Dev server | `pnpm run dev` (Next.js dev server in `apps/web`; `docs/docker.md`) | | OpenSpec | `/opsx:propose` · `/opsx:explore` · `/opsx:apply` · `/opsx:archive` | | Spec provenance | `pnpm run check:spec-provenance` (only `origin/main...HEAD`; CI is the gate) | | Mutation check | `node scripts/mutate.mjs --file … --spec … --mutation … --with …` | Conventional Commits required (commitlint). Pre-commit = lint-staged + `tsc --noEmit --incremental`. Pre-push = `pnpm test`. Never bypass git hooks with `--no-verify`. PR titles and descriptions are always written in English (regardless of the language used in chat). Operator surface = dashboard (`/dashboard/{tokens,projects,sessions,judgments,memories,consolidation,maintenance}`). No operator CLI; Docker image runs the server only. ## Claims need evidence A finding is not a finding until it has been executed. Scale the evidence to the cost of being wrong: the higher the consequence, the less an argument from reading the code is worth. This generalises the standard `db-performance-auditor` already applies to queries — measure the alternative, don't assume it. - **Never publish what you have not run.** Issue comments, security advisories, PR descriptions and spec text are outward-facing; a wrong claim there has to be retracted in public. - **A subagent's finding is a hypothesis.** Re-verify before acting on it or repeating it — subagents report plausible readings as results. - **Probe the boundary the real caller uses, and include a control that must pass.** A probe that skips a layer proves nothing about it (calling an MCP handler directly bypasses its zod schema, so "the tool accepts X" measured that way is not a fact about the tool). With only a failing case you cannot tell a real defect from a broken probe; the control is what tells you which you have. - **Classify from behaviour, not from the symptom's shape.** "The observable result changes" makes something a behaviour change; whether it is a _defect_ depends on whether the current behaviour is defensible — same evidence bar as anything else. - **A new guard is not covered until its test fails without it.** Weaken each condition separately and confirm the tests naming it go red — `scripts/mutate.mjs` does the backup/mutate/run/restore loop. Three tests in one session passed while proving nothing (one asserted over an empty result set; two short-circuited before the condition they named), and only mutation found them. A test green on both sides of the change is the default outcome, not the exception. - **One instrument per series, named.** Never present an isolated statement's timing and an end-to-end operation's timing as one table: a 39× statement speedup was a 2.5× user-facing one, and the ratio came from silently switching instruments between rows. State which you measured, and quote the end-to-end figure when a user waits on it. Where a claim is about spec text rather than runtime, the evidence is the verbatim quote with `file:line`, never a paraphrase. ## Architecture Single Node process, single SQLite file. `apps/web` is the Next.js App Router app that owns every HTTP surface — `/mcp`, `/api`, `/healthz`, OAuth and the dashboard; the extracted packages own the rest — `packages/db` is the SQL boundary (schema, migrations, repositories, client, migrate, diagnostics, query tokenizer, scope), `packages/core` holds services, consolidation, embeddings and summary, and `packages/mcp` is the protocol domain (tool definitions, server factory, scope resolution). Shared plugin tree at `apps/plugin/` ships to FIVE clients (Claude Code, Codex CLI, Hermes Agent, opencode, Pi) — see [Plugin development](#plugin-development-discipline). Pi is the odd one out: it has no built-in MCP client (intentionally, per its own docs), so `.pi-plugin/` speaks Streamable HTTP MCP itself and discovers its tools with `tools/list` instead of enumerating them. Monorepo uses pnpm workspaces with `apps/*` (deliverables) and `packages/*` (shared libraries — `db` and `core` carry the extracted code, `config` is dev-only tooling, `ui` is a scaffold with no dependents). Turborepo owns `build`, `typecheck` and `test` via `turbo.json`; `lint`, `format:check`, `test:coverage`, `eval` and `dev` stay direct scripts. ### Load-bearing invariants (do NOT violate without an OpenSpec change) - **Append-only memory.** Rows never `DELETE`d (narrow purge exceptions in `packages/core/src/services/{memory,agent-sessions}.ts` and `apps/web/src/scripts/seed-dev.ts`, allow-listed in `apps/web/src/test/invariants.test.ts`). `content` never `UPDATE`d. Lifecycle = `status` flips (`active` → `superseded` | `archived`) plus `replaces` links. Every consolidation op journaled in `consolidation_ops`, reversible. - **Scope enforced at service layer.** Every `MemoryService` query filters by `Scope`. Cross-scope reads return `not_found`. New MCP tools that need project scope MUST obtain it from `resolveEffectiveScope` (`packages/mcp/src/_shared.ts`), which consults both `ctx.project` (URL slug) and `SessionRouter` (`project.use` pins, roots discovery) — never read `ctx.project` in isolation, and never construct the default project's scope in a handler. - **Convergent topics via `topic_key`.** `saveWithTopicKey` atomically supersedes the previously-active row in the same `(scope, project_id, topic_key)`. - **Fresh-context judgment.** Conflicts surface in `memory.save.candidates[]`; closed by `memory.judge_batch`. Aged pendings re-surface in `memory.context.pendingJudgments[]`. The consolidation sweep is deterministic (decay + deadline orphaning, no LLM, no cron — runs throttled on session start). - **Review state is derived, never stored.** Two orthogonal staleness axes: _decay_ (access + confidence, keyed on `last_seen_at` — the sweep archives) and _review_ (affirmation, keyed on `max(created_at, last confirmation) + per-type TTL` in `packages/core/src/services/review.ts`). `reviewState`/`needsReview` are computed at read time in `MemoryService`/repository reads — no column, no sweep, no new mutation verb. Re-affirming a `needs_review` memory is the existing `memory.confirm_batch` (append-only event); reading it (`last_seen_at`) deliberately does NOT clear it. Path-scoping contract (in `packages/mcp/src/_shared.ts::resolveEffectiveScope`): every connection resolves to exactly one project and no tool argument can name another. `/mcp/` is fixed to that project, and a slug naming no project is refused with `project_not_found` rather than falling back. `/mcp` resolves through the `SessionRouter` entry (a `project.use` pin or roots discovery) and otherwise the `is_default` project. `scope_locked` refuses a _switch_, never a scope. Tool input schemas are strict, so an unknown property is refused rather than silently stripped. ### Data access pattern - ALL SQL (Drizzle builder or raw) lives under `packages/db/src/`: one repository per aggregate in `packages/db/src/repositories/`, DB-level introspection (PRAGMA/dbstat/VACUUM/ping) in `packages/db/src/diagnostics.ts`, schema and migrations in `packages/db/src/{schema,migrations}/`. Services orchestrate (scope resolution, validation, transactions); dashboard handlers call `admin*` repository reads + service mutations. No SQL in services/dashboard/mcp/server — grep-enforced by `invariants.test.ts` (data-access confinement). - **Scope still resolved at the service layer.** Services compute the `Scope` (`resolveEffectiveScope`, or `resolveEffectiveScopeOrNull` for the recovery tools exempt from `project_not_found`) and pass it down; scoped repository methods _require_ it as a parameter. Unscoped reads carry the `admin*` prefix and are callable ONLY from `src/dashboard/` (+ the dashboard router) — grep-enforced. Cross-scope service reads keep the `unsafe*` prefix. - Services own `db.transaction()`; repositories never open transactions (single synchronous connection means repo calls inside a service tx participate automatically). - Never hand-write row/DTO shapes. Derive from schema types: `$inferSelect`/`$inferInsert` for entities, `Pick & { … }` for join projections, schema-derived aliases (`MemoryStatus`, `RelationKind`) for filter params. ### Table-rebuild migrations (SQLite) SQLite has no `ALTER TABLE … ADD CONSTRAINT`, `ALTER COLUMN`, or change-nullability. To add a `CHECK`, change a type, or flip NOT NULL you have to do the rebuild dance (`CREATE TABLE x_new (…)` → `INSERT … SELECT *` → `DROP TABLE x` → `ALTER TABLE x_new RENAME TO x` → recreate indexes/triggers). With `foreign_keys = ON` (the default set by `packages/db/src/client.ts`), `DROP TABLE` on a parent of any populated child table fails with `FOREIGN KEY constraint failed`. `PRAGMA foreign_keys` cannot be changed inside a transaction, and `PRAGMA defer_foreign_keys` does **not** defer the DROP-TABLE check (it only defers per-row FK violations). The migration runner (`packages/db/src/migrate.ts`) therefore wraps every migration in `PRAGMA foreign_keys = OFF` → `BEGIN IMMEDIATE` → migration body → `PRAGMA foreign_key_check` (pre-commit gate) → `COMMIT` → `PRAGMA foreign_keys = ON`. Migration authors do not need to add any pragma. The integrity gate runs `foreign_key_check` before COMMIT and aborts the transaction on any dangling reference, so disabling FKs around the body is safe. Enforced by `apps/web/src/test/invariants.test.ts::"migration runner FK-safety invariant"`. ## OpenSpec workflow Behavioral changes are spec-driven. Specs in `openspec/specs//`; active proposals in `openspec/changes//`; archived in `openspec/changes/archive/`. **Before changing a load-bearing invariant or adding a new MCP tool, open an OpenSpec change first.** ## Dashboard conventions - **Timestamps go through the `