# Tracks: several units of work in one repo A track is one unit of parallel work: its own git worktree, its own branch, its own `handoff.md`, and its own runtime namespace. Several run at once in the same repo and merge back to the same base branch. The worktree is the container; the track is the work. Both words are needed — a worktree left behind with no branch and no handoff is a worktree with no track in it, and that is exactly the state worth naming. Worktrees separate files. They separate nothing else, and what they leave shared is what breaks concurrent work. Layout and spawn rules live in `worktrees.md`; this covers what makes several tracks coexist. For how to actually work in each of the three modes — a track, a coordinator, a coordinator driven by specs — start at `working-modes.md`. ## A track outlives the sessions that work on it A worktree is created once, by `/track`, and removed once, by `/land`. Between those two moments any number of sessions may work in it, each ending with `/handoff` and the next starting with `/pickup` in the same tree. That pair is a resting point, not a finish line. **No agent removes a worktree or deletes a track's branch. Only `/land` does, and only after the merge completes.** An agent that finishes what it was asked to do writes the handoff and stops there; the tree stays exactly as it is. So the resting state of a live track is a branch with commits, a `handoff.md` with next steps, and no agent running. Most tracks spend most of their life in it. Absence of a running agent says nothing about whether the work is done, and is never on its own a reason to land, remove, or clean up anything. ## The working directory is the identity A session's working directory is what every other mechanism reads to decide which track it is in: the statusline's place indicator, chook's `restrict_to_cwd` allow rules and its worktree guards (which take `cwd` per call and hold no session state), and the directory a subagent inherits at spawn. None of them can see intent, so a session working a track from the primary checkout is treated as being in the primary checkout — correctly, because that is where its writes will land. Moving is therefore the first act of working on a track, not a nicety. `/pickup ` enters the worktree before reading anything, `/track`'s brief has its agent `cd` there before anything else, and a session holds one track for its lifetime: picking up a second is refused, because half a session's state stays pinned to the first tree. **A session's directory and its shell's directory are two different things.** `EnterWorktree` moves the session; the shell that launched it never moved. So a track entered that way lasts exactly one session — `exit`, then `claude`, and the new session inherits the shell's directory, which is still the primary checkout. A track meant to run for weeks therefore gets a shell of its own standing in the worktree (`tmux new-window -n -c `, then `claude`), and every session in that window is in track mode from its first token, with no argument to `/pickup` and nothing to re-explain. Entering from the primary checkout is for a single visiting session. `bin/track-path.sh` resolves a track name to its worktree path, for shells that need to `cd` there. `bin/track-place.sh` classifies a directory, and is the one implementation behind both `statusline.sh` and the `SessionStart` hook `hooks/session-place.sh`, which states the mode, the tree, and the governing handoff before anyone types. The hook's silence is meaningful: a repo declaring no tracks and holding no worktrees gets nothing, because imposing track vocabulary on a repo that never opted in is noise on every session start. The main session moves with `EnterWorktree`, passing `path`. It accepts any path `git worktree list` reports and reloads the directory-dependent context, where a shell `cd` moves only the shell. An agent whose directory was pinned at launch cannot use that form for a `.worktrees/` path — pinned agents may only switch to `.claude/worktrees/`, which is the layout `worktrees.md` rejects — so those `cd`. ## What each project declares `tracks.json` at the repo root, committed with the project: ```json { "worktrees": ".worktrees", "base": "main", "install": "yarn install", "verify": "yarn test", "namespace": { "PROFILE": "{track}", "TMPDIR": "{worktree}/.tmp" } } ``` - `worktrees` — directory for worktrees, relative to the repo root. Inside the repo, for the reason `worktrees.md` gives. - `base` — branch every track starts from and merges back to. - `install` — run inside a new worktree before anything else. Omit when a checkout needs no preparation. - `verify` — must pass before a track lands. Omit only when a project genuinely has no gate, rather than leaving it blank by accident. - `namespace` — environment variables that make one track's runtime state distinct. `{track}` and `{worktree}` are substituted. An empty object is a legitimate answer for a project that runs nothing. - `coordination` — optional; repo-relative paths, beyond the roster, that the coordinating layer edits directly at the root: a plan, a design record, a status page. A trailing slash names a subtree. Everything else still goes through a track. A project with no file gets defaults: `.worktrees`, `main`, no install, no verify, no namespace. Enough for a documentation repo, not enough for anything that listens on a port. The file sits at the repo root rather than under `.claude/` because it describes the repo's branching layout, not one tool's configuration — and because chook reads it on every write to decide whether this repo routes edits through tracks, which is not something to go looking for in a vendor directory. **Declaring tracks arms a guard for unattended writes.** `trackRepoRootGuard()` in `chook.mjs` binds only while `CHOOK_UNATTENDED` is set — true for a scheduled tick or any script launched that way, never for a person's own interactive session — and while it binds, denies a `Write`/`Edit`/ `NotebookEdit` landing anywhere in a declaring repo's primary checkout, from any session, no matter where that session is standing — `handoff.md`, `tracks.json`, `.gitignore` and the `specs/` subtree excepted. An interactive session in that same checkout is unbound by it and may edit project files there directly; `primaryCheckoutGuard()` still fences whatever subagent it spawns, so a writing subagent still needs a track regardless of who is asking. A repo whose plan or design record is rewritten between tracks rather than inside one names those paths under `coordination` (`["roadmap.md", "docs/"]`; a trailing slash means the subtree), and they become writable at the root the same way, unattended or not. So adding this file commits the repo to the discipline: unattended code change goes through a track from then on, and a dirty primary checkout still blocks `/land` and a tick's own roster commit either way. The trigger is the file parsing as an object carrying at least one key above, not its name, so a repo whose `tracks.json` is a transit feed or an album's track list is unaffected. Removing the file, or the declaration keys in it, opts back out. ## Why the namespace is the load-bearing part Two tracks running an application or a test suite contend on ports, single-instance locks, application data directories, and `/tmp`, no matter which files they own. Setting the namespace when the worktree is created — not when something is launched — is what prevents it. A launcher invoked without it falls back to a shared default, which looks like isolation until two tracks write the same database. Worked example, the peek checkout: `PROFILE=` is sufficient for that application's own state, because `entry.ts` derives the user-data path from it and unpackaged builds skip the single-instance lock. Driving a specific instance from the command line needs `PEEK_PROFILE=` as well, or the client reaches whichever copy it finds first. Nothing generic knows any of that; it comes from the project's own declaration. ## What is shared no matter what - **One `.git`.** Worktrees share the object store and refs. Each has its own HEAD and index, and git locks ref updates, so concurrent commits in separate trees fail loudly rather than corrupt. The hazard is a repacking operation while other tracks are live. - **The base branch.** Landing is one at a time by nature. One actor lands, and it runs `verify` first. - **The machine.** Caches, global installs, and anything outside the repo. ## Handoff files One `handoff.md` per tree, at its root. The directory is the identity and the filename never varies, which is what lets `/handoff` and `/pickup` work without an argument: both resolve `git rev-parse --show-toplevel` and act on what is there. Two scopes, one structure. Inside a track's worktree it is that track's own handoff — goal, state, next steps for one unit of work. In the primary checkout it is the coordinator's own handoff, in the same Resume Here shape (Goal, Current state, Next steps, Key files & symbols, How to verify), plus decisions made interactively at the coordinator level. Its Next steps can be root-level project work as well as track actions — start, land, rebase, ask. It also carries what a per-track handoff cannot: one line per live track pointing at that track's own handoff, and the cross-track intent — what the set is building toward, what order they land in, which one blocks which. **Never tracked, in any repo.** `handoff.md` is gitignored and `/track` refuses to start in a repo where it is not. Both scopes share one path, so a tracked copy on any branch is a trap: git treats an ignored working copy as disposable and replaces it with the tracked version on any fast-forward, with no conflict and no message. Only a file tracked nowhere is safe from that. The cost is that a handoff never crosses machines by git; each machine keeps its own, and `/tracks` rebuilds the git-derived facts anywhere. **The coordinator handoff never records git-derived state.** `/tracks` derives branch, commits ahead and behind, dirty or clean, agent running, and last commit time from git, and is right every time it runs; anything the coordinator handoff recorded about those would be stale within minutes, so it never tries. Two sessions in the primary checkout can still overwrite each other's edits to it, last writer wins — the same hazard a track's own handoff carries between `/handoff` and `/pickup`, one scope up. Nothing prevents that, deliberately: the alternative is session-awareness in chook, which it does not have and is not worth adding for this. `/track` adds a line when it creates the worktree; `/land` removes it after the merge. A roster accumulating landed tracks is the same failure as a checkout full of stale handoffs, one scope up. An interactive session in the primary checkout may edit project files there directly — root-level project work is what the Next steps above can name alongside track actions. A subagent spawned from that session still cannot: `primaryCheckoutGuard()` denies its writes regardless of what the session itself is doing. Root work gets committed promptly, since a dirty primary checkout blocks `/land` and a tick's own roster commit; see "Enforcement, and its limits". **Add `handoff.md` to the project's `.gitignore`.** A worktree's handoff sits at the same repo-relative path as the roster, so committing it inside a track lands a working note on the base branch when the track merges, and collides with the roster the primary checkout is already keeping there. Neither is a record — anything durable in either moves into the project's docs before the worktree goes. **Add the worktrees directory too.** It sits inside the repo, so leaving it untracked makes the primary checkout permanently dirty from the moment the first track exists. That is not only noise: a spec-driven tick refuses to do anything at all in a dirty primary, so a repo that skips this line gets a coordinator that aborts identically every five minutes. An existing handoff describing different work belongs to another track — read for context at most, never rewritten, and never sidestepped by inventing a disambiguating filename. A checkout holding several handoffs kept apart by hand-written disclaimers is the failure this avoids. Reads are unscoped, so a stale absolute path opens the wrong track's handoff without complaint. Briefs name the absolute path of the one to read. ## A track's record lives in git and its issue tracker, not the roster A landed track already has two durable records: the commits themselves (what changed), and — when the work traces back to an external tracker (this user's is Peek) — that tracker's item (why, and its own lifecycle: open, in review, done). A roster entry repeating either is a third copy that goes stale the moment someone forgets to update it, which is the failure "A roster accumulating landed tracks is the same failure..." above is naming: it is not only ugly, it duplicates records that already exist and already update themselves. **Cardinality is many tracks to one issue, not one to one.** A single tracker item can be split across several tracks (a big migration, say, landed in stages), and a track with no tracker item behind it is the gap worth minding — not the reverse. A tracker item is where a person or a future session looks to find out *why* something exists; a track that never names one leaves that question answered only by the diff, which answers *what*. **Verification detail belongs in the tracker item when the work closes, not in the roster.** "Tenth macOS run, green," "hand-checked against a packaged build," which gate ran and when — none of that lives naturally in a commit message or in an item's open/in-review/done status. If a project wants that detail kept, `/land` writes it into the tracker item's own record as part of closing the loop (a comment, a notes field, whatever that tracker offers), not into a roster entry that outlives the track only to go stale. ## Never block on a person Work running unattended that trips an interactive approval does not fail; it waits, indistinguishable from work still in progress. Where chook is configured with `CHOOK_UNATTENDED`, every ask becomes a deny instead, so an ask on a hot path is a stall either way — the choice is between a hang and a hard stop. Every approval reachable from unattended work must therefore become an allow, or a deny whose text names an alternative that works. A deny that says "work around it" to a caller holding a shell teaches the bypass rather than preventing the action. ## Enforcement, and its limits Three checks in chook hold the line, all ahead of the allow rules and none expressible as a config rule, since agent identity never reaches the rule matcher. `writerWorktreeGuard()` denies a writing spawn made from a primary checkout unless the prompt names a `.worktrees/` path that exists on disk. `primaryCheckoutGuard()` denies a subagent's `Write`/`Edit`/`NotebookEdit` when the file resolves into the primary checkout of the repo the session is working in — including from inside a worktree reaching back over its parent, which the worktree's `.git` file makes recoverable. `trackRepoRootGuard()` binds only while `CHOOK_UNATTENDED` is set, and while it binds, denies the same write from any session, including the top-level one, when the target repo declares `tracks.json` at its root; the coordinator handoff, the declaration, `.gitignore`, `specs/` and the declaration's own `coordination` paths are excepted even then. An interactive session — the ordinary case, since nothing sets `CHOOK_UNATTENDED` for a person's own session — is unbound by it and may edit anywhere in the primary checkout. Symlinks are followed the way the write would follow them in all three. The two write guards scope themselves in opposite directions, which is the part worth holding on to. `primaryCheckoutGuard()` protects the session's own repo, because its subject is an agent that wandered out of its worktree, and it binds regardless of who is asking. `trackRepoRootGuard()` ignores where the session is standing entirely and reads the target repo's declaration, because its subject is a repo stating how unattended edits reach it — a rule avoidable by working from another directory would not be one; an interactive session sidesteps it only because it was never the guard's subject. The remaining hole is shell: redirections, copies, and `sed -i` are not parsed, and `Bash` is deliberately left alone so ordinary git and build commands run in any tree. The spawn check reads prompt text, so it also proves nothing about where the agent then works. Deletes are the exception, because they are the part with no undo and the exposure grows with unsupervised running time. chook's `deleteFenceCheck()` covers `rm`, `rmdir`, `unlink`, `shred`, `find -delete`, `git clean`, and `mv` to `/dev/null`. When `CHOOK_UNATTENDED` is set, it denies any delete whose target resolves outside the session's own worktree. A recursive `rm` gets a stricter check, attended or not. It runs without review only when every target is in a temp dir, or inside the session's tree with nothing git couldn't restore (see `deleteLossReason()`). Otherwise it asks, and unattended that ask becomes a deny that says why and points at a temp dir. Temporary directories are exempt because that is where a writer cleans up its scratch files, and installs and network access are not fenced at all. Treat file isolation as containment against inattention. It is not a sandbox, and a design that assumes otherwise will be wrong in the direction that costs data. ## How a rework loop actually ends Two failure modes were observed by running the coordinator against specs written to break it. The spec text is kept here because it is the whole evidence and the repo it ran in was scratch. **A self-contradictory spec converges rather than parking.** A spec demanding both a number and the string `none` for an empty array failed review once and passed on the rework, because the second reviewer deferred to the first review's resolution rather than re-opening the contradiction. Rework loops therefore terminate on *disagreement about what a spec means*, and run to the round limit only on *impossibility*. Reaching `parked` needed a spec asking for a string that is not equal to itself. The contradictory spec, in full: - `count(items)` takes an array and returns how many items it holds. - `count` must return a number for every input it accepts, including an empty array. - For an empty array, `count([])` must return the string `none`. The impossible one: - `stamp()` takes no arguments and returns a string. - Two calls must never agree: `stamp() === stamp()` must evaluate to `false`, and the same must hold for the value of a single call compared with itself, so `const s = stamp(); s === s` must also be `false`. **A writer will keep a gate green over a broken spec.** Facing the impossible requirement, a writer added an `unmet | ...` line to the verify script while leaving it exiting 0 and printing `all green`, reasoning that a failing gate would make the branch unmergeable over a defect in the spec text rather than in the code. Review caught it. A gate that prints its success signal alongside a declared unmet requirement is not a signal, so a writer never edits what the gate reports about its own success. **A gate cannot express "knowingly partial", so something else has to.** Given the same impossible spec and a brief requiring a closing `**Result**` section, a second writer took the better route: it implemented the satisfiable half, left the impossible clause with no assertion at all rather than a weakened or failing one, and named the clause and its contradiction under `**Result**`. The gate is then honestly green about what was built and silent about what was not — which is correct, and still leaves the exit code unable to distinguish a fully implemented spec from a knowingly partial one. Nothing in the verify output carries that difference; the writer's handoff and the roster line pointing at it are the whole channel. This is why the coordinator's message is a file rather than a gate result. ## Known gaps - How an agent's identifier behaves across resume and retry is unobserved. Nothing should depend on it being stable for a track's lifetime. - Path resolution follows symlinks in the containment and worktree checks, but a `file_path_regex` still matches the path as written, so a rule aimed at a nominal path misses the link's real target. - Worktrees created by the harness's own isolation land outside the declared directory and beyond any rule. - chook state is keyed on the session, so two sessions driving the same repo see nothing of each other. - Nothing requires a live track to have a handoff, so a worktree left detached with no branch and no handoff is indistinguishable from active work.