Read the doc for the area you are touching before you touch it. The table below is the whole index. These are plain paths, not imports: they are not in your context until you read them, and the rules in them are not optional.
| Working on | Read first |
|---|---|
Anything in apps/api — tRPC, auth, logging, sync, deletes, caching |
docs/api.md |
apps/agent — the eve research agent, tools, tasks, dispatch |
docs/agent.md |
.env, configuration, which variables exist and why |
docs/environment.md |
UI in apps/app or packages/ui |
docs/design.md (below) |
| Deal amounts, totals, charts, exchange rates | docs/currency.md |
| The record sheet's Agent tab | docs/agent-panel.md |
/settings/connections, integrations, the intake endpoint |
docs/connections.md |
| The tracking script, the collector, form submissions | docs/tracking.md |
| Running it locally, Google Cloud, DB commands, secrets | docs/setup.md |
| Anything that sends a telemetry event, or a new property on one | docs/telemetry.md |
.github/workflows, versions, changelog, how a change reaches release |
CONTRIBUTING.md |
Also check .agents/skills/ for a relevant skill before starting — better-auth,
prisma, nestjs-trpc, eve, shadcn, nuqs and others have one. Tell the user which
rules and skills you read.
- Never add code comments. Not to new code, not to code you edit.
- No coauthoring commits. No
Co-Authored-Bytrailer, ever. - Intelligence lives in
apps/agent, never in the API. No vendor client, no enrichment, no scoring, no identity matching in Nest — it writes anAgentTaskrow and lets the agent decide. Seedocs/api.md. - One
.env, at the repo root..env.exampleis its documentation: add every new variable there with a note on what it does, and declare it inapps/api/src/config/env.validation.tsif the API reads it. Never add a per-package.env. - Anything a self-hoster might not have is optional and must never throw. A
missing key removes a capability.
apps/agent/agent/lib/capabilities.tsis the pattern. /packages/uiis the single source of truth for UI. Shared shadcn components only; a new variant is implemented there, not overridden at the call site.- eve's own docs ship in
apps/agent/node_modules/eve/docsand match the installed version. Read the relevant guide before writing eve code rather than working from memory — guessing typechecks, builds, and then behaves differently.
Do not bury a known problem inside a paragraph. A problem inside prose is a problem nobody reads. Report every issue, including ones you caused, in a list at the end of your reply.
Write every message, every report and every issue in ASD-STE100 (Simplified Technical English):
- One idea per sentence. Maximum 20 words.
- Active voice. Present tense. No conditionals.
- One word for one meaning. Do not use synonyms for variety.
- Say the effect, not only the cause.
- No hedging: never "may", "might", "possibly", "somewhat".
Use exactly this shape:
## Issues
1. BROKEN — Slack is not connected. Agents that post to Slack fail.
Fix: connect Slack in Settings → Connections.
2. RISK — A run longer than 5 minutes is cancelled. Work is lost.
Fix: not done. Needs a separate execution lease.
3. NOT DONE — The manual run button shows on event-only agents.
Rules for the list:
- One line for the problem. One line for the fix.
- Start each with BROKEN, RISK, NOT DONE, or UNKNOWN.
- BROKEN is failing now. RISK fails later. NOT DONE is unbuilt. UNKNOWN is not investigated.
- If you introduced it, write I caused this on the fix line.
- Zero issues? Write
## IssuesthenNone.
Don't — bury it in prose:
The fix works well. One honest limit: abandoning a sweep unblocks the queue but doesn't cancel the underlying hung promise, so it leaks until restart.
Do — put it in the list:
- RISK — An abandoned sweep leaks its promise. Memory grows until restart. Fix: not done. Needs cancellation in
receive(). I caused this.
A client component must never import a server package. @crm/auth and @crm/db
are server packages: their barrels reach Prisma, which reaches pg, which
reaches dns. The bundler follows that chain into the browser and the build
fails with Module not found: Can't resolve 'dns'.
The import trace is the whole error. Read it from the bottom: the last line is the page, the line above is the client component that leaked, and the top is the Node module that cannot exist in a browser.
Don't — a client component reaching for a server package:
"use client";
import { describeSlackScopes, SLACK_SCOPE_GROUPS } from "@crm/auth";
export function SlackScopeGroups({ scopes }: { scopes: string[] }) {
const groups = SLACK_SCOPE_GROUPS.map(...)
}Do — the page does the work and hands over plain data:
// page.tsx — server
import { describeSlackScopes, SLACK_SCOPE_GROUPS } from "@crm/auth";
const groups = groupScopes(status.scopes);
return <SlackScopeGroups groups={groups} />;// slack-scope-groups.tsx — client
"use client";
export type ScopeGroup = { id: string; label: string; scopes: ScopeLine[] };
export function SlackScopeGroups({ groups }: { groups: ScopeGroup[] }) { … }Rules that follow:
- The client component owns its own prop types. It does not re-export a server type to get them.
- Anything interactive — an accordion, a dialog, a search field — is a client component that receives finished data. It never derives it.
- A
"use client"file may import from@crm/ui, the tRPC client, and React. Anything else needs checking. - The server page is where
awaitand secrets live. The client file has neither.
A number that someone will want to tune goes in a named config module for its area. It does not go at the top of whichever file happened to need it first. Somebody changing a timeout must not have to know which file to open.
Don't — one constant per file, found only by grep:
// dispatch.ts
const DRAIN_TIMEOUT_MS = 4 * 60_000;
// crm.ts
const STALE_QUEUE_MS = 5 * 60_000;
// tasks.ts
const LEASE_MS = 10 * 60_000;Do — one object, grouped by concern, imported where used:
// dispatch-config.ts
export const DISPATCH = {
sweep: { timeoutMs: 4 * MINUTE_MS, staleQueueMs: 5 * MINUTE_MS },
task: { leaseMs: 10 * MINUTE_MS },
} as const;apps/agent/agent/lib/dispatch-config.ts is the pattern. Rules:
- Group by concern, not by file that uses it.
- Derive units from one base (
MINUTE_MS). Never write4 * 60_000twice. as const, so the values are literal types.- No magic numbers inline. If it is tunable, it belongs in the config.
- One convention across the codebase. Do not invent a local style for one file.
Untyped data — a Prisma Json column, a webhook body, an API response — is
parsed into a domain type at the moment it enters the process, with Zod, in a
module that owns that shape. Every consumer downstream receives the parsed type
and nothing else. Record<string, unknown>, unknown casts and one-off
recordOf() helpers are how a shape becomes unknowable and a typo becomes a
runtime bug two files away.
Don't — reach into raw JSON, re-deriving the shape at each call site:
function manifestActions(value: unknown) {
const actions = recordOf(value).actions;
return Array.isArray(actions) ? actions.map(recordOf) : [];
}
const slack = manifestActions(version.manifest).find(
(action) => action.type === "slack.message.post",
);
const id = (slack?.destination as Record<string, unknown>)?.id;Nothing here is checked. destination may be missing, id may be a number, and
the compiler cannot help. Rename a field and every one of these silently returns
undefined.
Do — one schema, parsed once, at the read:
export const agentManifestAction = z.discriminatedUnion("type", [
z.object({
type: z.literal(AGENT_ACTION_TYPES.SLACK_MESSAGE_POST),
provider: z.literal("slack"),
summary: z.string(),
destination: z.object({
kind: z.enum(["channel", "user"]),
resolution: z.literal("chosen"),
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(120),
}),
}),
]);
export type AgentManifest = z.infer<typeof agentManifest>;
export function parseAgentManifest(value: unknown): AgentManifest { … }const manifest = parseAgentManifest(version.manifest);
const slack = manifest.actions.find(
(action) => action.type === "slack.message.post",
);
const id = slack?.destination.id;apps/agent/agent/lib/agent-manifest.ts is the pattern. Rules that follow from
it:
- The schema describes what is actually stored, not the loosest thing that parses. If a test fixture fails the schema, fix the fixture — a fixture that omits required fields is testing data that cannot exist.
- Parse failure is a real error with a real message. Do not swallow it into an empty array, because "unreadable manifest" and "no actions" are different problems and only one of them is the user's fault.
- Derive types with
z.infer. Never hand-write an interface beside a schema; they drift.
@docs/design.md
Median can use a project-local workspace binding. If this repository has
.median/config.json, run mdn commands from inside this repository so the
correct Median workspace profile is selected. The local config stores only a
profile name; API keys stay in your user config.
To bind this repository to a workspace:
mdn setup --local
Before starting work, check your assigned tasks:
mdn tasks --agent <your-agent-name>
When picking up a task:
mdn status <TASK-CODE> in_progress --agent <your-agent-name>
When completing a task:
mdn status <TASK-CODE> ready --agent <your-agent-name>
To create a new task:
mdn create --title "Description" --status todo --priority medium --agent <your-agent-name>
Always include the Median task ID in commit messages and PR titles so tasks get marked automatically.
git commit -m "MDN-42 fix: resolve auth token expiry"
For pull requests, include the task ID in the title:
MDN-42 fix: resolve auth token expiry