CONTRIBUTING.md
Rama main · réplica de lectura del repositorio original.
# Contributing to Titration
Titration is a self-hosted MCP "referee + memory" for AI coding agents. This
file covers dev setup, the gates a PR must pass, the code rules the codebase
follows, and how to propose a new curated learning.
## Dev setup
Requirements: Node >= 22.11 (see `engines` in `package.json`) and Docker (for
a disposable local Postgres + pgvector).
```bash
git clone https://github.com/kaithoughtarchitect/titration.git
cd titration
npm install
cp .env.example .env # fill in TITRATION_DATABASE_URL if you change the default
docker compose up -d # starts pgvector/pgvector:pg17 on localhost:5432
npm run setup # migrate -> ingest the curated base -> embed (if OPENROUTER_API_KEY is set)
```
## Gates — run before every PR
| Command | What it proves |
| --- | --- |
| `npm run typecheck` | `tsc --noEmit` over `lib/`, `server/`, `ingest/`, `query/`, `scripts/`, `retrieval-eval/` (`moduleResolution: bundler` — see `tsconfig.json`; do not switch this to `nodenext`, it breaks the codebase's import style). |
| `npm test` | The offline unit suite (`lib/__tests__/*.test.ts`, run one-by-one under `tsx` by `run-tests.mjs`; each file drives a small hand-rolled `check(name, condition, detail)` harness, not Jest/Vitest) plus the import-graph check (`scripts/import-graph-check.mjs`), which walks every relative import reachable from the entry points and fails on a missing, outside-tree, or denied edge. |
| `npx tsx scripts/smoke-schema.ts` | Live DB smoke against a real `TITRATION_DATABASE_URL`: applying the migration twice is a no-op, both seed tenants exist, a card round-trips with a `NULL` embedding. |
| `npx tsx scripts/smoke-projects.ts` | Live DB smoke for project(tenant)-scoped reads/writes and the base-merge read path. |
| `npx tsx scripts/smoke-evolution.ts` | Live DB smoke for local evolution-capture (change-note) writes. |
| `npx tsx scripts/smoke-jobs.ts` | Live DB smoke for local job execution (start, poll, fail, cleanup). |
| `npm run retrieval:eval` | The retrieval-quality regression gate over `cardSearch` — recall@k / MRR against the frozen `retrieval-eval/base-queries.json` answer key. Needs `OPENROUTER_API_KEY` (it embeds the query set); if you don't have a key, say so in the PR and a maintainer will run it. |
The four `smoke-*.ts` scripts are **not** part of `npm test` — they need a real
Postgres, so run them directly against the compose database after
`npm run migrate` (or `npm run setup`) has applied the schema. `smoke-projects.ts`,
`smoke-evolution.ts`, and `smoke-jobs.ts` remove `OPENROUTER_API_KEY` from
their own process before they start, even when `.env` sets it: every path they
exercise is embedding-free by construction, and without a key a stray provider
call fails the smoke instead of spending.
Run every gate with full output. Never pipe a typecheck or test run through
`| tail` — the first error is the one that matters.
## Code rules
- **Pure-core / I-O split.** `lib/X-core.ts` holds pure logic only — no DB, no
network, no filesystem — and gets an offline unit test in
`lib/__tests__/`. `lib/X.ts` holds the store/embed/judge I/O that calls into
it. Mirror this split for new modules: don't put I/O in a `-core.ts` file,
and don't put logic that should be unit-testable in a plain `.ts` file that
can't run offline.
- **No `Date.now()` / `Math.random()` inside a `*-core.ts` file.** Pass `now`
and any seed in as an argument from the caller. A core that reaches for
wall-clock time or entropy directly can't be tested deterministically
offline.
- **postgres-js traps** (this project talks to Postgres through the
`postgres` package directly, not an ORM):
- Vectors: `toVec(arr)` + `` `${qv}::vector` `` — never bind a raw JS array
as a vector parameter.
- jsonb writes: `` `${sql.json(obj)}` `` — never
`` `${JSON.stringify(obj)}::jsonb` `` (that double-encodes to a string).
- jsonb reads: wrap in a defensive try/catch; if a row comes back as a
string instead of an object, parse it carefully and fail open to the raw
string — a bare `JSON.parse` inside a list mapper takes down the whole
list on one bad row.
- UUID columns (e.g. `from_card_id` / `to_card_id`): never bind a JS array
directly to one — fetch the candidates and filter in JS instead.
## Grading laws — do not weaken these without discussion first
The verdict engine (`verify`, `goal_titrate`, `flywheel`) exists to referee an
AI agent's work, so its own honesty is the product. A PR touching
`lib/consensus.ts`, `lib/judge.ts`, `lib/verify.ts`, `lib/goal-titrate.ts`, or
`lib/flywheel*.ts` must preserve these invariants:
- **Verdict inputs are the declared outputs plus the frozen rubric, and
nothing else.** Grading never reads back the pushed prompt, trace, or
corpus case — only the fields the caller actually declared as outputs.
Richer context can be surfaced to a human as evidence; it must never be fed
into the grading call itself.
- **Knowledge-graph cards are advisory-only.** They may be shown to a human
next to a verdict; they must never enter a grading prompt or change a
numeric verdict.
- **A numeric verdict needs at least two judges from distinct vendor
*families* — never merely two judges.** Two judges from the same family
(e.g. two models behind the same vendor) don't corroborate each other.
`lib/consensus.ts`'s `distinctFamilies()` is the one place that counts this
correctly; route any new call site through it rather than counting
`judges.length`.
- **The agent whose work is being graded never sits on the panel judging
it.** The panel excludes the family doing the work under test.
- **The retrieval gate's answer key never shrinks to raise a number.**
`retrieval-eval/` scores `cardSearch` against a frozen query set
(`base-queries.json`); every published recall@k/MRR figure is a mean over
`n`, and dropping a query the retriever gets wrong reports an improvement
while measuring less. `n` may not fall below 20 on a re-freeze —
`TITRATION_RETRIEVAL_ALLOW_SHRINK=1` exists to record a deliberate,
documented exception, not to wave one through silently. See
`retrieval-eval/README.md`.
If a change to any of the above looks necessary, open an issue first — these
properties are what makes a Titration verdict worth trusting, and changes to
them get discussed, not merged on green CI alone.
## How to contribute a learning
`docs/core-learnings/` is Titration's own curated knowledge graph about
running Titration well (see `docs/core-learnings/README.md` for the curation
rule — domain-independent principles only). To propose a new card:
1. Add a `## T-XXX-NNN — title` section to the relevant
`docs/core-learnings/CORE-LEARNINGS-v0.N.md` file (or start the next
`v0.N+1` file if the existing ones are full), following the section format
already used by the neighboring cards in that file.
2. Before opening the PR, run `npm run ingest:base` against your local
database and then `npm run retrieval:eval` — a new or edited card must not
regress recall/MRR on the frozen query set. If you add a query of your own
to cover the new card, read the anti-leakage rule in
`retrieval-eval/README.md` first: a query that quotes the card's own
title/body measures string overlap, not retrieval.
3. Open the PR. A maintainer reads every proposed learning by hand before
merge — this is curated content, not a bulk import, and no automated check
substitutes for that review.
## Response time
Titration is maintained by one person. Reviews may take a week or more —
a quiet PR is not a rejection.
## Sign-off
No DCO / `Signed-off-by` line is required. Opening a PR against this repo is
taken as agreement to license your contribution under this project's Apache
License, Version 2.0 (see `LICENSE`).