Thanks for working on plumb. AGENTS.md is the compact,
always-loaded contract; this page owns the detailed build, code-style, testing,
and delivery guidance. Read the brief first, then use this page while changing
the public source.
git clone https://github.com/plumbkit/plumb
cd plumb
make install-hooks # REQUIRED after every fresh clone
make buildmake install-hooks installs a pre-commit hook that runs
golangci-lint run --allow-parallel-runners ./..., so formatting and lint issues
are caught before they reach the tree. The hook only CHECKS: an unformatted file
fails the commit, the hook lists it, and you run golangci-lint run --fix ./...,
review, re-stage and commit again. It never rewrites files itself, because a
rewrite after the commit's content was chosen left a dirty tree behind a passing
commit. --allow-parallel-runners keeps a peer agent's lint from failing your
commit on the shared lock. make check-pre-commit (part of make verify) pins
both behaviours against a stub linter.
Keep your golangci-lint on the pinned version.
GOLANGCI_LINT_VERSIONin.github/workflows/ci.ymlis the single source of truth, and the pre-commit hook reads it to warn when your binary differs. The warning matters because a newer binary brings newer gofumpt rules, so the hook can fail on files your commit never touched. Do not reformat those as part of your commit. To match the pin:go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v<pinned>— note that a Homebrew-installedgolangci-lintdrifts on its own schedule and will re-break the match on the nextbrew upgrade. When the newer version is the one we want instead, bump the pin and commit any reformatting as its own commit.
| Command | Does |
|---|---|
make build |
Compile to ./plumb, version stamped from git/VERSION. |
make test |
go test ./... |
make test-pure |
Build the whole tree and run the account tests with CGO_ENABLED=0 — the mode .goreleaser.yml releases in, and the mode no other command here uses. A C compiler turns cgo on by default, so every other gate compiles a linux && !cgo file out of its build entirely: internal/account/home_linux_pure.go is in no make test, make verify or make integration-test binary at all. Linux only, and the target refuses elsewhere rather than passing quietly — darwin has no cgo/pure split. Runs in CI on ubuntu-latest and inside make verify-full-container. |
make test-race |
go test -race ./... |
make lint |
golangci-lint run via scripts/lint-with-retry.sh — retries with bounded backoff on the shared-cache lock ("parallel golangci-lint is running"), so a peer agent's lint does not read as a failure of this one. |
make lint-tags |
Lint and format-check the files behind custom build tags (integration, clients*, parity, race), which make lint never analyses. scripts/build-tags.sh reads the tags from the //go:build lines of every Go file that is not ignored, so a new tag is covered without a config change. Part of make verify. It lints this OS's files only; make lint-cross covers the other's. |
make verify |
Build, test, lint (including make lint-tags), compile integration/client binaries, check file/brief/changelog limits, and verify go.mod tidiness — the fast gate CI runs on every pull request. It does not RUN the integration suite, and prints that on success. |
make verify-full |
The same checks as make verify plus make integration-test — the full "ready to commit" gate, with the unit tests run once. Its cmd/smoke suite starts plumb daemons of its own and refuses on a developer machine (internal/separateenv), so run it in CI or through make verify-full-container. integration-test SKIPS tests whose language server is absent. |
make verify-full-container |
make verify-full in a Linux container with pinned Go, golangci-lint and language servers: streams in the working tree as git sees it, runs with --init and --rm, and fails if a plumb daemon is left after the suite. It runs the CGO_ENABLED=0 leg (make test-pure) too, which the image's own gcc would otherwise hide. The local way to run the full gate. |
make lint-cross |
Statically lint and vet the other supported OS, its tagged files included; required after platform-constrained or linter-config changes. |
make cover |
Enforce the whole-tree statement floor from scripts/check-coverage.sh. |
make vuln |
Run govulncheck; requires network access. |
make tidy |
go mod tidy |
make clean |
Remove build artefacts. |
make install-clients |
Install every supported client CLI, for local client-integration testing. |
make clients-test / make clients-test-auth |
On-demand connection/auth tiers that drive each installed client CLI headless (own build tags, never part of make verify) — see the comments above these targets in the Makefile. Like cmd/smoke they start plumb daemons, so they refuse outside CI, a container or a declared VM. |
Formatting: always format via
golangci-lint run --fix ./...(the fix the hook names when it fails), not a standalonegofumptbinary — the two can pin different versions and disagree.
Why the CHANGELOG placement guard is not in verify. A rebase replays a
CHANGELOG.md addition under whatever heading happens to sit at that offset, and
it never conflicts, so the entry lands silently in an already-released section —
this has needed a by-hand fix four times. scripts/check-changelog-placement.sh
catches it by diffing against the merge-base, which means it needs a base ref, and
a working tree has not got one. make verify and the pre-commit hook have to keep
working offline and in a shallow clone, so the guard runs instead as a step in
CI's verify job, with the base SHA taken from the pull-request event. Run it by
hand with make check-changelog-placement (defaults to origin/main); its
regression suite is make check-changelog-placement-test. The whole-file half of
the pair — no version number in two headings — is make check-changelog, and that
one is in verify and the hook.
Why make lint-cross exists. golangci-lint only analyses files matching the
current GOOS, so a Linux make lint never sees sandbox_darwin*.go /
process_darwin*.go and a macOS run never sees the _linux files. This is not
hypothetical: the widened linter set was trialled on Linux only and passed clean,
then failed CI's macOS leg on the one usetesting hit hiding behind a build tag.
CI's two-OS matrix is the backstop, not the first line.
Test temporary directories are inside the checkout. make test sets
GOTMPDIR=$(CURDIR)/.testcache, so t.TempDir() is repository-descended. Tests
must not assume their temporary path lives outside the repository. Reproduce
the CI shape with GOTMPDIR=$PWD/.testcache go test ./....
Tests never touch the live session registry. In a test binary,
session.Dir prints the reason and exits the process if it resolves to the
registry that the binary's start-up environment points at, which is the
developer's own daemon's. It exits rather than panics because the daemon's
recover() sites would swallow a panic and let the test pass. Every package
whose tests reach the registry, directly or through daemon, connection, hook, TUI
or web code, has a TestMain (session_isolation_main_test.go) that points
XDG_DATA_HOME at a temporary directory for the whole binary. A test that needs
a registry of its own sets t.Setenv("XDG_DATA_HOME", t.TempDir()) as usual,
which wins over TestMain's. Do not add an override that outranks it: one
directory shared by every test lets a session another test left live hold a
name this one asks for.
Coverage and vulnerability checks are deliberately separate from verify.
Coverage re-runs the whole suite instrumented and vulnerability scanning needs
the network. CI runs both, plus test-race and integration jobs. Treat the
coverage floor as a ratchet: raise it when sustainable; never lower it to make a
red build green.
These are the detailed rules behind the compact contract in AGENTS.md:
- Australian English in all prose, comments, logs, and error strings (initialise, behaviour, colour…). Exception: identifiers from external specs (LSP method names, etc.) keep their canonical spelling.
log/slogonly — neverlogorfmt.Printlnfor logging.- Wrap errors with context:
fmt.Errorf("loading config: %w", err). context.Contextfirst parameter on every blocking/I/O function.- State the concurrency contract in every type's doc comment.
- Do no real work in
init(); wire dependencies through constructors. - Add no globals except the documented TUI style variables and the intentional
pathLocks/mutationRunLockprocess-wide tool locks. - Comments explain why, not what. No what-comments.
- Max ~600 lines per production file and ~900 per
_test.go; additions toscripts/.filesize-baselineneed a justification. Cyclomatic complexity is capped at 15 for first-party non-test functions. - Every
//nolintdirective carries an inline justification..golangci.yml's rejected-linter list is deliberate; re-review and document the hits before enabling one. - Every
Tool.Execute()is a thin orchestrator over parse → run → format steps.
<type>(<scope>): <short summary>
[optional body: why, not what]
Types: feat, fix, refactor, test, docs, ci, chore. Prefer one
commit per discrete change, each with a CHANGELOG.md entry — bisectable
history over squashed PRs.
- A new MCP tool — follow the
add-mcp-toolproject skill in.claude/skills/(the checklist, including the thin-orchestratorExecute()pattern and the lean-profile decision). Implement theToolinterface, takeWriteDepsfor write tools, register it inregisterAllTools(internal/cli/conn_register.go), add tests, and document it indocs/tools.md. - A new LSP adapter — see
docs/adding-an-lsp.md. - A new config field — add it to
config.Config, updatedefaults, validate invalidate(), and document it indocs/configuration.md.
- Tests live next to the code (
_test.go, same package); table-driven where it fits. internal/lsp,internal/cache, andinternal/toolsneed meaningful coverage. UseWriteDeps{}as the zero-value setup for write tools, and keep per-session isolation tests in the package whose state they protect.- Internal tool-to-tool
Executecalls use the target's canonical parameter names. Aliases resolve only at the MCP dispatch boundary; the in-process call guard test enforces this. - Integration tests requiring external binaries (gopls, pyright) are gated with
//go:build integration. - Don't chase TUI coverage.
make coverinstruments every package with-coverpkg=./...; an untested package therefore counts against the whole-tree floor. The canonical floor is inscripts/check-coverage.sh; usemake cover-reportto find gaps.
The Makefile resolves the build version from an exact Git tag, then VERSION,
then the short commit hash, and stamps the source revision alongside it. Change
VERSION during development; do not create a tag for each iteration. Full
output contracts live in docs/cli-reference.md.
After rebuilding or reinstalling, run plumb restart: a source change never
activates inside the old daemon process. The resilient proxy reconnects clients.
Concurrency, the rate limiter, read tracking, and the stats schema carry subtle
cross-session invariants. Inspect the architecture and existing tests before
changing them, keep each change discrete, and add its CHANGELOG.md entry.
- Import paths are v2 only:
charm.land/bubbletea/v2,charm.land/lipgloss/v2,charm.land/bubbles/v2. Never mix in the v1 packages — type/API incompatibilities. Modelis exported;NewModel(logPath)constructs,Run(logPath)is the entry point.View()returnstea.View(v.AltScreen = true). Keys:tea.KeyPressMsg, match viamsg.String().- Sections (opened with
/):Dashboard,Sessions,Memory,Logs,Settings(0–4). Settings (internal/tui/model_settings.go) is a two-pane editor: a left Scope column (Global + each workspace) and a right rows pane with General/LSP tabs;tab/shift+tabcycle focus,[/]resize. - Settings persistence is scope-aware: Global rows write global config (
config.Save), workspace rows write a sparse override to.plumb/config.toml. Each row carries a reload-tier numeral (¹live /²next session /³restart) and a⁴override /⁵inherited mark; only Theme and Log level apply live.ctrl+topens the theme picker. - List and
[lsp.<lang>]rows open shared pop-up editors (model_settings_listeditor.go,model_settings_texteditor.go), auto-saving on close; overlays dim viadimLines()+spliceOverlay(). - Rebindable keys: the twelve navigation/action keys are configurable via the global-only
[ui.keys]table (unknown actions and key conflicts are warned at startup with deterministic resolution; overlay/popup keys and the vim aliases stay fixed). The help overlay and Sessions footer render the live bindings. See the[ui.keys]config section. - Theme system:
ActiveTheme/ActiveThemeNameare globals ininternal/tui/theme.go; lipgloss style vars are rebuilt byRebuildStyles()after any mutation. Adding aThemefield means updating every theme literal —TestTheme_AllFieldsSetcatches omissions.