Imported from zgeoff/atc (
AGENTS.md). Install upstream withnpx skills add zgeoff/atc. Copyright stays with the author.
Agent Guidelines
Operations
- AGENTS.md is generated from
agents/shared.mdandagents/project.md— edit the partials, never AGENTS.md itself. The shared partial is synced from zgeoff/tools; cross-project rule changes belong there. - Perform all work on a branch in a git worktree under
.worktrees/(e.g.git worktree add .worktrees/<branch> -b <branch>) — never commit directly onmain. - Use Conventional Commits for all commit messages.
- A squash merge makes the PR title the commit subject, so a PR title is a Conventional Commit too.
feat(#412): add the retry budgetis a title;add the retry budgetis not. - Commit subjects and PR titles use the imperative mood ("add X", never "added X" or a bare noun phrase).
- Open PRs against
mainusing the PR template (.github/PULL_REQUEST_TEMPLATE.md). Descriptions are condensed: lead paragraph ≤2 sentences, one-line bullets, ≤150 words — write the short version first, don't draft long and trim. - After pushing, link the PR URL in your response.
- A PR is ready only when its checks are green: watch CI (
gh pr checks <n> --watch) after opening or updating, and report a failure with what you're doing about it.
Code style
Mechanically enforced rules (oxfmt, oxlint, format-codemod) aren't repeated here — this file covers what tooling can't check.
- One primary export per file, and the file name kebab-cases that export (
with-jest-context.tsexportswithJestContext). Exceptions:index.tsentrypoints,types.tsfor a package's shared types, and side-effect-only modules, which are named for what they do (augment-bun-test.ts). - Module order: imports, the primary export, then private helpers in composition order (depth-first) — never helpers first. Supporting declarations (consts, interfaces, type aliases) sit directly above their first use, never below it and never leading the file; types for the primary export's signature may sit just above it.
- Acronyms stay uppercase in identifiers (
runCLI,parseCLIArgs,ASTNode,pkgURL,isPackageJSON) — except when one starts a camelCase name, where it lowercases whole (cliPath,astNode). ID counts as an acronym:userID,sessionID— neveruserId— andidTokenwhen it starts a name. File names are unaffected: kebab-case lowercases everything (parse-cli-args.tsexportsparseCLIArgs).
Function naming
Every function name starts with a prefix from the closed list below: pick from it, or extend this file in the same PR that introduces the new verb. The prefix is a contract — a reader should know the function's shape without opening it.
Predicates — return boolean, no side effects:
| Prefix | Contract | Example |
|---|---|---|
is |
type or state test | isVarDecl |
has |
containment, possession | hasBlankLine |
can |
capability | canResize |
should |
policy decision | shouldSkipFile |
needs |
requirement | needsBlankLine |
Pure producers — result comes from arguments alone, no side effects:
| Prefix | Contract | Example |
|---|---|---|
build<Result>[From<Source>] |
default constructor for values; drop From<Source> when no single source |
buildEditsFromAST |
define<X> |
identity; its only job is compile-time constraint of its literal argument | defineErrors |
parse |
unstructured input → structure, invalid input reported | parseSource |
encode |
structure → its defined compact or wire form, reversed by decode |
encodeState |
decode |
encode's output → the original structure, malformed input reported |
decodeState |
derive |
one-way cryptographic derivation from secret material | deriveAvatarKey |
plan |
compute an action without performing it | planGapEdit |
pick |
select among known alternatives | pickMode |
find |
search that can miss — null/undefined on miss | findPrevious |
get |
cheap access that cannot miss (throwing on a broken invariant is fine) | getNodeEnd |
collect |
gather from a traversal or scan | collectChildNodes |
count |
how many | countNewlines |
split |
one value → parts | splitLines |
merge |
parts → one value | mergeWindows |
sort |
reorder | sortEdits |
format |
value → human-readable string | formatRange |
render |
structure → output text or markup | renderHunk |
normalize |
variant forms → the canonical form | normalizePath |
resolve |
follow indirection to a concrete value | resolveBinPath |
expand |
compact form → full form | expandInputs |
compress |
value → its reversible compact encoding | compressGraph |
decompress |
reverse a compress encoding (non-encoded shorthand is expand) |
decompressGraph |
to<Result> |
cheap representation change | toPosixPath |
transform |
a package's own source→source operation | transform |
Effectful — touches the world (filesystem, streams, processes, registries):
| Prefix | Contract | Example |
|---|---|---|
apply |
perform previously planned changes | applyEdits |
create |
bring a resource into existence (file, directory, process) | createWorkDir |
claim |
atomically take exclusive ownership of a work item or resource; ownership ends at commit or an explicit release | claimNextChain |
read |
pull raw content from filesystem or network into memory | readSource |
load |
read and parse into a ready structure | loadConfig |
write |
persist to the filesystem | writeOutput |
remove |
delete a resource | removeStaleDist |
update |
mutate existing state or resource in place | updateIndex |
upsert |
single-statement insert-or-update keyed by a natural or composite key, refreshing the conflicting row's columns in place | upsertUser |
set |
assign a store's named state slice wholesale — the store-setter idiom; partial mutation is update |
setSelectedNode |
toggle<Flag> |
invert a boolean state slice | toggleDevCamera |
reset |
return state to its initial value | resetCombatState |
print |
write to stdout/stderr | printHelp |
run |
execute a subprocess, task, or whole pipeline | runCLI |
check |
evaluate and report findings; effects allowed per mode | checkFile |
try<X> |
X with failures captured as a value instead of a throw | tryCheckFile |
register |
add to a registry the caller doesn't own | registerMatcher |
subscribe |
attach a listener to an event source, returning or enabling detachment | subscribeToTicks |
unsubscribe |
detach what subscribe attached |
unsubscribe |
assert |
throw when an invariant doesn't hold | assertSpan |
require |
throw unless a runtime condition holds — a guard real input can trip (assert covers invariants) |
requireAuth |
verify |
test a claim or credential against evidence, rejecting on mismatch | verifySession |
emit |
dispatch an event or notification | emitProgress |
send |
transmit a payload to a remote receiver (fire-and-forget or RPC — no resource semantics; REST mutations are create/update/remove) |
sendWebhook |
wait |
block until an event or condition resolves; may return the awaited value | waitForMessage |
setup |
prepare the environment or fixture the following code assumes; teardown reverses it |
setupTest |
teardown |
release what setup prepared |
teardownTest |
start |
put a long-running resource into service (server, worker, poll loop); stop reverses it |
startQueues |
stop |
take a long-running resource out of service, releasing what start acquired |
stopWorker |
drain |
consume a pending backlog until empty | drainJobs |
Wrappers and factories — the result is behaviour, not data:
| Prefix | Contract | Example |
|---|---|---|
with<X> |
HOF that runs a callback inside a context | withJestContext |
make<X> |
factory whose result is itself a function | makeExcluder |
Framework conventions — where the ecosystem's prefix is load-bearing, it wins:
| Prefix | Contract | Example |
|---|---|---|
use<X> |
React hook — the prefix drives rules-of-hooks linting; helpers inside a hook follow the normal taxonomy | useDebounce |
on<Event> |
event-callback prop or parameter | onRowClick |
handle<Event> |
local implementation passed to an on<Event> prop — the idiomatic React pair; the handle ban applies everywhere else |
handleRowClick |
handle<LifecycleEvent> |
implementation of an engine lifecycle callback, keyed by the engine's lifecycle-event enum | handleTick |
Banned — each is a vaguer or synonymous form of a listed verb; use that one instead: handle
(except the handle<Event> framework conventions), process, manage, do, perform (say what
it does), execute (→ run), compute (→ build), fetch (→ read), save/store (→
write), delete (→ remove), search/lookup (→ find/get).
Algorithm-native vocabulary (walk, backtrack, slideDiagonal) is allowed inside the module
implementing that algorithm — forcing list verbs onto textbook terms hides the algorithm.
Dependencies
- Pin exact versions — no
^/~ranges. (bun addsaves exact automatically viaexact = truein bunfig.toml — the rule applies to hand-written edits.)
Review bots
CodeRabbit reviews every PR. Its shared config lives in the zgeoff/coderabbit repo, and a repo-root
.coderabbit.yaml with inheritance: true layers repo-specific settings on top. CodeRabbit reads
this file as its guidelines. A repo that runs another review bot names it and its config in
agents/project.md, and these rules cover that bot too.
- A PR is ready only after every bot review is read and every finding is answered: a fixed finding's
reply cites the commit that fixed it; a declined finding's reply states the reason — when a
finding contradicts this file, this file wins and the reply names the rule. Reviews land within a
few minutes of opening; read them with
gh pr view <n> --commentsandgh api repos/<owner>/<repo>/pulls/<n>/comments. A finding outside the diff arrives in the review body, not as a thread, so its answer is a PR comment. - Resolve a thread once its reply is posted, fixed and declined alike (GraphQL
resolveReviewThread). A finding the agent cannot confidently judge is escalation, not disposition: reply saying so and leave the thread open for a human. - Never teach a bot through chat (
@coderabbitailearnings and the like) — a correction to bot behaviour is an edit to its config, reviewed in a PR. - Bots review a PR once, at open; an agent invokes a re-review only when asked. The exception is a
PR that got no review at all, such as one opened before the bot was installed: request it once
with that bot's documented trigger, such as
@coderabbitai reviewfor CodeRabbit.
atc
atc is a terminal control tower for coding-agent sessions (Claude Code, Grok Build, and Codex CLI):
a daemon (atc daemon) hosts stock agent CLIs in PTYs, and thin TUI clients drive them over an
NDJSON protocol behind a keyboard-driven session list with hook-driven attention routing. No panes,
no tiling, no mouse. See docs/architecture/overview.md for how the pieces fit; the README
documents install and keys, and docs/guides/configuration.md documents config.
Layout
Single package, no workspaces. src/ groups its modules by concern, each still one primary export
per file: daemon/ owns the fleet and per-session runtime state; client/ is the TUI and its
connection to the daemon; agents/ holds the AgentAdapter interface and the Claude, Grok, Codex,
and gateway implementations; store/ is the SQLite state store and its migrations; workspace/
resolves a spawn's workspace source and clones, sanitizes, and archives it; sources/ holds the
spawn picker's discovery sources, the directories, GitHub, and git URL sources the daemon offers;
protocol/ is the wire format, the transport it rides, and the types both ends of it share;
shared/ holds id types, config, and other utilities used across the rest of src/. cli.ts is
the CLI entrypoint, and gateway.ts is the entrypoint of the separate atc-gateway binary, which
must never reach daemon/, agents/, or any module that starts a daemon. A module that exists only
to back one of its subcommands stays beside it at src/ root, while the tui and daemon
subcommands load their subsystems from client/ and daemon/. bun run check:imports fails on an
import cycle and on an import a directory's rule forbids; the rules live in
scripts/check-imports.ts. mcp/ holds the MCP tool definitions and request handling that
mcp-server.ts serves, plus the HTTP transport behind mcp-http-server.ts and the better-auth
authorization server and its pages. federation/ holds the gateway's daemon registry, per-daemon
callers, and id and cursor rewriting, and imports only shared/ and protocol/. test/ holds the
PTY-driven e2e suite, bin/atc is the executable shim. mods/ holds the atc-bridge Claude Code
mod. scripts/ holds repo tooling, not app code.
Runtime rules
- Bun only.
bun test, never vitest or jest.bun <file>, never node or ts-node. - PTYs come from
bun-pty. Never addnode-pty: its fd-socket plumbing delivers no data events under Bun. - The TUI is hand-rolled ANSI on purpose — no TUI framework. Escape sequences are written as
\u001Bescapes, never raw bytes and never\x1b. - Everything the hooks and CI run is a root
package.jsonscript; invoke gates by script name, never by re-spelling the underlying command. - Releases ship compiled binaries (
bun build --compile) next to the source package. Code that needs a file outside the bundle at runtime (a package's native binary, the source tree) checksisCompiledBinary()and takes the path that works withoutnode_modules; the daemon suite runs through each binary in CI withATC_BIN, so that branch is tested. - The live install on a machine runs an installed release: the global MCP server entry, every
service unit, and every long-running
atcprocess start~/.local/bin/atc(or the release's install path), neverbun src/cli.tsfrom a checkout. A pull or branch switch in a checkout then never changes the protocol a live client speaks. Run source builds against a daemon of their own, in a state directory of their own.
Agent integration contract
- Everything specific to one agent CLI lives in its adapter behind the
AgentAdapterinterface — a new agent CLI is an adapter, not a refactor. - Claude sessions are instrumented only through two files atc writes to its state directory and
passes per invocation: the generated
--settingsfile (writeHookSettings), holding hooks (SessionStart,Notification,Stop,UserPromptSubmit,SessionEnd) and a chained statusline, and theatc-bridgemod passed as--plugin-dir. - The
atc-bridgemod's source lives inmods/atc-bridge/.bun run build:atc-bridgeregenerates its embedded copy insrc/agents/atc-bridge-files.ts, which compiled binaries write out. The mods API is early access, so CI validates and tests the mod against one pinned Claude Code version. Never write into the user's own agent config (Claude, Grok, or any future agent); instrumentation an agent cannot take per-invocation is a documented self-install step. - A Claude-compatible backend is a configured gateway, not a new adapter class per vendor: it gets
its own agent id, its own generated settings file, and its
ANTHROPIC_BASE_URLin that file'senvblock. The credential never goes in the file — a helper command supplies it. - Grok attention is a user-installed hook file at
$GROK_HOME/hooks/atc-reporter.json. atc prints that file (atc grok-hooks) and never writes into the user's Grok config. - Codex attention is user-installed hook entries in
$CODEX_HOME/hooks.json, printed byatc codex-hooksand trusted once in the Codex TUI — Codex parses untrusted hooks but never runs them. - Hook and statusline reporters run inside the wrangled session and must always exit 0 — a broken reporter must never break the session it reports on.
- The agent is the naming authority for sessions:
/renamecustom-titles beat user-typed names beat auto-summaries. - A spawn through
atc mcpfrom inside a session makes a sub-session of the caller: listed under it, pinned and killed with it, one level deep. The MCP server reads the caller fromATC_SESSION_ID;detached: trueopts out. - State lives in
~/.local/state/atc/:atc.db(SQLite — fleet, hook-event trail, spawn history) plusstatus.json, which stays a plain file because statusline reporters read it without speaking the protocol.mcp-auth.dbholds the OAuth state ofatc mcp --http; only that process,atc clients, andatc grantsopen it, never the daemon.daemon.lockadmits one daemon per state directory, anddaemon.jsonholds its pid and socket paths for clients whose environment computes other socket paths. The fleet is rewritten on deliberate kills only, so crashes leave a restorable fleet; killed sessions persist as exited entries until a second kill removes them, except on a target that can destroy its host, where only a confirmedsession.forgetremoves one.
Function naming — project verbs
Project additions to the shared taxonomy (keep in sync with zgeoff/function-verb in
.oxlintrc.json): ack, acquire, adopt, answer, attach, boot, copy, destroy,
detach, dispose, draw, forget, jiggle, kill, log, materialize, mint, open,
quit, reconcile, record, refresh, release, renew, restart, restore, revoke,
sanitize, schedule, spawn, suspend, transfer, truncate, yank.
acquire, renew, and release take, extend, and give back a lease that keeps a remote host awake
(acquireLease), as opposed to claim, which takes exclusive ownership.
destroy deletes an execution host and everything on it, which nothing brings back (destroyHost),
as opposed to remove, which deletes one record or file.
dispose releases every resource an object holds in one call (SessionRuntime.dispose), and is
safe to call more than once.
materialize builds a resource on an execution target from a source held elsewhere, through the
target's provider operations, and checks the result before it counts as ready
(materializeWorkspace).
forget drops a session from the fleet for good (forgetSession), destroying its host where its
target can, as opposed to remove, which deletes one record without touching what it describes.
mint generates a new id that atc itself is the sole authority for (mintSessionID), as opposed to
to<Brand>, which trusts an id that arrived from outside atc.
reconcile settles stored state that a stopped daemon left mid-change against the evidence the
change leaves behind (reconcileIdempotencyKeys).
revoke withdraws a credential atc issued so it no longer authorizes anything (revokeGrant), as
opposed to remove, which deletes a resource outright.
sanitize strips a resource of credentials and executable configuration in place and checks that
none remain (sanitizeWorkspaceClone), as opposed to update, which makes no promise about what is
left.
suspend puts an execution host to sleep with its processes kept inside it, so a later wake finds
them as they were (suspendHost), as opposed to kill, which ends a process.
transfer moves content into an execution provider's host (transferArchive), as opposed to
write, which persists to the daemon's own filesystem.
Exempt names (tiny geometry/row helpers and script entrypoints): cols, rows, ptyRows, out,
main, boxTop, boxDivider, boxBottom, boxRow, dimRow.
init, acquireConnection, beginTransaction, commitTransaction, rollbackTransaction, and
releaseConnection are also exempt: kysely's Driver interface fixes these method names, so the
state store's driver implements them under the names the library requires.
Comments
- JSDoc is always multi-line, never single-line
/** … */. - No history or project state in comments — a comment describes the code as it is, never how it got that way or what is planned.
- Comments never name other declarations: renames strand the reference. Describe the behavior instead.
Writing
- All committed prose follows the
docs-writingskill; run itscheck-prose.shover touched docs before committing. - Banned words in all prose (fix on sight):
anchor/anchors on(the data-modelling metaphor — a link anchor like#section-headingis a different word and stays),bites,CAS,ceiling,fence/fencing,floor,load-bearing,seam,surface. One carve-out:Surfaceis the domain term for a session's output producer — that sense is legal; the vague filler sense ("API surface", "surfaces an error") stays banned. - A data artifact never speaks: a row, key, id, field, or endpoint does not
name,say,tell,answer, orknow— it holds, includes, returns, or matches. Three senses ofnamestay: the imperative to the reader, assigning a name ("the-oflag names the output file"), and an error or doc that mentions something in its text.
Testing
Testing conventions live in the shared testing skill and in atc's project-testing skill (the PTY
harness, the fake claude, and the daemon-phase rules); load both. Two rules worth restating here:
never spawn the real claude binary in tests (verification against real Claude Code happens
manually before merging changes to the integration contract), and every gate is invoked as a root
package script. One exception to the first rule: test:atc-bridge runs claude plugin validate and
claude plugin test on the mod, and neither starts a session.
Dependencies
- Exact pins only (bunfig
exact = true); the 7-dayminimumReleaseAgegate applies. When the latest version is younger than the gate, pin the newest version that passes — don't add exclusions for convenience. - A dependency knip can't see gets its
knip.jsonignore entry in the same PR that introduces it, with the reason in the PR description.
