Contributing
The make targets, the check gate, the conventions that matter, the forbidden APIs, and the three places a new MCP tool must be wired.
The conventions live in AGENTS.md at the repo root, and that file is the source
of truth. This page is the orientation: what to run, what the gate checks, and the
handful of rules that are worth knowing before you write a line.
The targets
make build # ./bin/seamlessd and ./bin/seam, with build metadata stamped in
make test # unit tests
make test-race # unit tests under the race detector
make vet # go vet
make lint # golangci-lint
make fmt # gofmt, over tracked files
make fmt-check # report gofmt drift instead of fixing it
make docs # regenerate docs-src/ -> docs/docs/ (committed)
make docs-check # fail if the committed site is stale
make check # the gate - everything above, in order
make help prints the full list, including the launchd dev-loop and prod-install
targets.
Note that make build is not the same as go build: it stamps the commit and
build date into the binary via -ldflags, and those show up in /healthz, the
MCP handshake, and the startup log. A plain go build leaves them unknown,
which makes a stale running daemon invisible.
The check gate
make check
That is the one command that must be green before work is done. It runs six steps
sequentially - as separate $(MAKE) invocations rather than prerequisites, so it
stops at the first red step and stays ordered under make -j:
| Order | Step | Catches |
|---|---|---|
| 1 | build |
It compiles. |
| 2 | vet |
The stdlib's own suspicious-construct checks. |
| 3 | fmt-check |
gofmt drift in tracked files. |
| 4 | docs-check |
A committed docs site that no longer matches docs-src/. |
| 5 | lint |
golangci-lint, including the repo's custom bans. |
| 6 | test-race |
Everything, under the race detector. |
The order is cost-ascending: the cheapest and most-likely-to-fail steps run first,
and test-race is last because it is by far the slowest. The individual targets
exist for iterating; check is the thing you run before you claim to be done.
docs-check regenerates into a temp dir and diffs, rather than rewriting your
working tree and running git diff. That means it never mutates the file you are
editing, and diff -r catches untracked drift that git diff cannot see - a page
deleted from docs-src/ but still committed under docs/docs/. Two docs pages
are generated from the code (the MCP tool reference reads mcp.Catalog(), the
configuration reference reflects config.Defaults()), so changing a tool or a
config key makes the committed output stale, and this is the step that says so.
Use make fmt, never gofmt -w .
This one bites in a specific way, so it is worth stating plainly.
The go tool's ./... pattern skips dot-directories, so build, vet, test,
and lint never see .claude/worktrees/ - other agents' checkouts of this same
repo. gofmt takes paths, not packages, and walks the filesystem raw. So:
gofmt -l .descends into those worktrees and reports their drift as yours.gofmt -w .rewrites their files underneath them, mid-edit.
make fmt and make fmt-check scope to tracked files (git ls-files '*.go').
Always go through them.
Style essentials
- Go 1.25+, no CGO. Pure-Go SQLite (
modernc.org/sqlite). Prefer modern stdlib (slices,maps,strings.SplitSeq, iterators) in new code. - No emojis in code or comments. No attribution lines in commits, PRs, or code.
- Package layout is consistent across
internal/:service.gofor the package's public API,store.gofor SQLite access,handler.gofor an HTTP surface,{feature}.gofor pure functions,*_test.goalongside. - Interfaces are role nouns -
Store,Recorder,Embedder, notIStore. - Constructors are
New{Type}(deps) *Type, or(*Type, error)when they can fail. - Domain errors are package-level sentinels (
var Err{Condition} = errors.New(...)), checked witherrors.Is. - IDs are ULIDs, via
core.NewID(). Never UUID. - Wrap errors with context and
%w:fmt.Errorf("pkg.Service.Method: %w", err). Reservefmt.Errorffor wrapping; sentinels come fromerrors.New. - Either log or return an error, never both. Fatal to the operation → return
it. Non-fatal (graceful degradation) →
slog.Warn/Debugand continue. log/slog, and never log memory or note bodies - IDs only.- Every service and store method takes
context.Contextfirst. Nevercontext.Background()in a handler or in a goroutine spawned by one: derive from the request context, or usecontext.WithoutCancelfor work that must outlive it.
Database rules
- WAL and foreign keys are set by Go on connection open, never in migration
SQL. One
seam.dbat{data_dir}/seam.db, withSetMaxOpenConns(1). - Migrations are numbered SQL files under
internal/store/migrations/, embedded withgo:embedand registered in theMigrations()list. Each runs once, inside a transaction, tracked inschema_migrations. - Never edit an applied migration. Append a new numbered one.
- A new
.sqlfile needs both ago:embedline and aMigrations()entry, or it silently never runs. This is the failure mode that looks like a working build and a mysteriously absent column. - Files are the source of truth for
memory/andnotes/. The*_indextables and FTS are rebuildable mirrors, kept in sync by the files watcher and startup reconciliation;content_hashis what lets an unchanged file be skipped. - Embeddings are little-endian float32 BLOBs in the
embeddingstable, and similarity is brute-force cosine in Go. Do not add a vector database. - The unified FTS5 table (
fts) spans memories and notes and is managed from the files layer with explicit INSERT/DELETE - not triggers, because it is not an external-content table.
Testing rules
testify/require(fail fast), table-driven for multi-case functions. This overrides any global "stdlib testing only" preference: the ported code and this project standardize on testify. Stay consistent.- SQLite tests use a fresh on-disk DB in
t.TempDir(), or a named in-memory DB (file:{t.Name()}?mode=memory&cache=shared). Never the unnamed shared-cache URI. - Migrations are exercised against a fresh DB.
- External services are mocked with
httptest.NewServer. A unit test never hits a real network service. - No
time.Sleepfor synchronization. Channels,sync.WaitGroup, or polling with a deadline. This one is enforced by lint. - Tests are named
Test{Unit}_{Scenario}, or table-driven witht.Run.
Forbidden APIs
Each row is a pattern that compiles, looks fine in review, and is wrong.
| Pattern | Why | Use instead |
|---|---|---|
ulid.MustNew |
Panics on entropy failure. | core.NewID() |
os.WriteFile on a .md file |
Non-atomic; a crash mid-write corrupts a source-of-truth file. | files.AtomicWrite (temp file in the same dir + fsync + rename) |
_, _ = time.Parse(...) |
Silently yields zero-value timestamps. | Capture the error, slog.Warn, do not emit zero times. |
err == ErrXxx, err == sql.ErrNoRows |
Breaks the moment the error is wrapped. | errors.Is(err, ErrXxx) |
_ = json.Marshal/Unmarshal(...) |
Marshal can fail; Unmarshal silently zeroes. | Check the error; warn and propagate. |
Unchecked RowsAffected() on UPDATE/DELETE |
Not-found looks like success, and a driver failure looks like not-found. | Check the error, then if n == 0 { return ErrNotFound }. |
close(ch) outside sync.Once in Close() |
Double-close panics. | closeOnce.Do(func(){ close(done) }) |
a + "/" + b for filesystem paths |
Breaks portability and traversal guards. | filepath.Join |
context.Background() in a handler/goroutine |
Leaks request scope, disconnects shutdown. | Derive from the request ctx, or WithoutCancel. |
os.Stat/WalkDir following symlinks across user data |
Leaks files outside the tree. | os.Lstat; skip ModeSymlink in WalkDir. |
strings.Contains(err.Error(), ...) for control flow |
Fragile across message changes. | Typed sentinels + errors.Is |
Returning (nil, nil) for a missing row |
Callers forget the nil check. | Return (nil, ErrNotFound). |
New migrations/NNN_*.sql without a Migrations() entry |
Silently never runs. | Add the go:embed + Migrations() entry; verify on a fresh DB. |
time.Sleep for synchronization |
Flaky tests; masks ordering bugs. | Channels / WaitGroup / deadline poll. |
make lint catches the grep-detectable subset: forbidigo bans ulid.MustNew
and time.Sleep, rowserrcheck catches a missing rows.Err(), errorlint
catches err == sentinel comparisons, and errcheck runs with check-blank: true
so errors discarded into _ are reported too.
That last one is the guardrail worth understanding. n, _ := res.RowsAffected()
in front of if n == 0 { return ErrNotFound } turns a driver failure into a
confident "not found" - the caller believes it, and there is no way to tell the
two apart afterwards. Every surviving discard is either listed in
.golangci.yml's exclude-functions (structurally uninteresting: a deferred
Tx.Rollback, a write to an already-committed HTTP response) or carries a
//nolint:errcheck with a reason at the site. There is no third category. If
you add a discard, say why in one of those two places rather than reaching for a
blanket exclusion.
Required patterns
- Atomic markdown writes. Every
.mdwrite goes through the files layer's atomic writer, including rollback paths. - DB-then-file ordering. An operation touching both commits the DB transaction first, then performs the filesystem mutation in a post-commit step, and undoes partial filesystem state on rollback.
rows.Err()after everyfor rows.Next().- FTS5 MATCH sanitization and LIKE escaping - see Domain invariants.
filepath.Joineverywhere, tests included.
Adding an MCP tool
A tool has to be wired in three places inside internal/mcp, and each one is
guarded so that missing it fails a check rather than shipping quietly:
registerTools- the actual registration. Without it the server does not serve the tool.ToolCount(inserver.go) - bump it.Server.NumTools()counts what was registered, andseamlessd doctorasserts the two are equal, so a tool written but never wired in fails the doctor check.Catalog()(incatalog.go) - add the tool's constructor, in the same order asregisterTools.Catalogexists socmd/docsgencan render the tool reference without constructing aServer: the constructors are plain data (name, description, input schema) and need no DB, config, or listening port.
internal/mcp/catalog_test.go enforces the parity, and it is a same-package test
so it can read the server's private toolNames - the registration record itself,
not a second hand-maintained list:
require.Equal(t, srv.toolNames, names, "Catalog order/content must mirror registerTools")
require.Len(t, names, ToolCount)
A second test, TestCatalogToolsAreDocumentable, guards what docsgen renders: a
tool with no description would emit an empty docs section, and a duplicate name
would collide on the page anchor.
Two places outside internal/mcp also track the surface:
cmd/seam/doctor.gohas anexpectedToolsconstant that mirrorsToolCountwithout importing the mcp package (which would pull its whole dependency tree into the CLI).seam doctorasserts the running server exposes that many tools viatools/list.- A docs page's
tools:frontmatter list (underdocs-src/reference/mcp/) decides where the generated reference for the tool appears. A page listing a name that is not inCatalog()is a docsgen error.
Before declaring done
make checkis green.*_test.gois updated in the same change if a signature changed. After any interface, store, schema, or migration change, runmake build && make testand update mocks and fakes for changed signatures in that same change.- Propagate every fix. When you fix a buggy pattern, grep the repo for other instances and fix them all together.
- No fake results on error. Never swallow an error and return a plausible dummy value - an agent cannot distinguish it from a real result. Return the error.