Three ways of working, and how to use each #
The working directory decides which one is in effect. Nothing infers intent, so standing in the wrong place is not a mistake that gets corrected later — it is a different mode, with different rules about where writes may land.
| Standing in | Mode | The work is |
|---|---|---|
A worktree under <repo>/.worktrees/ |
Track | Writing code for one unit of work |
A primary checkout that declares tracks.json |
Coordinator | Starting, reviewing, and landing tracks |
The same, with specWatch declared and a schedule installed |
Spec-driven coordinator | Approving what a tick produced |
hooks/session-place.sh states the mode at session start, and statusline.sh
keeps it on screen. A repo that declares no tracks and holds no worktrees gets
neither, deliberately.
Mechanism and rationale live in tracks.md; worktree layout in worktrees.md.
This page is how to use it.
Mode 1 — working a track #
One unit of work, its own worktree, its own branch, lasting as many sessions as it takes.
Start it from the repo's primary checkout:
/track <name>
That creates <repo>/.worktrees/<name> on track/<name>, runs the declared
install in it, writes the worktree's handoff.md, and starts the first writer
there. The coordinator remains in the primary checkout; the writer reaches the
track by its worktree path.
If a person is taking over or watching it, give it a shell of its own, and use that shell for every session on this track:
tmux new-window -n <name> -c <repo>/.worktrees/<name>
claude
This is the form to prefer. EnterWorktree moves the session but not the shell
that launched it, so a track entered that way lasts exactly one session: the
next exit and relaunch lands back in the primary checkout. A shell standing in
the worktree makes every session in that window a track session from its first
token.
Each session after the first starts with /pickup, with no argument. The
handoff at the root of the tree is the source of truth for where the work
stands.
End a session with /handoff, which rewrites that file for whoever picks it
up next. Ending a session is not ending the track; the tree and branch persist.
End the track from the primary checkout with /land <name>, which verifies,
rebases, merges, removes the worktree, and deletes the branch.
Mode 2 — coordinating by hand #
A session in a primary checkout that declares tracks.json is a coordinator.
Interactively, it may edit project files in that checkout directly:
trackRepoRootGuard() in chook binds only while CHOOK_UNATTENDED is set,
which a person's own session never is. A subagent it spawns is fenced
regardless, by primaryCheckoutGuard(), so work that needs a writing subagent
still gets a track. Root edits get committed promptly, since a dirty primary
checkout blocks /land and a spec-driven tick's own roster commit.
/tracks— where every track actually stands. Read-only, and derived fresh from git rather than from the coordinator handoff, which records decisions and next steps and drifts on anything git already tracks./track <name>— start one./land <name>— land one. Serialized: one at a time./pickup <name>— hand a track to atrack-workeragent; the coordinator stays in the primary checkout. It moves the session into the worktree only when the person explicitly asks for that, and the move prompts in a way no rule or hook can suppress; a track worked across many sessions wants its own shell instead. Run with no argument from the primary checkout, it acts on the coordinator handoff's own Next steps before it scans tracks.
<repo>/handoff.md is the coordinator's own handoff — the same Resume Here
structure as a track's (Goal, Current state, Next steps, Key files & symbols,
How to verify), plus decisions made interactively at this level. It says why
each track exists and in what order they land, one line per track plus the
cross-track intent no per-track handoff can hold. It is maintained by hand at
both ends for that track list — /track adds a line, /land removes one — so
/tracks is what catches the drift on anything git-derived.
Mode 3 — a coordinator driven by specs #
The same primary checkout, with the work described in files instead of typed in.
The schedule drives every tick, which reads every spec, moves each track one
step, and stops short of merging. Merging is a person's decision unless the
repo has declared a landing script (below); a tick's most advanced outcome is a
track sitting at awaiting-approval with a line in the roster saying so.
The schedule drives the repo, not a session. bin/spec-watch-schedule.sh install <repo> installs a cron entry (a LaunchAgent on macOS) that runs
bin/spec-watch-tick.sh <repo> every specWatch.intervalSeconds. No
interactive session drives ticks, so opening, closing, or restarting a session
in the repo never starts or stops that work — writers (claude --bg sessions
and lane writers) were already independent of any session, and now so is the
tick itself. bin/spec-watch-tick.sh is the only way a tick runs, scheduled or
by hand: it holds a per-repo flock, exports SPEC_WATCH_TICK_LOCK, and writes
a pid file at ~/.local/state/spec-watch/<repo basename>.tick.pid for as long
as it runs. /spec-watch (commands/spec-watch.md) refuses when
SPEC_WATCH_TICK_LOCK is unset and points at spec-watch-tick.sh, so a tick
typed into a session can never race the scheduled one. hooks/session-place.sh
tells a session standing in a spec-driven repo's primary checkout that the
schedule drives it, not to run /spec-watch or any loop or wait, and that the
status line shows each track's stage.
The status line shows what the schedule is doing. statusline.sh reads
each due check's saved stage for a repo's tracks and shows one beside each
track's line, and shows "tick running" beside the repo's own line while a
tick's pid file names a live process. That is how a session standing in the
repo sees what is running for it, since nothing in the session itself drives or
watches a tick.
No model for a snapshot already ticked. Each tick action changes the spec
snapshot, so a tick on an unchanged one repeats work already done.
bin/spec-watch-due.mjs fingerprints every spec's derived state, without a
model, and reports a tick due only when that changed since the last due tick,
or an hour has passed so a tick that died is retried. bin/spec-watch-tick.sh
runs it first and starts no model when nothing is due, whether the tick was
scheduled or run by hand. Every due check also saves each track's stage — spec
state, writer state, lane index — to
~/.local/state/spec-watch/stages/<same 16-hex key as the due record>.json,
which is what the status line reads. docs/spec-watch-token-cost.md (on
track/token-cost-design until landed) has the measurements behind the due
check.
Declare it #
Add specWatch to the repo's tracks.json:
{
"worktrees": ".worktrees",
"base": "main",
"verify": "node verify.mjs",
"specWatch": {
"specs": "specs",
"maxLive": 3,
"maxRounds": 1,
"writerModel": "claude-sonnet-5"
}
}
specs— directory of spec files, relative to the repo root. Writable at the root even though the rest of the checkout is not, since specs are how work is commissioned.maxLive— how many tracks may exist at once. Backpressure: with no slots free, a tick starts nothing and says which specs are waiting. A parked track counts, so parking is a way to run out of room — and the fix is never to raise this number, because a parked track is waiting on a person editing a spec and starting work around it buries the thing that needs doing.maxRounds— failed reviews allowed before a track parks.writerModel— the model a writer runs on when its spec names none. Optional; defaults toclaude-sonnet-5. The tick and its reviewers always run on the modelspec-watch-tick.shsets, whatever this says.landScript— optional; path, relative to the repo root, to an executable that lands one approved track. Absent, a tick never merges and every track waits atawaiting-approvalfor/land. See "Let a tick land tracks".
Write a spec #
One file per unit of work, front matter plus prose:
---
enabled: true
touches: [src/greet.mjs, verify.mjs]
depends-on: [greeting]
---
# Shouting
Add a `shout` function to `src/greet.mjs` that upper-cases a greeting.
- `shout(text)` returns `text` upper-cased with a trailing `!`.
- `verify.mjs` gains an assertion covering it.
enabled: falsetakes a spec out of consideration without deleting it.model— the model this spec's writer runs on, passed verbatim toclaude --model(opus,claude-opus-5, …). Omitted, it is the repo'swriterModel. Name a stronger model for work that needs judgment rather than raising the default, so cheap work stays cheap.touches— the files the work is expected to land in. Anything committed outside these globs is derived from the diff and reported at the approval gate; it is not fatal, but it is never dropped for brevity, because a file beyond the list is the work having widened its own brief.depends-on— spec names that must have landed first. The gate reopens when a dependency lands and closes again if that dependency's spec is later edited.
Write the requirements so they can all hold at once. A spec that contradicts
itself does not reliably park — it converges on whichever reading the first
review settled, and the resulting code encodes a reviewer's preference rather
than a decision. tracks.md has both failure modes in full.
Editing a spec that already landed makes it stale, and the next tick starts
fresh work for the difference.
Run a tick unattended #
bin/spec-watch-tick.sh <repo> runs one tick with nobody watching, whether
that is by hand or from the installed schedule. Nothing is pushed at
you. A tick has no terminal, so it leaves its message in two files that exist
anyway: each writer states its outcome in its own handoff.md under a section
headed **Result**, and the tick rewrites a ## Waiting on a decision section
in the roster at <repo>/handoff.md naming every track that needs you and
pointing at its worktree. That section is rewritten whole every tick, so it is
never stale and never keeps yesterday's entries — and when nothing is waiting
it says so in one line.
bin/spec-watch-tick.sh sets what an interactive shell already has and a bare
invocation would not: PATH including the mise shim directory, since node is
version-managed and reaches PATH only through a login shell; --add-dir for
$HOME/.claude/bin, since a session's sandbox otherwise covers only its
working directory and cannot reach the status script; and
CHOOK_UNATTENDED=1, which turns every approval into a hard refusal so a
writer with no terminal refuses rather than stalling. Output appends to
$HOME/.local/state/spec-watch/spec-watch.log.
To run one tick now, run bin/spec-watch-tick.sh <repo>; its log entry lands in
the same file. /spec-watch typed into a session refuses, because only the
script holds the repo's tick lock.
What a tick does #
It reads bin/spec-status.mjs, derives every spec's state from git and the
filesystem, and moves each at most one step per tick:
- unstarted — creates a worktree and launches a detached writer, if a slot is free.
- implementing — nothing. A writer goes idle rather than exiting when its turn ends, so liveness is read from session state, never from process death.
- needs-review — rebases if behind, runs the gate, spawns a read-only reviewer, records a verdict pinned to the branch tip. On a failure it appends the findings to the handoff and relaunches, until the round limit.
- awaiting-approval — nothing, unless the repo declares
landScript; see the next section. - landed, disabled, blocked, parked — nothing at all. No re-review, no re-tidying.
It aborts without changing anything if the primary checkout is dirty, if the
declared verify does not run, or if it cannot read the status — and says so in
the roster, because a loop that refuses identically on every beat otherwise
looks exactly like one with nothing to do.
Let a tick land tracks #
Without this, approved tracks keep their slots until a person runs /land, and
any spec that depends-on one stays blocked until then. A repo that wants
unattended feature work to keep moving declares specWatch.landScript in
tracks.json. Nothing else changes for a repo that does not.
The tick runs the script once per track sitting at awaiting-approval, passing
the spec name, the spec's sha and the branch tip — the exact pair the passing
review was recorded for — and stops after the first one that lands, so tracks
land one at a time. The script refuses when the spec on the base branch or the
branch tip no longer matches that pair, so nothing lands that the review did
not see, and it refuses a call that passes only the name. The script decides
everything else: what has to be true before a track may
land, the rebase, the merge, the spec/<name>/<sha> tag, and removing the
worktree. The tick does not repeat any of it. The script prints one line and
exits with a status the tick acts on:
| Exit | Meaning | The tick |
|---|---|---|
| 0 | landed | reports the line; a spec depending on it unblocks on the next tick, since the tag now exists |
| 1 | not landed; the line names the condition | leaves the track at awaiting-approval and reports the line |
| 2 | the branch is behind base and rebasing conflicts; the line lists the paths | resolves a handoff.md-only conflict itself, and the rebased branch goes back through review before the script runs again; relaunches the writer for any other path |
A conflict in handoff.md alone is not a conflict. handoff.md is
gitignored and untracked in every repository that uses tracks: the roster and
each track's handoff are working files git never carries, because a tracked
copy on any branch makes every fast-forward silently overwrite another
worktree's ignored copy. A conflict on it can only come from a branch that
still tracks the file, replayed onto a base that does not. The tick drops it
from the branch's index (git rm --cached handoff.md, which keeps the working
copy), continues the rebase, and repeats for each replayed commit that
conflicts on it. No writer is relaunched for it, so a moving base does not
spend the relaunch cap.
When a rebase conflicts in any other path, the tick aborts the rebase so the
branch is left as it was, and relaunches the track's writer through the same
launch a dead writer gets, with a paragraph added to its brief: rebase onto the
base branch, resolve the conflicting paths (the list never names handoff.md alone, and
the brief says to untrack handoff.md if it conflicts) so that what landed there and what this
spec asks for both survive, rerun the gate, commit. Each such relaunch counts
against the same three-relaunch cap as any other. Past it the track is
parked, with the conflicting paths recorded and named in the roster, and stays
that way until a commit lands on the branch or the spec is amended. A conflict
found while rebasing a track that is still being reviewed takes the same path.
The script lives in the repo, so each repo sets its own bar. A generic one
checks that the gate passes on the tip, that nothing changed outside the spec's
touches, that the base is an ancestor of the branch (rebasing when it is
not), and that every command in a specWatch.landChecks list exits 0, then
lands the way /land does. Whatever a script checks, a clean rebase is still
not a passing one, so it should run the gate after rebasing.
Approve, or edit the spec #
/tracks puts the evidence in front of you for every track a tick has parked or
left awaiting approval: the gate rerun on the current tip, the review verdict,
the files outside touches, and the writer's recorded Decisions.
Read the Decisions last and most carefully. An unattended writer resolves ambiguity by choosing and recording rather than asking, so that section is the complete list of places the spec did not determine the answer — and approving the track approves those readings. A Decision saying the spec contradicts itself or cannot be satisfied is not something to approve; the answer is an edit to the spec, which reopens the track on the next tick.
Then /land <name> merges it, and writes the spec/<name>/<sha> tag that tells
the coordinator the spec is done. A spec-driven track merged without that tag
is one the coordinator starts again from scratch on its next tick, so landing
by hand with a plain git merge is not a shortcut.