Imported from memcastle/memcastle (
AGENTS.md). Install upstream withnpx skills add memcastle/memcastle. Copyright stays with the author.
AGENTS.md
Notes for anyone — human or otherwise — changing this repository.
What this project is
MemCastle is a long-running memory server, not a CLI process that happens to expose MCP.
One daemon serves one palace; memcastle serve is the only command that does real work locally
(daemon start just spawns it in the background) —
every other subcommand (status, search, mine, job ...) is a thin HTTP client to that daemon,
so a web UI could do everything the CLI does by calling the same API.
The one exception is memcastle migrate, which touches storage directly because it must work before a daemon
exists (see invariant 1).
Storage is unified in SurrealDB (embedded SurrealKV for local dev, remote for server deployments) —
never a second datastore, never a separate vector index file to fall out of sync.
See docs/architecture.md for the full rationale, including what this deliberately does not do yet.
Non-negotiable invariants
Each of these should be enforced by a hook or a test. An invariant nothing checks is a comment, and it will be violated.
-
The CLI has no business logic MCP/HTTP can't reuse — every subcommand except
serve/migrateonly callsclient::DaemonClient, neverstoreorjobsdirectly (completionscalls neither: it prints a script locally). (daemon startanddaemon restartalso manage the daemon process — they read the registry file viaserver::lifecycleand spawnservedetached — but touch neitherstorenorjobs.source init,build,test,package,indexandkeygenwork on a project directory or on archives with no daemon at all, throughcrate::source, whichtests/source_isolation.rsholds to touching neitherstorenorjobsnor the network, andsource installof a file or a project directory reads it locally to show its permissions before calling the daemon.plugin inspect,packageandsignare similarly local publisher tooling over a manifest or validated archive; they touch neither store, jobs nor a daemon.plugin testspawns a disposable daemon and uses its existing HTTP client to preview, install and enable sources in an isolated palace, never the configured palace or agents.integration list,install,updateandremovework on files and the agent's own commands with no daemon at all, throughcrate::integration, whichtests/integration_isolation.rsholds to touching neitherstore,jobs, the client nor the network, and which nothing butmain.rsmay call to install one, so there is no MCP tool and no route that installs one.plugin::packagemay parse the pure integration manifest in a plugin archive but never call the installer (seedocs/adr/034-agent-integration-distribution.mdandtests/integration_isolation.rs).miner(list, get, set, enable, disable, remove, reload, run) calls onlyclient::DaemonClient, like every other daemon command: the rules for a miner live inapp::miners(seedocs/adr/037-persistent-miner-configuration.md).trigger(list, get, set, enable, disable, remove, reload, fire) is the same, with its rules inapp::triggers(seedocs/adr/043-source-triggers.md).source search,install <name>andupdatenever fetch anything themselves: the daemon does, throughcrate::distribution, and the CLI calls it over REST.source authnever talks to a provider or keeps a token itself: the daemon runs the OAuth flow throughcrate::credential, and the CLI calls it over REST, shows the code or address, and waits (seedocs/adr/039-oauth-credentials-for-mining-sources.md).notealso reads the project directory throughcrate::projectto choose a wing and room, then calls the daemon.doctorchecks configuration and local prerequisites without a daemon and reads runtime state throughclient::DaemonClientwhen one is available; it never opens storage or changes files.)migrateis a second, narrow exception alongsideserve: it connects to storage directly (viacrate::migrate::run/status, the same runnerservecalls on every startup) because migration must work without, and before, a daemon exists — seedocs/adr/004-versioned-database-migrations.md. Enforced by theprekarchitecturehook'sstore-isolationast-grep rules: they inspectmain.rs,cli.rs,client/,mcp/andapi/for anycrate::/memcastle::storeorjobspath, in code or in a string, allowing onlymain.rs'sSurrealStoreimport formigrate. -
Job status only changes through
domain::Job::apply— no other code assignsjob.statusdirectly. Enforced bydomain::job's table-driven test over every (status, event) pair, including the rejected ones, and by the prekarchitecturehook'sjob-status-only-via-applyrule, which fails on any.status =assignment outsidedomain/job.rs. -
The job queue is durable, the scheduler is only the execution mechanism — a job's state survives a daemon restart. Enforced by
jobs::Scheduler::recover, its per-state unit tests injobs::tests, andtests/persistence.rs(SIGKILL a daemon mid-job, restart, the job resumes). -
One daemon per embedded palace, one writer —
storeis only ever constructed byserver::runor themigrateCLI command (the same second exception as (1)); nothing else opens the embedded SurrealKV path directly. SurrealKV's file lock enforces the single writer for an embedded palace. A remote palace may be shared by several daemons; job leases and fencing keep that safe (seedocs/adr/006-job-leases.md). Enforced by the prekarchitecturehook'ssingle-writerrule:SurrealStore::connectand thesurrealkv:endpoint may only appear underserver/,store/and inmain.rs. -
Schema management is SurrealKit's, not MemCastle's —
database/schema/*.surqlis applied through SurrealKit'sSync/embed_schema!()(store::mod), never a hand-rolled schema-diff/versioning engine. MemCastle owns only its own application data-migration steps and version watermark (crate::migrate,store::migration_state) — seedocs/adr/004-versioned-database-migrations.md. Enforced by the prekarchitecturehook'sno-hand-rolled-ddlrule: noDEFINE/REMOVE/ALTERDDL orINFO FORstatement may appear in Rust code undersrc/(comments excepted). -
Every route but
GET /api/healthpasses the authentication layer, and MCP never touches credentials — the layer wraps the merged router inserver::run, so a route added later is guarded by default (GET /api/events, the server-sent change stream, is one: it is read with a header, never a token in a URL, and its events carry identifiers and never content, seedocs/adr/041-server-sent-events-for-dashboard-updates.md), and token generation and revocation are REST/CLI operations with no MCP tool. The same holds for a mining source's OAuth sign-in: the browser flow's redirect lands on a one-shot loopback listener outside the router, never on a public route, and no answer, log, file or database row but the credential store holds a token (seedocs/adr/039-oauth-credentials-for-mining-sources.md). The one other exception is the web UI's static files:GET/HEADof/uiand below, only whenweb.enableis set, named inapi::auth::is_publicand nowhere else (seedocs/adr/035-web-ui.md). A token or secret is never logged, serialised or persisted in plaintext (config::Secretredacts it; the store holds only a digest) — seedocs/adr/014-optional-token-authentication.md. Enforced bytests/in_process/auth.rs(every route and an unknown path refused without a token,/uirefused while the dashboard is off, no credential-named MCP tool),tests/credential_isolation.rs(the OAuth credential module reaches neither the palace nor the jobs nor the runtime, nothing outsideappand the daemon's root names it, and a token is read only where it is stored or sent to its provider),tests/in_process/web.rs(with the dashboard on, only/uiis open:/uix, every/apiroute and/mcpare still refused) andtests/auth_lifecycle.rs(no token in the log, the database or any file). -
The database admin endpoint is opt-in, loopback by default, and runs on the daemon's own handle — only an explicit
memcastle db start(REST/api/db) opens it,servenever does, and it never binds beyond loopback without--allow-remoteand authentication. It serves each connection from a clone of the daemon'sSurreal<Any>, never a second connection or process, and has no MCP tool — seedocs/adr/015-database-admin-endpoint.md. Enforced bytests/in_process/db_endpoint.rs(a started daemon has no endpoint, unsafe binds are refused, foreign origins are refused, the endpoint sees and shares the daemon's data), bytests/in_process/auth.rs(/api/dbis guarded, no MCP tool mentions the database) and bytests/auth_lifecycle.rs(no token in the log or any file). -
An integration is lifecycle glue over MCP and HTTP, and nothing else — everything under
integrations/decides when to call MemCastle and never reaches storage, the job code or the database admin endpoint; full lifecycle integrations satisfy the same conformance matrix, while limited clients document unsupported lifecycle hooks and manual fallbacks (docs/integration-contract.md,docs/adr/019-shared-integration-contract.md). Client lifecycle logic does not move into MemCastle to make two clients look alike. Enforced by the prekarchitecturehook'sintegrations-http-only-*and sharedclients-http-only-*rules: they fail onsurrealdb,surrealkv,SurrealStore, astore/jobspath or/api/dbin supported source files underintegrations/(Markdown andnode_modulesexcepted), and bytests/in_process/integration_contract.rs, which replaystests/fixtures/integration/against a real daemon and fails when the contract page, the capability manifest and the test names disagree. -
Source-specific code stays out of the mining pipeline, and adapters stay out of storage — everything that differs between mining sources (discovery, reading, normalizing) lives in one adapter under
src/mining/adapters/, and the pipeline, the chunker and the domain model know no source by name and read no file. An adapter never touches the store or the jobs, and only the pipeline writes a source's cursor and document records, after the drawers they describe (seedocs/adr/023-unified-source-model-for-mining.md). A source is built in or an installed WebAssembly component (src/mining/wasm/, the only module that names the runtime), behind the same contract, and the pipeline cannot tell which (seedocs/adr/026-pluggable-source-adapters-as-webassembly-components.md). Sources arrive from a registry throughsrc/distribution/, which onlyappcalls (andconfig, only to parse a location at load), which touches neither the store, the jobs nor the runtime, and which returns bytes that have passed the index's SHA-256 and the trust policy (seedocs/adr/033-source-distribution.md). A source that ships with MemCastle arrives from nowhere: it is unpacked beside the binary, installed from the start, and read in place bysrc/mining/bundled.rs, which touches neither the store, the jobs nor the network (seedocs/adr/040-bundled-sources-are-installed-from-the-start-and-the-official-registry-is-published.md). Entity extraction is the stage after: it reads drawers, names no source, and only adds graph records, never writing a drawer (seedocs/adr/024-entity-extraction-as-an-enrich-job.md). Enforced bytests/source_isolation.rs, which fails on an adapter name or file access inpipeline.rs,chunk.rsoradapter.rs, on an adapter reachingstoreorjobs, on any other module calling the source writers, and onsrc/extractnaming a source or calling a drawer writer. Deduplication is called by the pipeline for every chunk it stores, sosrc/dedupobeys the same rule: it names no source, reads no file and writes no source record, and the matching policy indomain/fingerprint.rsanddomain/resolution.rsstays pure (seedocs/adr/025-memory-deduplication-and-entity-resolution.md). The host, the adapters andsrc/source/reachingstoreorjobs, and any module butsrc/mining/wasm/naming the runtime, fail the same test. -
An installed source has no ambient authority, and installing one is administrative — a WebAssembly source starts with no filesystem, environment, network or programs, and the host grants exactly what its manifest lists and the user consented to at install (a digest of the name and the permissions), read-only for files, with a memory ceiling and a time limit. Its component is checked against the digest recorded at install on every load, and
unavailableis computed, never stored. Install, enable, disable and remove are REST and CLI only, with no MCP tool, so an agent cannot install code or widen its own reach (seedocs/adr/026-pluggable-source-adapters-as-webassembly-components.md). The same holds for searching, installing from and updating from a registry, which make the daemon fetch code:mining.registriesis the official registry by default and is read only when a user runs one of those commands, never at startup, a package must match its index's SHA-256 (for an entry naming a GitHub repository, the digest GitHub reports for the release asset; theGH_TOKENthat lifts its rate limit goes to that request alone), pass the trust policy and carry the name and version the index lists, and an update that asks for permissions the installed version did not is never installed without consent (seedocs/adr/033-source-distribution.md). A source that ships with MemCastle is the one exception to consent and trust, because it is the release itself: enabling it needs no digest, it is never fetched, installed, updated or removed through a registry (memcastle::source::bundled), and enabling it is still REST and CLI only (seedocs/adr/040-bundled-sources-are-installed-from-the-start-and-the-official-registry-is-published.md). A source that signs in with OAuth declares it as a permission (a public client and endpoints, part of the consent digest) and is given an access token only byhost.access-token, never fornormalizeand never one it did not declare; signing a source in is REST and CLI only, with no MCP tool, so an agent cannot start one (seedocs/adr/039-oauth-credentials-for-mining-sources.md). Enforced bytests/source_isolation.rs(the host calls nothing that inherits the environment, standard streams, arguments or a writable directory, and opens the network only inside the manifest's flag), bytests/wasm_projects.rs(a real source cannot read outside its grant, see the environment, outrun its time limit or memory, or run an unlisted program), bytests/wasm_runtime.rs(no install without the exact consent, an altered component is never run) and bytests/in_process/auth.rs(every/api/source-packagesand/api/source-registryroute is guarded, no MCP tool installs, searches or changes a source), bytests/source_isolation.rs(distributionreaches no store, jobs or runtime, onlyappcalls it, andconfigonly to parse a location, the local tooling and adapters open no network, the digest and trust checks are inRegistry::fetchwith no origin that skips them, and the bundle is read from disk and nothing else) and bytests/wasm_registry.rs(a tampered or substituted package is never installed,requiredtrust refuses what no trusted key signed, an update never widens permissions silently, a registry never installs, updates or replaces a bundled source),tests/wasm_bundle.rs(the release script removes stale sources from Core's empty bundle) andtests/wasm_oauth.rs(a source gets a token only with the declaration and the consent, and no token is kept anywhere but the credential store). -
The web UI is a client of the REST API and nothing else, and it is opt-in — everything under
web/reaches MemCastle only over HTTP, never storage, the job code or the database admin endpoint, exactly as an integration does (invariant 8), and the daemon serves it only whenweb.enableis set. It is found through the one assets root atweb/dist/, in a checkout and in a package alike, and what it may do is what the REST API lets any client do: no shutdown, source installation or token management from the page. Its login is the database console's (the usermemcastle, the token as the password), checked by the same layer on every request; the token lives insessionStorageand is never put in a URL, a cookie or a log (seedocs/adr/035-web-ui.md). Enforced by theprekarchitecturehook'sweb-http-only-*and sharedclients-http-only-*rules (they fail onsurrealdb,surrealkv,SurrealStore, astore/jobspath or/api/dbin supported source files underweb/, Markdown,node_modules,distandbun.lockexcepted), bytests/in_process/web.rs(off by default, no path leavesweb/dist, the headers, a missing build is a 503 and never a failed start,/api/configcarries no secret), bytests/web_bundle.rs(the package holds the build and nothing else, and a daemon serves it from an installed prefix and from the checkout through the same lookup) and byweb/test(the login, the route guard and the client, with a real daemon). -
Changing a miner is administrative, and the daemon is the only writer of miner configuration — miner definitions live in the configuration file the daemon was started from or in individual XDG miner TOML files, and adding, changing, enabling, disabling, removing, reloading and running one are REST and CLI only, so an agent can read the miners (
memcastle_miner_list,memcastle_miner_get) but cannot decide what the daemon mines. A secret is never written to the file (credentialis a reference, and a secret-looking key is refused) nor returned (a credential is shown as a kind (env,fileoroauth) and whether it resolves), and a change made through the daemon never broadens the miner's effective source options without being told to. The rest of the global file is never rewritten, only that section, in place; a miner file is edited separately (seedocs/adr/037-persistent-miner-configuration.md). Enforced bytests/in_process/auth.rs(every/api/minersroute guarded, and the only miner MCP tools are the two read-only ones), bytests/in_process/miners.rs(validation before anything is written, option changes never broaden silently, a credential never leaks, a cursor survives every change, REST, CLI and MCP agree) and byconfig::miners_file's tests (comments and other tables survive an edit, a stale write is refused). -
Triggers and miner-owned schedules are opt-in, administrative, and only ask for a run — a trigger decides when a miner is run and nothing else: it ends in the one request
memcastle miner runmakes (app::miners'srequest_miner_run), so it can cause nothing a person could not, and it acquires, normalises and stores nothing and carries no payload to a source. Every trigger is disabled until the user enables it in the configuration file or withmemcastle trigger: a miner-owned schedule is disabled until the user explicitly enables it in its miner file or withminer schedule-enable: installing a source, defining or enabling a miner, writing a trigger, recovering after a crash and re-installing never enable one, and what the palace remembers about a trigger (progress, accepted deliveries) never says whether it is enabled, so a restart cannot override the user's choice. Enabling is refused, with what to set up, until every prerequisite holds, and a source declaring that it supports a trigger kind (a capability outside the consent digest) is one of them. Defining, changing, enabling, disabling, removing, reloading and firing a trigger are REST and CLI only, so an agent can read the triggers (memcastle_trigger_list,memcastle_trigger_get) but cannot decide what runs unattended. A webhook is delivered to a listener of its own (never a route of the main router, so invariant 6 has no exception), which is off unless[webhook] enableis set, bound only while a webhook trigger is enabled, loopback unlessallow_remoteand authentication are on, bounded in body size and concurrency, authenticated per trigger by a secret referenced and never written in the file, and never logs, stores or passes on a delivery's body or a secret (seedocs/adr/043-source-triggers.md). Enforced bytests/trigger_isolation.rs(src/triggerreaches no store, jobs, source or interface, onlyappand the daemon's root name it, MCP calls nothing that changes or fires a trigger, the main router serves no/hooksroute, the listener logs and forwards no body or secret, and a trigger asks for a run only through the one path), bytests/in_process/auth.rs(every/api/triggersroute guarded, and the only trigger MCP tools are the two read-only ones), bytests/in_process/triggers.rs(disabled by default and nothing started, enabling refused with what is missing, deliveries authenticated and idempotent, bursts coalesced, restart and failure handling) and byconfig::miners_file's tests (the[[triggers]]section is rewritten in place, with the miners and every comment kept). -
Plugins are the distribution unit; their modules never acquire one another's lifecycle state — the reviewed
plugins.jsoncatalogue is separate from the legacyregistry.jsonsource index, and a release archive contains a generatedmanifest.tomlwith the descriptor's metadata, release versions, checksums of every artifact, and typed modules; the project-localplugin.tomldescriptor is never rewritten or archived. Package generation does not write a siblingmanifest.toml; it derives the version from the plugin's own Git tags (or an explicit--version) and accepts only declared module and support assets. Plugin install defaults each new source toinstalled, never enables it or installs an integration in an agent; onlysource enableor the localintegration installdoes that. A legacy source cannot be silently claimed by a plugin: a mined legacy name needs explicit--adopt-source, and a previously installed plugin retains ownership after uninstall so another plugin cannot inherit its cursor. Each source retains its own miner, cursor, consent and permission digest, and uninstall preserves everything already mined. Onlyappwrites plugin ownership in the palace;plugin::packageanddistribution::plugintouch neither store, jobs nor the integration installer. Enforced bytests/plugin_isolation.rs,tests/source_isolation.rs,tests/integration_isolation.rs,tests/in_process/auth.rs,tests/in_process/plugins.rs,tests/wasm_plugin.rsand the plugin package/ledger unit tests.
Layout
src/
├── main.rs the memcastle binary: parses Cli, dispatches (and hosts `migrate`'s direct storage access)
├── cli.rs argument types only
├── lib.rs module wiring
├── error.rs the crate's error type
├── term.rs terminal presentation for the CLI: colour, TTY detection and confirmation prompts
├── config/ typed configuration (defaults -> file -> env -> CLI -> validate) and Unix XDG paths; `miners_file`
│ rewrites the `[[miners]]` and `[[triggers]]` sections of the file in place and nothing else
├── domain/ Palace/Wing/Room/Drawer/Job, checkpoint payloads, entities, memory modes — pure types, no I/O
├── store/ SurrealDB connection and repository methods (schema is applied from `database/schema/`)
├── migrate/ versioned data migrations and the version watermark, run before serving
├── assets/ runtime asset resolution (override, installed, embedded); never user data, never the network
├── dbadmin/ the database admin endpoint: SurrealDB's WebSocket protocol over the daemon's own handle
├── trigger/ source triggers: the supervisor that runs the enabled ones (timetable, poll, file watcher) and the webhook listener;
│ knows no store, jobs or source, reaches the daemon only through a trait `app` implements, and only `app` and
│ `server` name it
├── events/ the change bus: identifiers and kinds published after a write, relayed by `GET /api/events`; pure, no store, no jobs
├── jobs/ the scheduler: claiming, dispatch, cooperative pause/cancel, crash recovery
├── project.rs the project-local `.config/memcastle.toml` and `MEMCASTLE_WING`/`MEMCASTLE_ROOM`: a directory read, shared by
│ the directory adapter and the CLI's `note`; no store, no jobs
├── mining/ the mining job handler: the source adapter contract, the shared pipeline and chunker, the built-in adapters
│ (the directory adapter alone reads a project's `.config/memcastle.toml`, for its default wing, through `project`),
│ the registry that names them, `bundled`, which reads the sources unpacked beside the binary, and the
│ WebAssembly host that runs installed sources (`wasm/`)
├── integration/ agent integrations: manifest, discovery under the assets root, install/update/remove with a receipt, and
│ the Pi, OpenCode and Claude Code agent adapters (no store, no jobs, no network, no daemon)
├── plugin/ versioned parent manifest, multi-module archive validation and local signing (no store, jobs or network)
├── source/ source packages: manifest, archive, scaffolding, build, signing, publishing an index, and the conformance runner
│ (no store, no jobs, no network)
├── credential/ OAuth for mining sources: the device and browser sign-in flows, the keyring or owner-only file store, and
│ renewal; reaches no store, jobs or runtime, and only `app` and `server` name it
├── distribution/ finding and fetching source packages: registry indexes, locations, the SHA-256 and signature checks;
│ called only from `app` (and `config`, to parse a location), no store, no jobs
├── checkpoint/ the checkpoint job handler (durable, resumable memory writes)
├── audit/ the audit job handler (read-only consistency report)
├── repair/ the repair job handler (narrow, dry-run-first fixes)
├── search/ the retrieval contract and ranking policy (lexical, semantic, hybrid, temporal, graph expansion)
├── embed/ embedding providers (command, OpenAI-compatible HTTP) behind one trait, and the `Embed` job handler
├── dedup/ drawer deduplication: assess what a new drawer duplicates or resembles, link it, skip an exact copy
├── extract/ entity extraction providers (heuristic, command, OpenAI-compatible HTTP) behind one trait, and the `Extract` job handler
├── app/ application services — the one layer mcp/api call into (the CLI reaches it over HTTP, via `client/`);
│ `miners` reads and rewrites the configured miners; `triggers` the configured triggers and the daemon's side of
│ firing them; `source_auth` signs a source in
├── server/ the daemon composition root + lifecycle (registry file)
├── mcp/ MCP tool surface, over HTTP
├── api/ the REST API (health/status/config/jobs/search/recall/wake-up/diary/notes/wings/rooms/drawers/entities/graph/
│ sources/source-packages/source-registry/miners/triggers/auth-token/db/shutdown; `docs/mcp-and-api.md` lists every route),
│ and `web.rs`, the dashboard's static files under `/ui` (only when `web.enable`; no store, no jobs)
└── client/ the CLI's HTTP client for a running daemon, and the human renderings of its answers (status, tables)
wit/ the source contract (`memcastle:source`), the one definition components and the host are built from
sources/ official and reference WebAssembly sources (`directory`, `github`, ...), one package per directory, built and
tested but not compiled into MemCastle; bundled sources ship with releases (`packaging/sources/build.sh`)
integrations/ Agent integration documentation; Core ships no agent-specific integration assets.
Pi, OpenCode and Claude Code source/integration modules live in standalone `memcastle-pi`,
`memcastle-opencode` and `memcastle-anthropic` plugins; Codex's integration and history and ChatGPT
history live in standalone `memcastle-openai`.
web/ the web UI: a Vue 3 and OpenVue application, a client of the REST API, built into `web/dist/` and
served under `/ui` from the assets root when `web.enable` is set (docs/web.md)
skills/ reusable agent instructions shared by every integration; `tests/in_process/skills.rs` holds them to the
tools, commands and routes they name (docs/skills.md)
tests/fixtures/integration/ the language-neutral conformance fixtures every integration is held to
Dependencies point inward: cli / mcp / api -> app -> domain + store/jobs/search -> store,
where the cli arrow is an HTTP call to the daemon through client/, not a Rust call into app.
Nothing in domain knows SurrealDB exists; nothing in cli/mcp/api knows store exists.
Style
Every non-obvious line carries a comment saying why. Not what — the code says what. Ideally naming the failure it prevents. A comment that restates the code is worse than none.
Errors are typed and actionable. thiserror for the library, miette at the binary edge.
A diagnostic must carry the two things the user does not already know:
what specifically failed, and what to do about it.
Diagnostic codes are memcastle::<module>::<kind>,
and a code is a public identifier users grep for — renaming one is a breaking change.
Test names are sentences. an_unchanged_input_produces_no_output, not test_run_2.
The name should say what would be broken if it failed.
Markdown prose uses semantic linefeeds. One sentence per line; only wrap inside a sentence, at a clause boundary,
when it would otherwise exceed the 120-column limit .markdownlint-cli2.yaml enforces.
This keeps a diff scoped to the sentence that actually changed.
The rule applies to the linted documents (AGENTS.md, CONTRIBUTING.md, README.md, docs/ and skills/*/SKILL.md);
PLAN.md, integrations/README.md and skills/README.md are working documents outside that lint scope.
Documentation ships with the behaviour.
A change to a CLI flag, a setting, an environment variable, an MCP tool, a REST route or any other user-visible behaviour
updates the page that documents it in the same change (docs/cli.md, docs/configuration.md, docs/mcp-and-api.md, ...).
Document what the code does, not what it will do, and diagram architecture with Mermaid.
See Development.
Commits
Conventional Commits, enforced by commitlint on commit-msg.
The type becomes a changelog heading, so choose it as if someone will read it in release notes — because they will.
Releases
Driven by gh-ship.
Never bump a version or push a tag by hand:
cliff.toml derives the version from the commit history, prepare-release applies it,
and .github/ship.yml is the contract between them.
See CONTRIBUTING.md.
Scope and cost
Doing what was asked is the job, and widening it costs the user time, money and a busy machine, so staying inside the request is a rule here and not a courtesy.
- Stay inside the request. "Add tests for these two files" means those two files, not "close the coverage gap". When a request can be read broadly, do the narrow reading, say what was left, and ask before widening.
- A broad task is fine when it is asked for. If the user states it explicitly ("raise coverage across the patch", "get this module to 100%", "speed up the tests"), that is a focused task and the broad reading is the right one. State the plan and a rough time estimate first, then do it.
- Let CI measure what CI measures.
Coverage comes from the Codecov comment on the pull request, which arrives minutes after a push and costs the machine
nothing.
Do not run
cargo llvm-covover the whole suite locally: it rebuilds the dependency tree instrumented, writes thousands of profile files and takes tens of minutes of every core. Run it only when the user asks for a coverage session. - Run what you touched.
While iterating, run the tests of the module or binary you changed
(
mise run test -- <filter>; that is the basic suite, and thetests/wasm_*.rsbinaries aremise run test:wasm -- <filter>). Run the full suite once, before pushing. - Two strikes, then ask. If the same approach fails twice (a tool, a flag, a workaround), stop and ask rather than building a third one.
- Estimate before anything heavy. Describe the cost first, and wait for a yes, for anything expected to take more than about ten minutes or to saturate the CPU: instrumented builds, repeated full-suite runs, many WebAssembly builds.
- Do not promise before knowing. Do not call work easy or small until its size is known. When it turns out larger than said, report the real size and offer to stop.
Before you push
mise run ci
Formatting, Clippy, spelling, workflow and Markdown linting, the architecture guard hooks, the basic and WebAssembly
tests, the build and test of every reference source and integration (which needs bun and the wasm32-wasip2 target)
and the documentation build.
This is the local equivalent of CI's lint, test and docs steps; CI additionally runs every prek hook, coverage on three
operating systems and gh ship validate.
This repository is generated from a template
The toolchain, hooks, CI and release workflows come from rust.tpl
and are updated with git tpl update.
Files carrying template-owned content end with a # --- project-specific ... marker
(mise.toml, prek.toml, Cargo.toml and a few more): add below it, never above.
Changing template-owned content here fixes it in one repository. Changing it in the template fixes it in all of them — prefer that.
