This repository has no description
cfg docs working-modes.md
17 kB
Markdown
at main

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 a track-worker agent; 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 to claude-sonnet-5. The tick and its reviewers always run on the model spec-watch-tick.sh sets, 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 at awaiting-approval for /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: false takes a spec out of consideration without deleting it.
  • model — the model this spec's writer runs on, passed verbatim to claude --model (opus, claude-opus-5, …). Omitted, it is the repo's writerModel. 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.