Collie (repo AltanS/collie) — a phone web UI for the AI agents running in your terminal,
served over Tailscale. A mobile-first PWA (Vite + React + TS + Tailwind v4 + shadcn) plus a Bun/TS
bridge that mirrors ONE multiplexer per install — Herdr, tmux or zellij — letting you monitor and
reply to agents from a phone. Herdr is one adapter among the three, not the product: it is the
default, it is the only one that talks over a Unix socket, and its plugin route stays supported —
plugin id herdr.collie (manifest: herdr-plugin.toml). Orientation:
README.md · ARCHITECTURE.md · the UI's visual
language DESIGN.md · verified API HERDR_API.md ·
decisions .adr/ · adding a harness
HARNESS_CONTRIBUTING.md · adding a multiplexer
MUX_CONTRIBUTING.md.
.adr/ holds the decisions whose reasoning would otherwise live only in a PR thread —
specifically the ones that close off an option someone will reasonably propose again. If you're
about to argue why not rather than how, check there first; if the answer isn't there and the
decision is that shape, add one (numbering + format: .adr/README.md).
Rules elsewhere in this file stay short and normative and link to the ADR for the argument. Don't restate an ADR's reasoning here, and don't edit a superseded ADR into agreement with the present — mark it superseded and write the next one.
Collie is SemVered, and the version is enforced, so it never silently drifts.
The version lives in three files that must always agree, plus a matching CHANGELOG entry:
herdr-plugin.toml (canonical — Herdr reads it) · package.json · web/package.json ·
newest numbered ## [x.y.z] heading in CHANGELOG.md. ## [Unreleased] is not numbered and is
not part of that agreement.
Two kinds of commit. A functional commit records; only the release commit bumps. Never bump a version because you fixed something; the version moves once, when the release is cut.
Before committing any functional change (anything under bridge/, cli/, web/src/,
web/public/, scripts/, systemd/, or the manifest / package files, minus the tests and hooks
carved out below), you MUST, in the same
commit, add one bullet to CHANGELOG.md at the end of the ## [Unreleased] list so the
list stays in landing order. Style: a group, a bold lead, then the detail. The bullet sits
under one of five level-3 headings, in this order and only where there is content: ### Added,
### Changed, ### Fixed, ### Packaging, ### Docs. It opens with a short bold lead sentence, present tense,
about ten words, the period inside the **, and the detail follows in the same bullet:
- **The lead sentence.** The detail follows here. End with the thanks and the issue or PR it
answers where one exists (Thanks @handle (#147).), and with no commit hash: the hash does not
exist yet, and the release commit adds it. The lead is what the GitHub Release page prints, so
write it as the sentence an operator reads there. Do not touch the three version files.
Cutting a release is one chore(release): x.y.z commit that does all of this and nothing else:
-
Pick the axis from the sum of the Unreleased entries — what the operator has to do, not how visible any one change is:
- PATCH (
0.2.0 → 0.2.1): nothing to learn. Bug fixes, internal refactors, and small additions that build on an existing verb or screen and change nothing about how you already use Collie: a QR printed beside the pairing code, an extra column indevices list, a new flag with a safe default. The phone folds a patch-only delta into its weekly update digest (DIGEST_PATCH_WINDOW_MSinbridge/update.ts); the in-app band shows it at once. - MINOR (
0.2.0 → 0.3.0): something to learn, or worth hearing about today. A new verb, a new page, a new crew capability, a changed default, anything that earns its own section indocs/. The phone nudges within a day. - MAJOR (
0.2.0 → 1.0.0): the operator must change something. A config key renamed or removed, a contract broken, a workflow that used to work and now does not.
The person cutting the release decides. When in doubt, pick patch.
- PATCH (
-
Bump all three version files to that number.
-
Rename
## [Unreleased]to## [x.y.z] - YYYY-MM-DD, using the release date. The four###group headings and their bullets come along as they are; within a group the bullets stay in landing order, oldest first, because each was appended to the end. Append each line's short commit hash in the link format([abc1234](https://github.com/AltanS/collie/commit/abc1234)). Clean up the section: merge or reorder lines as needed, and delete entries for changes reverted before release. -
Re-create an empty
## [Unreleased]heading above it. -
Bump
flake.lockif it is to move at all — in this commit and no other. The input is pinned by revision, so a bump is two edits: therevinflake.nix, thennix flake lockto re-record it. The lock pins the toolchain the published binary is built with (Build / run → the flake), so a lock that moved in a feature commit describes a build nothing records. This is the only commit allowed to touch it, andscripts/check-flake-lock.shrefuses the others. Leaving it alone is the ordinary case; a release does not owe the lock a bump. -
Run
scripts/check-version.sh— it must print✓. Then tag and push (next paragraph).
A PR from a fork is the exception: leave all four files alone. Bump nothing, add no CHANGELOG
line — send the functional commits only. The version is the maintainer's to pick, because it depends
on what else lands in the same release and on which axis the sum of those changes sits; a bump
guessed at PR time collides with the chore(release): commit that actually cuts the release, and two
PRs both guessing 0.26.1 conflict with each other. scripts/check-version.sh stays green either
way — all four files simply keep the version they already agree on. The pre-commit hook may object
locally; SKIP_VERSION_CHECK=1 git commit … is the intended escape hatch here. If you'd like a
CHANGELOG line in your words, put it in the PR description and it'll be used. (Maintainer side: the
Unreleased line is yours to write on merge — cherry-pick the functional commits with -x, then add
the line in a follow-up docs(changelog): commit or by amending the merge. When a fork PR does carry
a release commit, drop that one — authorship is preserved and main stays unreleased until you cut
it.)
Doc-only changes (*.md) need neither a bump nor a CHANGELOG line, and neither do tests
(*.test.ts, *.test.tsx, *.test.sh) or the git hooks (scripts/git-hooks/). Both ship
nothing: a test is not in the binary and not in web/dist, and a hook runs on a developer's machine
at commit time and is not in the release tarball at all. No operator can see either change, so there
is nothing to record. Touch one of them and the code under it and the ordinary rule is back — the
source file is what the line is about. This is enforced two ways, but you are the first line — do
it as part of the change, not after:
A docs change reaches colliepwa.dev only with a release. Collie's release.yml tells the website
on every tag, and the website re-quotes docs/*.md at the newest published release — so a doc-only
fix pushed to main and not released sits unpublished, and the website's daily cron will not pick it
up either. To publish sooner, run the website's sync by hand against a ref:
gh workflow run sync-docs.yml -R AltanS/collie-website -f ref=main.
scripts/check-version.shruns insidecollie build(a release can't build while versions disagree).- A git pre-commit hook (
scripts/git-hooks/pre-commit, activate once withscripts/install-hooks.sh) blocks a functional commit that neither adds a line under## [Unreleased]nor bumps the version, blocks a commit whose staged## [Unreleased]bullets are not grouped under one of the five###headings or do not open with a bold lead, and blocks a release commit (version bumped) whose## [Unreleased]section still has lines in it. The same hook holds guard (D), which refuses a stagedflake.lockthat is not part of a release commit. Escape hatch for a single commit:SKIP_VERSION_CHECK=1 git commit …(everySKIP_*hatch is listed under Linting below).
Publish every release you cut — tag it when you push it. Cutting a release means the three
version files + the newest numbered CHANGELOG.md heading agree on x.y.z, and ## [Unreleased]
is empty again (the release recipe above). A cut version that never gets a tag is not a release at
all: .github/workflows/release.yml triggers on
push: tags: ["v*.*.*"] and nothing else creates the GitHub Release the in-app update banner links
to, so an untagged version exists only as a CHANGELOG heading and nobody can install it. So when
that release lands and you push, always push a matching annotated git tag with it —
git tag -a vX.Y.Z -m "Collie X.Y.Z" && git push origin vX.Y.Z (or git push --follow-tags so the
tag ships with the release). One v<x.y.z> tag per shipped version on the remote.
A release is published only from a commit whose CI run succeeded. release.yml enforces it: its
first job, gate, looks up the ci.yml run for the tagged commit, waits while that run is still in
flight, and refuses the release when the run failed, was cancelled, or never existed. 1.5.5 was
published from a red commit because nothing joined the two workflows; now they are joined, and the
whole matrix waits behind the gate.
Push main first, watch CI, then push the tag. That is the recipe:
git push origin main
gh run watch # or: gh run list --commit "$(git rev-parse HEAD)"
git push origin vX.Y.Z
git push --follow-tags in one go still works, because the gate waits rather than races. The
two-step form is preferred anyway, because a red CI then costs nothing: no tag exists yet, so you fix
it on main and the release commit can still be amended before anything is public. Once the tag is
pushed and CI is red, that door is shut: the fix is a follow-up patch release with a new tag, never a
moved or re-pointed tag.
This is why pushes are held while a release is being cut. ci.yml's concurrency group is
ci-<ref> with cancel-in-progress, so a second push to main cancels the release commit's run
mid-flight. The gate then sees cancelled, not success, and refuses. Re-running CI for that commit
and then re-running the Release workflow clears it, but the cheaper move is the old rule: hold the
push until the release is out.
The GitHub Release page is built, not written. release.yml runs
scripts/release-notes.ts over CHANGELOG.md and hands the result to gh release create. The
order on the page is the reader's, not the file's: ## Update first and unfolded, because a
phone arrives here from the in-app banner to copy one command and must not have to open anything
to see it; then ## What changed, the bold lead of every bullet in that version's section, one
line each, under its group's name; then a link to the section itself for the commits and a compare
link; then the by-hand verify recipe, the one block that sits in a <details>.
The compare link's other end is a real tag, asked of git (git describe --tags on the tagged
commit's parent), never derived from a CHANGELOG heading: betas 33 to 41 have headings and no tags,
so a derived link would 404. No previous tag means no compare line. GitHub's generated notes are no
longer appended either: that list only knew merged pull requests, and most of Collie's history
lands as direct commits or cherry-picks that keep the author, so it read as if almost nothing had
shipped. Nobody writes release notes by hand, and a bullet the script cannot read stops the release
rather than publishing an empty page. scripts/release-notes.test.ts pins the body's shape and
also reads this repo's own CHANGELOG.md, so a badly shaped bullet is red in CI on the commit that
wrote it; the pre-commit hook refuses one at commit time, and the tag-time failure is the backstop.
scripts/check-tag.sh checks this: with no arguments it asks whether the version the repo currently
claims has a tag; given a rev-list selector it asks the same of every chore(release): commit the
selector picks, reading the version from that commit's manifest. The pre-push hook runs it over
the range being pushed and WARNS — loudly, last, with the exact git tag -a command. It warns
rather than blocks because the tag may legitimately be cut after CI has looked at the release
commit; skip it once with SKIP_TAG_CHECK=1 git push. Nothing checks the remote, so the last step is
still yours.
Betas 33 to 41 are unreachable on purpose. Do not back-fill them. They were cut in the version files and the CHANGELOG and never tagged — not even locally — which is the failure the guard above exists to stop repeating. Their commits are superseded by the betas that followed, and a tag cut today would claim a release nobody ever tested.
Update notice (user-facing). The app's in-app update banner links to the newest release's GitHub
page and shows the command to run. Pushing a v* tag auto-creates that GitHub Release (with the
commands) via .github/workflows/release.yml. On a Herdr-managed install, always express
user-facing update/restart instructions as Herdr plugin actions — herdr plugin action invoke update --plugin herdr.collie (or restart) — never bin/collie … / systemctl … collie, which
depend on the caller's cwd and the unit name; the Herdr action runs from anywhere. A binary
install (scripts/install.sh's versioned layout) is not a Herdr plugin and has no such actions:
there the spelling is collie update / collie restart. A packaged install (a folder a package
manager owns — read-only, outside $HOME, or root-owned) takes neither: collie update REFUSES
there, so printing it is printing the command that fails. The spelling is the package manager's own
command where the resolved prefix names one, and the boundary sentence alone where it does not
(ADR 0035). A string that may be read
on more than one kind must come from the install kind (cli/install-kind.ts), never assume one —
and "which kinds are there" is now three answers, not two.
The website re-quotes these pages, so they are read on a phone: the renderer scrolls a code block
instead of wrapping it, renders a > blockquote as a card, and silently drops raw HTML. Write every
page to be skimmed.
- Commands before prose. A section that has something to run opens with the command, then explains it.
- One numbered step is one sentence and one command. A step that needs a code block holds the block indented under it. Explanation past one sentence goes in a paragraph after the list, never inside the step.
- A callout is a real blockquote (
> **Note.**,> **Experimental.**), never a bold sentence buried in a paragraph. The site renders the blockquote as a card; the bold sentence is lost. The site colours the card by its bold lead word:Note.blue;Experimental.,Caution.,Warning.amber;Never,Danger.red; else neutral. A callout needing severity opens with one of these words bold; the phrase may continue (**Experimental in 1.0.**keys experimental). - One idea per paragraph, about four lines at 80 columns. A caveat still stays in the same sentence as the claim it qualifies. Split the material around it rather than let the paragraph grow.
- A fact the reader needs now is stated inline, the default, the path, the command, and the
link comes after, for the rest.
see X for where that livesis the shape to avoid. - Enumerable facts go in a table: variables, flags, per-multiplexer differences.
- A code line fits 90 columns, trailing comment included. The renderer scrolls, it does not wrap, so a long comment goes on its own line above the value.
- Every
##opens with a one-line summary, before any detail. - No raw HTML and no
<details>. The site drops both without a word. Tables and blockquotes are safe.
- Every verb is spelled
bin/collie <verb>and implemented once, incli/.scripts/collie-ctl.sh <verb>is a bootstrap shim that compiles the binary when the checkout has none andexecs it — it implements nothing, and its path is frozen because Herdr <0.8.0 invokes the action set cached at install time (ADR 0006). Teach the binary; don't add logic to the shim. collie linkpublishes~/.local/bin/collieas a SYMLINK to the checkout's binary — never a copy, never a wrapper script, and never as a side effect ofbuild/update(ADR 0021).unlinkremoves that name only when it points at this checkout.- There are two checkout shapes, and
updatehandles both.herdr plugin installdoes not clone — it leaves a detached, shallow checkout, sogit pullcannot run there; a linked clone sits on a branch. One predicate (git symbolic-ref -q HEAD) picks the strategy, and the same predicate stopsupdatere-linking a managed checkout — a re-link re-registers the plugin as local and Herdr then refusesherdr plugin install, the operator's only other way to refresh (ADR 0006). - Frontend changes (
web/): rebuild withbun run build(root) orcd web && bun run build. The bridge servesweb/distfrom disk at request time, so on the deployment host a rebuild is immediately live — no restart. - Backend changes (
bridge/*.ts): Bun does not hot-reload the service — you mustsystemctl --user restart collie. Forgetting this is the #1 "my change didn't take" trap. bun run build(root) is now one definition: it runscollie build, which gates onscripts/check-version.sh, installs both trees, typechecks both sides (root tsc + web tsc), compilesbin/collie, builds web todist-staging, and swaps both artifacts in last — a failed build never empties a liveweb/distand never replaces the running binary. The binary is always renamed into place, never written through (the running service keeps its old inode until it restarts). Barecd web && bun run buildskips all of that; don't ship from it.- Typecheck, and the trap in it: the ROOT
bun run typecheckdoes not coverweb/'s test files. Onlycd web && bun run typecheckdoes. So a change to a shared type can leave the root check green whilebun run build— and thereforemake deploy— fails on stale test fixtures. Run both, every time:bun run typecheckat the root andcd web && bun run typecheck. This has shipped a broken tip toorigin/v1once; it is not theoretical. - Tests: frontend
cd web && bun run test(Vitest + jsdom + Testing Library + MSW); backendbun run testat the root, Bun's own runner over every pure-logic module inbridge/(access checks, state engine, config, journal adapters, notifications, uploads, …) plusscripts/collie-cli.test.sh, which drives every verb of the compiled binary in a sandboxed HOME, andscripts/collie-ctl.test.sh, which pins the shim's delegation and bootstrap. Neither of these opens a browser, the browser tier is separate, see "Browser tests" below. A pre-push hook (scripts/git-hooks/pre-push) runs both before every push — override once withSKIP_TESTS=1 git push(see Linting → escape hatches). The bits that genuinely needBun.serve/Bun.connect(HTTP handlers, the socket client) stay unit-untested — Vitest-on-Node can't run them, so keep new backend logic pure/injectable enough forbun test, or exercise it throughweb/. flake.nixis the build environment, andnix developis the reference. It pins the five tools this tree is built and checked with — Bun, Node, git, tmux, zellij — at one nixpkgs revision, andrelease.ymlbuilds every published payload inside it. Build and check through the flake where you can (nix develop --command bun run build, and the same forlint,testand both typechecks); a committed.envrccarriesuse flakefor direnv users, and nobody is obliged to allow it. Do not install a build tool by hand to get past a version problem — move the pin, or say why you did not. Herdr is deliberately not pinned: it is the product's peer, not one of Collie's build tools.nix developis the reference, not a requirement. A developer with no Nix works exactly as today —bun run build,bun run lint,bun test, unchanged. No script, hook orbun runtarget needs Nix to be installed.- Never touch
flake.lockoutside achore(release): x.y.zcommit. It records which toolchain a published binary was built with, so a lock that moves in a feature commit means the binary a bisect builds is not the binary the release built, and nothing in the tree says when it changed. The release recipe (Versioning, step 5) is where it moves;scripts/check-flake-lock.shrefuses it anywhere else, withSKIP_FLAKE_LOCK_CHECK=1as its own hatch. The guard judges a change to the lock, so the first commit that adds it passes without a release commit. The pinned Bun must also stay at or aboveMIN_BUNincli/update-check.ts— they are one fact, andscripts/check-flake-bun.test.tsfails when they drift apart. - Service:
systemd --userunitcollieon the deployment host; logsjournalctl --user -u collie -f. - Dependencies must be 7 days old to install (
bunfig.toml+web/bunfig.toml, mirrored in.npmrcfor npm users) — a compromised release is usually pulled within hours. A brand-new version resolving to an older one is the rule working, not a bug; CI's--frozen-lockfileis unaffected. - TS is strict on both sides, with
noUnusedLocals/Parameterseverywhere.web/additionally enforcesverbatimModuleSyntax+erasableSyntaxOnly(useimport type, no parameter-property shorthand there). The bridge tsconfig does not enable those two — bridge code uses parameter-property shorthand by convention; keep each side consistent with itself.
A real Chromium opens the app. Vitest still covers every unit test; this layer sits above it and tests nothing Vitest already covers.
- Tier 1 runs in CI, on every push.
cd web && bun run e2ebuilds the web bundle, serves it, and drives Chromium atphone(390x844) andtablet(820x1180), declared as four projects inweb/playwright.config.ts:app-phone,app-tablet,states-phone,states-tablet, plus a fifth,app-phone-webkit, the sameappspecs under WebKit, Safari's engine. That one is always on in CI and opt-in elsewhere (COLLIE_E2E_WEBKIT=1), because Playwright's WebKit build cannot launch on Fedora;make e2e-webkitat the workspace root runs it inside an Ubuntu distrobox there. It exists because Safari disagrees with Chromium on geometry a unit test never sees (web/e2e/belt.spec.tsholds the first such case, 2026-09-14). Theapptarget servesweb/distand answers every/api/*request fromweb/e2e/fixtures/api.ts; it never touches a live bridge. Thestatestarget runs the playground on port 5199, the waymake playgroundruns it, and answers no API at all. Cases live underweb/e2e/: todaysmoke.spec.tsandhandles.spec.ts, plus the named cases proving a hand check,web/e2e/issue-180.spec.tsandweb/e2e/m24-crew.spec.ts, andweb/e2e/service-worker.spec.ts, which builds two bundles underweb/e2e/.builds/to prove an old shell picks up a new one. - Tier 2 runs by hand, from the workspace root:
make e2e. It drives the dev lane's lead, instancenexton port 8788, reads only, and never restarts or rebuilds anything. Its cases live underweb/e2e/live/, sharing the harness inweb/e2e/live/live.ts. It never runs in CI:web/e2e/live/playwright.config.tsthrows whenCIis set, so a copied command cannot point a runner at somebody's machine. - Tier 3 is the VM lab: a real three-machine crew and a real update run. It is named here as the end state and is not built.
Fixtures. Tier 1 imports the same fixture modules the vitest suite already uses,
web/src/test/handlers.ts and web/src/playground/fixtures.ts, and feeds them to page.route. A
case never invents its own payload.
The selector rule. A case addresses a role and an accessible name, getByRole or getByText,
never a CSS class. No case adds a data-testid anywhere in web/src. The one exception is the
playground: every card carries an explicit data-state handle, set by a state prop on Card
(web/src/playground/harness.tsx), never derived from its label.
The locale rule. A case that checks translated text pins the locale before the first
navigation, by writing the bare locale code into collie:locale:v1 in localStorage
(web/src/lib/i18n/index.ts). There is no URL parameter and no Accept-Language path. Assert
against the string in web/src/lib/i18n/messages/<code>.ts, never against English's absence.
Adding a case.
- Tier 1,
apptarget: add a.spec.tsunderweb/e2e/, callinstallApiStub(page)fromweb/e2e/fixtures/api.tsin abeforeEach, thenpage.goto("/"). - Tier 1,
statestarget: add a case toweb/e2e/handles.spec.tsor a sibling.spec.tsmatched bySTATES_TEST_MATCHinweb/playwright.config.ts,page.goto("/playground.html"), and address a card by[data-state="…"]. - Tier 2: add a
.spec.tsunderweb/e2e/live/, importtest/expect/messagefromweb/e2e/live/live.ts. Read only, no pairing, no "Take over", no update, no device revoke, no pane close or rename.
Reading a failure. A failed case leaves a screenshot and, on a retry, a trace
(screenshot: "only-on-failure", trace: "on-first-retry" in web/playwright.config.ts); CI
uploads both under if: failure(). Open the HTML report (playwright-report/, ["html", { open: "never" }]) to see them together with the run log.
Two prohibitions. Never assert a pixel, no toHaveScreenshot, no baseline images, a screenshot
is evidence for a person, not a comparison. And never point Tier 2 at anything but the dev lane.
The pre-push hook (scripts/git-hooks/pre-push) runs the backend suite and cd web && bun run test. It does not run the browser suite, a browser download does not belong in a hook that fires
on every push. cd web && bun run typecheck covers e2e/; the root bun run typecheck does not.
- oxlint is the linter and
bun run lintis how you run it — oxlint's own correctness/suspicious/perf catalog plus all 15 rules of the vendored anti-slop plugin, aterror. Don't add ESLint or biome (ADR 0019). - One config,
.oxlintrc.jsonat the root — the editor, the PostToolUse hook, pre-commit and CI all shell out to it with no flags of their own.web/has no lint script; the root config already coversweb/src. Only the full-tree run (CI) defines "passing". collie builddoes NOT lint, and must not learn to.buildis the operator's path — a clean install andupdateboth run it on the operator's machine — and oxlint's allocator SIGABRTs below roughly 7 GB of RAM, which bricked installs on ordinary boxes (1.0.0-beta.44). The mux-name check left with it; CI covers it throughscripts/check-mux-names.test.ts.- A finding is fixed in the code, never suppressed and never cleared by downgrading a rule.
There are zero
oxlint-disablecomments in the tree and that is the policy. A// SAFETY:comment must state the invariant that makes the assertion sound — "safe, trust me" clears the rule and fails review. - Changing what's enforced goes through the rationale table in
ADR 0019, which also holds the
fix-shapes for the rules you'll trip most and the reasoning for the scoped
no-runtime-typeofparse-boundary overrides. Per-rule reasons live as comments at the rule in.oxlintrc.json. - Don't edit
tools/oxlint/anti-slop/— it's a vendored copy, overwritten at the next re-vendor. Re-pinning upstream is the maintainer's deliberate act, and the diff gets a human read: vendored code is copied, not installed, so the 7-day dependency age gate never sees it. overrides.filesglobs match the full path — a glob must start with**/or it silently matches nothing. Verify any new one with a planted violation in-scope and a negative control out.
Each guard has its own name on its own surface, so skipping one never disarms another. Use one for a single command; never export one.
| variable | surface | skips |
|---|---|---|
SKIP_VERSION_CHECK=1 |
git commit (pre-commit hook) |
the version-consistency + bump-on-change guard |
SKIP_LINT_CHECK=1 |
git commit (pre-commit hook) |
oxlint over the staged files |
SKIP_CREW_WIRE_CHECK=1 |
git commit (pre-commit hook) |
the crew-wire decision guard |
SKIP_FLAKE_LOCK_CHECK=1 |
git commit (pre-commit hook) |
the flake.lock-only-in-a-release guard |
SKIP_TYPECHECK=1 |
bun run build / collie build |
both typecheck steps |
SKIP_TESTS=1 |
git push (pre-push hook) |
both test suites |
SKIP_TAG_CHECK=1 |
git push (pre-push hook) |
the untagged-release warning |
The pre-commit hook's four guards are independent — SKIP_VERSION_CHECK=1 does not disarm the
lint guard, the crew-wire guard or the flake.lock guard.
- The UI has a written design language — read
DESIGN.mdbefore building a visual component. Its first rule is the one that keeps getting broken: look inweb/src/components/ui/for an existing primitive, and promote one the moment a second place needs the same visual idea. It also holds the no-shift rule, the radius and line tokens, the mono-vs-sans split, and the Tailwind v4 traps that each cost a day. - Before adding any recurring visual pattern, check
web/src/components/ui/for the primitive; if none exists, the primitive comes first, in its own commit. A pattern built at a call site first is one that never gets promoted — the alert family cost six components that way. - Check UI states in the playground (
web/src/playground/,cd web && bun run playground, README → "The states playground") before changing a banner, the mark, the boot splash, the idle lock, or the crew page — it renders every state at once. Never import playground code from app code. - Data flows through React Router (
createBrowserRouter, data mode): route loaders (web/src/lib/loaders.ts) fetch the snapshot + pane; polling isuseRevalidator()on an adaptive interval (web/src/hooks/use-polling.ts); mutations are directlib/api.tscalls followed byrevalidator.revalidate(). There is no TanStack Query — don't reintroduce it. - Routes (
web/src/router.tsx):/,/space/:spaceId,/settings,/pane/:paneIdand/pane/:paneId/history. The router instance is module-scoped so it keeps its location. - The idle lock pauses; it does not gate. It only appears when Collie is left open, visible and
untouched — a hidden page never locks, and returning to the foreground auto-resumes. It covers a
still-mounted router (unmounting it ate in-progress composer drafts) and pauses polling through
lib/idle.ts. Don't restore it as a security control or re-describe it as one (ADR 0007). - "Type into terminal" is armed by a named choice and dies with the pane view. Long-pressing Send
opens a menu; the hold never arms it alone. It disarms on a pane switch, a composer lock (gone pane,
read-only, idle pause), a hidden page, and a failed batch — never persisted, never restored. Don't
lift it, and don't add the reply guard's
composerReadypre-flight to it; the reasoning for both sits inweb/src/components/send-mode-menu.tsx's header. - The phone moves the operator's terminal only on the "Show in terminal" tap (
setFocus, one row in the pane sheet).MuxPane.focusedis a fact the snapshot reports, and the terminal never moves the phone in the other direction — that may not become a side effect of navigation (ADR 0031). - The "Full reply" card is gated on an identity check, not a length check. The pane view re-shows
the newest journal turn only when the tail probe proves that turn IS the message on screen and the
head probe shows its start is not. A failed tail probe means render nothing — don't relax it to "the
newest journal turn", which would present a stale or still-streaming reply as the one you're reading
(
web/src/lib/latest-reply.ts). It replaces the rows it covers rather than sitting above them (hideLeadingLines), and that hiding is render-only, applied after every grammar has run over the whole screen — never trim the text a detector, guard or draft probe sees. - The operator's rows in
commands.tomlreplace the shipped command catalog on the panes they address, never merge into it (ADR 0018); the bridge re-reads the file behind an mtime check, so edits are live and need no restart. - That replace-law runs PER SURFACE, and the harness bar is the second surface — a row with
bar = truegoes on the bar above the keys as well as into the palette, and the bar's replace-or-fall-back runs over thebar = truerows ALONE, so one bar row never blanks the Agent palette (ADR 0043).web/src/lib/harness-bar.tsis a VIEW ofagent-commands.ts, never a second catalog: a command it spells that the catalog lacks is a failing test, and a row for a capture-sourced harness needs anevidencepath that exists.commandsForis unchanged. keys.tomliscommands.toml's sibling — the operator's rows replace the Keys tray's shipped Ctrl presets on the panes they address (ADR 0018 again), and only those presets: the tray's keyboard is fixed. Both files share one reader (bridge/operator-file.ts) and one scope ladder (web/src/lib/operator-scope.ts); teach both, never one.quick-replies.tomlis the third on that contract — the operator's groups replace the Quick dock's shipped phrases on the panes they address (ADR 0018 once more), shell panes included when a row is scoped to them. Same reader, same scope ladder: the three files differ in grammar and never in posture, so teach all three or none.theme.tomlis the operator's fourth file, and it is the one that ADDS rather than replaces — its[[font]]rows put extra UI typefaces UNDER the shipped three in the Settings picker, and the bridge serves the files read-only from<config-dir>/fontsatGET /api/fonts/<basename>. Same reader, same mtime liveness; the opposite posture, because a font cannot fire an action and so shadows nothing (ADR 0033). Don't dilute ADR 0018's replace-law to cover it.launchers.tomlis the operator's fifth file, and the only one whose rows CREATE a pane — its rows are the allowlistPOST /api/launchmatches exactly, so the client names a row and never supplies a command line. Same reader, same mtime liveness; no scope ladder, because a row that makes its own pane has nothing to address. Do not add a second allowlist and do not let the client supply a command line.- Every user-facing string goes through
t()/tn()from@/lib/i18n, and a component that calls them subscribes viauseLocale()so it re-renders on a locale (or lazy-dictionary) change.messages/en.tsis the source of truth; all six dictionary files change together, enforced bytsc. Not translated: terminal/agent output, quick replies, menu/dialog labels the screen printed, key caps, crew role names, push notifications, service-worker strings, crew-link errors, and the slash-command descriptions inweb/src/lib/agent-commands.ts(another tool's vocabulary — deferred) (ADR 0030). - PWA via
vite-plugin-pwa(web/vite.config.ts): manifest +sw.js, registered manually fromvirtual:pwa-registerinmain.tsx(bundled = CSP-safe). Install/SW need a secure context — over plain HTTP they no-op silently (Chrome insecure-origin flag, or HTTPS, to test). - The app's UI typeface is a per-device SETTING, not the maker's choice — System / Space Grotesk /
Aldrich (default), plus whatever the operator declared, applied pre-paint as a root class by
web/public/theme-init.jsand stored incollie:design:v1(web/src/lib/design.ts). CSS owns every stack; JavaScript only swaps a class name. What survives the reversal is the other half of the rule: the chosen face never dresses agent-authored text —font-monoandfont-contentare untouched by it (ADR 0033). - The bundled Nerd Font subsets stay lazy and out of the precache —
unicode-rangeper face, version in the filename, cached first-use bysw.ts. Don't add them toglobPatterns, don't widen a range, don't move subsetting into the build; the reasoning for each sits at the line that would change (web/src/index.css,web/vite.config.ts,scripts/build-nerd-font.sh).
- RPC is one-shot: one request per connection; the server closes after one reply.
idmust be a string. Onlyevents.subscribestreams. pane.send_keysgrammar is+-joined, not tmux:ctrl+c(NOTC-c),shift+tab,Up,Tab,Escape,Enter,Backspace.PageUp/Home/End/Deleteare unsupported.- A long send to Claude is verified via its paste placeholder — anything past Claude's paste
threshold collapses in the input box to
[Pasted text #N +M lines]; the guard accepts that token as send evidence only when it is consistent with the message just typed. Don't try to dodge the threshold by chunking sends (ADR 0010). - A password prompt is recognised so Collie can SAY what it is, never so it can send — no automatic Enter, no relaxed verification, no secret channel; the remedy offered is the operator's own tap on "Type" (ADR 0017). Recognition does one thing on its own: it drops the stored draft and stops persisting keystrokes.
- Pane output is rendered as React text nodes (never
innerHTML); the ANSI parser only derives colors/weights. Keep it that way — it's the XSS boundary. Strict CSP + same-origin gate stay. - Collie runs no terminal emulator —
pane.readreturns Herdr's already-rendered grid, so the parser needs colour and nothing else. Don't add one on either side, and don't reach forterminal session observe/control: a stale mirror is a transport problem, cursor position is an upstream ask, andcontrolresizes the shared PTY (ADR 0008). - A table pans; the mirror around it keeps wrapping —
lib/table-run.tsgroups a table's rows into a single scroller inside the wrapping<pre>(ansi-output.tsx). One scroller per table, never one per row. Each grammar anchors on a row nothing else prints — a markdown delimiter row, a+---+rule, a frame row carrying a cross — and then grows by agreement, so a menu, a chrome box or a rule beside a table is never claimed.table-run.test.tsgates it against every capture infixtures/panes; the argument sits intable-run.ts's header. - Never use a
dark:variant inside the mirror<pre>— it tracks the root theme, which is backwards in a surface that renders dark under every theme and inverts in light (ADR 0002). Fails silently;ansi-output.test.tsxguards it. - The plan dialog's last row is a text input, and it is never a button — its label is only a
placeholder while the box is empty, and its digit merely focuses the field. While
❯sits on it the terminal swallows every digit as a character, so no button on that dialog may be pressable; while it holds text, Collie must not type into it (the caret resets to position 0, so it would prepend). A long value wraps the row rather than windowing it, which re-flows the screen above — so nothing may read that row as one line, and no mid-flight identity may reach above the question. Feedback is sent as a verified sequence, never a keystroke — the ground truth for every state isPLAN_FEEDBACK_NOTES.md; re-walk it before touchingharness/claude/prompt-select.tsorlib/prompt-action.ts. - A generically-detected menu emits only the keys the screen printed — the footer's
<key> to <verb>hints plus the arrows it advertised. Never synthesise a digit from a numbered row: in the/modelpicker a digit confirms and persists the user's default. The generic grammar (harness/claude/menu.ts) runs LAST, after every specific detector declines, and an unrecognised modal refuses composer typing via the adapter'scomposerReadypre-flight (ADR 0009). - A composed key queue never outlives its dock — closing Keys discards it (guarded by a two-tap confirm on the drawer transition, not the ✕). Don't lift or persist it: a queue surviving into a later open would let Send fire a stale sequence into a pane that has moved on (ADR 0005).
- The statusline-run bound in
chrome.tsguards less than it looks — a dialog below the box is refused by the border/prompt checks and by the blank line Claude paints above its footer hint, never by the row count. Size it up if a real statusline needs more rows; don't delete it, and don't credit it with protection it doesn't provide (ADR 0004).chrome.test.tspins both halves. - The Herdr socket is never dialled across a machine boundary, and no Herdr vocabulary crosses a crew link — the lead consumes a peer's Collie API, never its Herdr socket (ADR 0011).
- How soon Collie sees an out-of-band change is DECLARED (
topologyLatency), never measured, andrefresh()is on the floor of the port so the phone can ask for a look now (ADR 0031). Every mutating route refreshes before it answers;POST /api/refreshis a read because it mutates nothing.
bridge/journal/ reads the agent's own session log off disk, per harness (claude / codex / pi,
registered in registry.ts). Two other things touch the filesystem, and neither is an exception to
the rule below: stt.json in the state dir when the operator ran collie stt setup
(ADR 0029), and the operator's own
font files under <config-dir>/fonts, served read-only through bridge/operator-fonts.ts
(ADR 0033).
The law is that the journal is the only place a CLIENT-SUPPLIED value becomes a path — and even
there it is a pane id, never a path. GET /api/fonts/<basename> does not become a second such place:
the request's name is looked up in the rows the operator's own theme.toml declared and that
row's path is taken, so a name nobody declared is refused before any path exists. The containment
rule in files.ts then runs anyway, on both surfaces and as an
independent second check: every path about to be read goes through containedRealpath — after
symlink resolution, on the real paths, including paths derived from one already checked. Reuse that
function; don't write a third answer to the sentence in bold. Run
bun scripts/journal-probe.ts against real logs after touching an adapter; unit tests pin the
grammar, the probe catches on-disk format drift.
Loopback bind only · exactly one hardened front door — tailscale serve (never funnel) or a
conforming reverse proxy per docs/deployment.md Variant C (COLLIE_SKIP_SERVE=1) · same-origin gate ·
optional identity/device gates · strict CSP. A socket call can type into a real terminal — treat a
collie as remote shell access.
The loopback gates fail closed, and the crew link is exempt by construction, never by relaxation.
Host validation is on by default (COLLIE_ALLOW_ANY_HOST=1 opts out), COLLIE_TRUSTED_USER rejects
an ABSENT Tailscale-User-Login as well as a wrong one (COLLIE_TRUSTED_USER_OPTIONAL=1), a
non-loopback bind refuses to start (COLLIE_ALLOW_NON_LOOPBACK_BIND=1), and a non-loopback TCP peer
is refused. A collie in a crew is exempt from the bind refusal and /crew/v1/* from the peer
check — a member is dialled across a machine boundary and that surface carries pinned mutual TLS
plus the crew secret (ADR 0013). The
exemption is granted by POSITION — the peer check sits after the federated dispatch in
bridge/server.ts — so no crew path is ever spelled there. The standby door is its own listener on
its own COLLIE_STANDBY_HOST and neither gate reaches it; don't route it through the front door's
fetch to share them.
The bridge makes no outbound call and spawns no long-running child for content — unless the
operator ran collie stt setup. Speech-to-text (bridge/stt/, CLI cli/stt.ts) is a registered
provider seam, absent until that verb writes stt.json: it then holds a provider credential at 0600,
opens an operator-configured outbound path carrying microphone audio, and on the codex provider
spawns a codex app-server child. All three costs are declined by doing nothing, the local-engine
configuration keeps the egress on loopback, and the wire identity is probed honest-first and recorded
(ADR 0029). Setup is a CLI act, never
a web form, for the reason pairing is.
Two device gates guard writes, independently, and compose by AND. COLLIE_DEVICE_HEADER trusts
a name a proxy injects; pairing (bridge/pairing.ts, collie pair / collie devices) requires a
bearer credential the device holds, and is on exactly when the registry is non-empty. Reads stay
ungated by both. Neither applies to /crew/v1/*, which has its own two factors. The reasoning sits in
bridge/pairing.ts's header; don't collapse the two gates into one.
Collie manages exactly one front door: tailscale serve — the CLI (cli/serve.ts) publishes it,
records the mapping in tailscale-managed-handler, and only ever tears down a mapping matching that
record.
Every other tunnel (NetBird, ZeroTier, Cloudflare Tunnel) is COLLIE_SKIP_SERVE=1 + docs/deployment.md
Variant E: the operator owns the ingress, Collie publishes nothing. Don't add a second managed front
door — ADR 0001.
The crew link (lead↔peer, /crew/v1/*) is specified in CREW_PROTOCOL.md
— two factors gate it (pinned mutual TLS + crew secret), and a peer publishes no front door
(ADR 0013); the one exception is the
deputy's standby door — bound, never published, armed by silence and spent by the operator's
pairing credential (ADR 0027 ·
ADR 0028).
Touching the crew wire surface forces a protocol decision — a commit staging one of the
wire-shape files in bridge/crew/ must also stage CREW_PROTOCOL.md (additive-optional, §7.1) or
bump CREW_PROTOCOL_VERSION (not expressible that way). scripts/check-crew-wire.sh is guard C of
the pre-commit hook; a pure refactor takes the SKIP_CREW_WIRE_CHECK=1 hatch
(ADR 0025).
Code reaches a peer over the operator's own SSH, never over the crew link — crew add installs
it and crew update levels it, both pushing the lead's own commit as a git bundle; the link
carries runtime data and never becomes a distribution channel
(ADR 0016, addendum 2026-09-04: a peer may also
level ITSELF to the release its lead is running, fetching that public tag from GitHub over anonymous
HTTPS on its own decision, which adds no code, route or verb to the link). How the operator
reached a member is remembered locally in crew-ops.json, which is never a wire field and never merged into the trust
store.