Single source of truth for how this repo is built. Plans should link here, not re-paste
conventions. Background: SUMMARY.md (build loop + decisions), docs/PRD.md, docs/CONTEXT.md
(domain glossary — name code after these terms), .agents/plans/ (per-milestone plans),
.agents/system-reviews/ (process retrospectives).
npm workspaces, all strict TS, Node ≥ 24:
packages/shared— token shape, event taxonomy, fingerprint, pricing, cost, ingest wire typespackages/db— Drizzle Postgres schema + migrations, AES-256-GCM field encryption, repositoriesapps/ingest— Fastify Ingest API (pairing, bearer-authed idempotent ingest, health)apps/collector— headless capture agent (parser, durable queue, watcher, sync, CLI)apps/dashboard— Next.js + shadcn/theGridCN frontend (M9 Live Monitor). Out of the roottsc -bgraph — see "Frontend workspace" below.
- ESM,
"type": "module",module/moduleResolutionNodeNext,verbatimModuleSyntax. - Relative imports end in
.js. Useimport typefor type-only imports. kebab-case.tsfiles,PascalCasetypes,camelCasefunctions,snake_caseSQL columns.- Strict mode across all workspaces; the four backend workspaces' root
tsc -bmust stay at 0 errors (the dashboard typechecks via its own enforced lane — see "Frontend workspace").
- Event fingerprint (
packages/shared/src/fingerprint.ts) and the normalized token/event shapes. They are the load-bearing dedup/idempotency keys (PRD §12, §23). Reordering fields or changing the delimiter silently breaks dedup across parser versions. - "Raw records sacred / events disposable" — raw payloads are immutable (insert-once); events are re-derivable and upsert by fingerprint.
- The M2 ingest wire types and server contract — the collector produces these shapes; M3+ feed them through the existing ingest client/API. No new server code or Postgres tables were added in M3.
A frontend stays out of the root tsc -b graph — it needs moduleResolution: bundler + jsx,
incompatible with the root NodeNext/composite graph, so its tsconfig.json is not referenced by
the root tsconfig.json (mirrors how *.int.test.ts are excluded). Consequence: root tsc -b will
NEVER catch dashboard type errors. It therefore gets its own enforced lanes, wired into the gate
(not just a convention): typecheck:dashboard (tsc --noEmit) runs inside repo-health, and
build:dashboard (next build, which also catches theGridCN barrel breakage) gates milestone sign-off.
- In automated execution, hand-write shadcn primitives (
card/table/badge/cn/globals.css) rather than runningnpx shadcn init— the CLI mutatestsconfig/globals.css/components.jsonand can prompt. Reserve the CLI for registry-only components (e.g.@thegridcn/data-card), and build-verify every add (the@thegridcn/hudbarrel ships broken — missing siblings). - The browser never holds
ADMIN_TOKEN. It talks to ingest only through same-origin proxy Route Handlers that readADMIN_TOKEN/INGEST_URLfrom server env and add the bearer on the server→ingest hop. Never expose the token via aNEXT_PUBLIC_*var (assert: 0 occurrences in served HTML).next dev/next buildload env from the dashboard CWD, not the repo root — passADMIN_TOKEN/INGEST_URLinline (or viaapps/dashboard/.env.local) when running it standalone. - For any long-lived resource (SSE stream,
setInterval, listener, upstreamfetch): arm its teardown BEFORE the firstawait(a disconnect during the initial await firesclosebefore a later-attached listener exists → leaked timer), and passrequest.signalto proxyfetchso the upstream hop cancels with the client.tsc+tests do not catch these leak windows —/lril:code-reviewdoes (it found exactly this class in M9).
Library files never write to stdout/stderr or call process.exit. Only entrypoints
(apps/collector/src/cli.ts, apps/ingest/src/server.ts) log, read argv, handle signals, and exit.
Libraries throw typed errors (e.g. NotPairedError, IngestHttpError); the entrypoint catches and
prints. Daemons take an optional logger callback wired by the entrypoint.
~/.420ai/ is the collector home: credentials.json (M2 pairing) + queue.sqlite (M3 durable queue
- per-file cursors). It lives outside the repo and is never committed (
*.sqliteis gitignored).
- Co-located vitest:
*.test.ts(no infra — always run) beside the code. - Integration:
*.int.test.tswithdescribe.skipIf(!process.env.DATABASE_URL_TEST)sonpm testpasses with no Docker; they reuse the real server in-process (buildApp). *.int.test.tsimport across app boundaries, so they are excluded fromtsc -b(seeapps/collector/tsconfig.json) and are type-stripped by vitest/esbuild instead.- Inject clocks/dependencies for determinism (e.g.
QueueStore(path, now),syncOnce({ post })). - Workspaces have NO per-workspace
testscript — only the root definestest(vitest run). For a focused run usenpx vitest run <path>from the repo root;npm test -w <pkg>fails withMissing script: "test".
Before any commit, npm run repo-health must pass. It is the enforced gate and runs:
- Root
tsc -b(npm run typecheck) — must exit 0. Per-workspacebuildis NOT a substitute; it misses cross-project/test-only imports (this is how a broken typecheck shipped through M2). - Full
vitest run— units always; integration self-skips withoutDATABASE_URL_TEST. - NUL-byte scan of tracked text sources — a source file written with embedded NULs passes typecheck + tests (the compiler tolerates NULs in comments) yet is corrupt; this catches it.
- Stray-artifact scan — no emitted
*.js/*.d.ts/*.mapunder anysrc/, nodist/or*.sqlitestaged.
A pre-commit hook (.githooks/pre-commit, enabled via git config core.hooksPath .githooks) runs
the fast subset (typecheck + NUL + artifact scans) automatically.
Integration tests self-skip without DATABASE_URL_TEST (which lives in gitignored .env), and a
skipped layer still reports green — skipped ≠ passed. A plain repo-health PASS does NOT prove the
DB-backed layer ran. Before signing off ANY milestone that touches @420ai/db or apps/ingest, run
npm run db:up && npm run db:migrate and then npm run repo-health -- --require-db, which FAILS if
DATABASE_URL_TEST is unconfigured or if any *.int.test.ts self-skipped (it asserts the int tests
actually ran, 0 skipped). This is the gap that hid the M5 lastActivity type bug through M5 sign-off —
the int test asserting it could never have passed against a real DB, so the layer was never exercised.
- The Bash tool is Git Bash (POSIX sh). For multi-line commit messages / PR bodies use a
heredoc (
<<'EOF' ... EOF), not PowerShell here-strings (@'...'@) — the latter injects literal@characters into the text. A quoted heredoc also eats\\; for content with regex backslashes, write the file with the Write/Edit tool instead ofcat. - An auto-push may carry a commit to
originbefore you push manually. If you then amend, expect a non-fast-forward; resolve withgit push --force-with-leaseguarded on the expected sha (only ever on your own unmerged feature branch). node:sqliteis experimental in Node 24 and prints anExperimentalWarningon import by design — do not suppress it in a way that breaks tests.- The gstack
browse/agent-browserdaemon is unreliable here (EEXIST .gstack, start-timeout). For screenshot evidence use headless Edge directly:"$EDGE" --headless=new --disable-gpu --hide-scrollbars --screenshot="<abs>.png" <url>($EDGE = /c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe). Pair it with HTTP-layer assertions (rendered HTML contains the expected data;grep -c "$ADMIN_TOKEN"on page source == 0).
- In a raw
sqltemplate a column'smode:"string"parser does NOT apply —max(ts)/min(ts)/date_trunc(...)over amode:"string"timestamptz come back as Postgres text (2026-06-14 11:59:00+00), NOT ISO and NOTDate. Type thesql<...>result asstringAND normalize throughnew Date(v).toISOString()if the wire contract is ISO. This shipped as the latent M5projectEventSummary.lastActivitybug and recurred in M9activeSessions— so when writing illustrative aggregate SQL in a PLAN, always show the normalization; never write "already ISO — do not re-coerce" for an aggregate. node-postgres also returnsnumericas a string (wrap inNumber(...)) but::intas a JS number — cast token/count sums::int, money::numeric+Number(). - Inline closed-set SQL keywords (e.g.
date_truncgranularity'day'|'week') as raw literals viasql.rawfrom a guarded union — never as a bound parameter. A bound param makes Postgres treat the SELECT and GROUP BY/ORDER BY expressions as distinct and reject the query (column ... must appear in the GROUP BY clause). - A
GROUP BY <col>over the full event stream collapses rows with a NULL<col>into a phantom group; restrict the WHERE to the relevantevent_types when a null-keyed all-zero row would be noise (e.g.usageByModelfilters tousage.reported/cost.estimated). - A guard sufficient for a READ is insufficient for a WRITE that adds an FK. The M6 projection reads
return 200-zeros for an unknown project uuid (
isUuid → 404only screens malformed ids, never inserts). An M7-style write whose row carries a FK (report_artifacts.project_id → projects.id) turns a well-formed-but-nonexistent id into an FK-violation 500 at insert. Guard write paths with an existence check (e.g.getProjectName(id)undefined → 404), not justisUuid, to preserve the repo-wide "unknown id → 404, never a DB-constraint/cast 500" invariant.