Task worktrees for coding agents: one checkout per task, set up and torn down by the project's own commands.
This document states what must hold, not how. README.md has the install and the essential commands, docs/commands.md every command, and src/README.md maps the modules that meet these requirements. §9 lists the known gaps and open questions.
Two agents in one checkout overwrite each other's files, so parallel work needs a checkout per task. A bare git worktree add gives a checkout that is not ready to work in, and leaves whatever the worktree started outside its directory running when it goes. Claude Code makes worktrees of its own and runs no project setup for them. agent-worktree owns the whole lifecycle: it creates the worktree, runs the project's setup, records whether setup finished, and runs the teardown before the worktree goes. A project's setup and teardown commands must be idempotent, since each runs again on every resume and retry.
Primary user: the owner's coding agents, through the CLI and the cniska-worktree Claude Code plugin, in the owner's git clones. Not the audience: bare repositories, submodules, or checkouts whose git directory lives elsewhere.
- FR-1 — A task worktree is created at
<data>/<key>/<name>, where<data>is$XDG_DATA_HOME/agent-worktreewith each symlink in its existing part resolved, so the path is the one git lists for the worktree. - FR-2 — An
XDG_DATA_HOMEthat is unset, empty or not an absolute path counts as$HOME/.local/share. - FR-3 — A
HOMEthat is unset, empty or only whitespace counts as the account's home directory. - FR-4 — A project's key is its primary checkout's directory name, a dash, and a short hash of the primary's absolute path; it is the same from the primary and from each of its worktrees.
- FR-5 — Two clones with the same directory name at different paths get different keys.
- FR-6 — No task worktree is created inside the primary checkout's directory, unless
XDG_DATA_HOMEitself points inside it. - FR-7 — The ledger, which records each registered worktree and whether its setup finished, lives in the git common directory under
agent-worktrees, where earlier releases wrote it, so their registrations are still read. - FR-8 —
path <name>prints, as its result'spath, where the worktree<name>lives.
- FR-9 — A worktree name is one path segment of letters, digits, dots, underscores and dashes that git accepts as a branch name; any other name is refused as
invalid_name. - FR-10 — A name that differs only in case from a registered worktree or an existing directory where worktrees live is refused as
case_collision, naming the existing one. - FR-11 — A checkout whose git directory is not a
.gitdirectory inside the primary checkout, such as a submodule or a separate git dir, is refused asunsupported_layout. - FR-12 — A command run outside any git checkout is refused as
not_a_checkout.
- FR-13 —
create [--from <ref>] <name>makes a worktree on branch<name>: the existing branch checked out, or a new branch with no upstream. Without--from, the new branch is cut fromorigin's default branch, read and fetched as FR-22 reads and fetches it; whenorigindoes not exist or has no default branch, it is cut from the primary's HEAD, notingbranch <name> cut from HEAD, no remote originor, origin has no default branchon stderr; whenorigincannot be reached,createis refused asremote_unreachable, namingcreate --from HEAD <name>. With--from <ref>, the new branch is cut from the commit<ref>names in the checkoutcreateruns in, reaching no remote; a ref that names no commit there is refused asunknown_ref, and--fromwith an existing branch asbranch_exists.createregisters the worktree, runs setup in it, records setup as finished, and prints the path as its result'spath. Acreaterefused asunknown_ref,branch_existsorremote_unreachableregisters nothing and makes no worktree. - FR-14 —
createon a worktree whose setup finished prints its path and runs nothing. On a registered worktree,--fromis ignored, notingworktree <name> exists, so --from <ref> is ignoredon stderr. - FR-15 — A setup hook that fails is refused as
setup_failed; the worktree stays registered with setup pending, and the nextcreatereruns setup without making the worktree again. - FR-16 — A directory where the worktree would go that agent-worktree did not register is refused as
unmanaged, and left as it is. - FR-17 — A registered worktree whose directory is gone is refused as
orphaned, namingprune. - FR-18 — A registered worktree whose directory git does not list as a worktree is refused as
not_a_worktree.
- FR-19 —
initin the primary checkout runs setup there, with no worktree name. - FR-20 —
initin a registered task worktree runs setup there with its name, and records setup as finished. - FR-21 —
initin any other checkout is refused asnot_a_managed_checkout.
- FR-22 —
remove <name>runs teardown, removes the worktree, unregisters it, and settles its branch against its remote: the branch's upstream remote, elseorigin. Once per remote in a command, it asks the remote for its default branch and its branches and fetches that default branch. The branch has landed when its tip is reachable from the default branch, or when it has an upstream that is gone from the remote and its whole change since the merge base matches one commit already in the default branch, as after a squash merge whose host deleted the branch. A landed branch is deleted, and so is its remote branch, its upstream or, with none, the branch of the same name onoriginother thanorigin's default branch, when the remote still has it and the branch holds every commit there; an upstream already gone is no error. Otherwise the branch is kept: one that has not landed; one whose remote branch holds commits the branch lacks, or that the remote refuses to delete; one that tracks the remote's default branch itself; one whose remote does not exist, cannot be reached or has no default branch; and one checked out in another worktree.--forcedoes not change which branch is deleted. Its result is the worktree'sname, itsbranch, null when it is detached, and the branch'soutcomewith the facts that outcome has:deletedwithlanding,mergedorsquash-merged,into, the remote's default branch, andremoteBranch, the remote branch it deleted or null; or why the branch is kept:unmergedortracks_defaultwithinto,no_remoteorno_default_branchwithremote,remote_unreachablewithremoteanddetail,remote_branch_aheadwithremoteBranch,remote_branch_keptwithremoteBranchanddetail, orchecked_outwithat; ordetached. Remote branches are named<remote>/<branch>. - FR-23 — A worktree with uncommitted or untracked files is refused as
dirtyunless--forceis given. - FR-24 — A worktree that contains submodules is refused as
submodulesunless--forceis given. - FR-25 — A locked worktree is refused as
locked, with or without--force. - FR-26 — A teardown that fails is refused as
teardown_failedand the worktree is kept. With--forcethe worktree is removed and stays registered, sopruneretries its teardown. - FR-27 — A name that is not registered is refused as
not_registered. An unmanaged, orphaned or unlisted worktree meets the refusal FR-16, FR-17 or FR-18 names.
- FR-28 —
prunedrops git's entries for worktrees whose directory is gone, then runs teardown for every registered worktree whose directory is gone and unregisters it. It then removes, asremovedoes, every registered worktree git lists whose branch has an upstream and has landed by FR-22's test, when the worktree is unlocked, has no uncommitted or untracked files, contains no submodules and is not the checkoutpruneruns in, notingworktree <name> removed, its branch landedon stderr; a worktree whose branch has no upstream, ascreatemakes it, is never removed byprune. A branch such a worktree had when git last listed it is settled as FR-22 settles it, and noted on stderr as FR-37 words it. - FR-29 — A teardown that fails during
prune, or a removal git refuses after it, leaves that worktree registered whileprunegoes on with the rest, andpruneis then refused asremovals_failed, naming every such worktree and its cause.
- FR-30 —
lsprints as its resultcounts, the count of listed worktrees in each state, 0 included, in the orderready,pending,orphaned,unlisted,unmanaged,legacy, andworktrees, a table of one record per registered worktree and per unregistered entry where the project's worktrees live, sorted by name: its name, its state and its path; with--full, its name, its state, its branch, or null when it has none, its port block (FR-57) and its path. - FR-31 —
lswith nothing to list printscountswith every state at 0 and an emptyworktrees, and nothing on stderr.
- FR-32 — A project needs no hook, and each of its three is optional on its own:
.worktree-hooks/setup,.worktree-hooks/teardownand.worktree-hooks/portsat the checkout's root, whichinstallwrites (FR-62), or the same three in the clone's git common directory, whichinstall --localwrites and which take their place (FR-64, FR-65). With no setup hookcreateandinitrun no setup, and with no teardown hookremoveandprunerun no teardown. The setup hook runs in the checkout being set up, as that checkout has it; the teardown hook runs in the primary checkout, as the primary has it, since the worktree may already be gone. The ports hook is read, never run (FR-51). - FR-33 — Both hooks get
WORKTREE_PRIMARY,WORKTREE_PATH, for a task worktreeWORKTREE_NAME, and for a worktree holding a port blockWORKTREE_PORT(FR-56).WORKTREE_PATHis where the worktree actually is, or where git last listed it when the command started; for a worktree git no longer lists, such as one dropped withgit worktree remove, it is unset. For the primary,WORKTREE_NAMEandWORKTREE_PORTare unset. A variable left unset stays unset even when the caller's environment set it. Beyond these variables, a hook gets the caller's environment, with nothing of agent-worktree's own added, such as the Bun it runs on. - FR-34 — A setup or teardown hook that exists but is not executable is refused as
hook_not_executable, and one that cannot start ashook_unrunnable; neither is skipped. - FR-35 — A hook's output goes to stderr and is never read; its exit status alone decides success.
- FR-36 —
hookgiven aWorktreeCreateevent on stdin creates the worktree it names, ascreatedoes, and prints only its path on one line, outside the envelope (FR-46), since Claude Code reads the hook's stdout as the path. - FR-37 —
hookgiven aWorktreeRemoveevent removes the worktree at itsworktree_pathwith force, asremove --forcedoes, and prints its branch's outcome on one line, outside the envelope (FR-46):branch <name> deleted, merged into <remote>/<branch>, orsquash-merged into, followed by, and <remote>/<remote-branch> deletedwhen it deleted that, orbranch <name> kept,and why, ordetached. - FR-38 — A
WorktreeRemovepath that is not where agent-worktree keeps that name's worktree, nor a worktree in the earlier location (FR-41), is refused asforeign_path. - FR-39 — Stdin that is not a JSON object with the event's string fields is refused as
hook_input; any other event is refused ashook_event. - FR-40 — The
cniska-worktreeplugin runsagent-worktree hookforWorktreeCreateandWorktreeRemove, and adds a note when a session starts, resumes, clears or compacts telling the agent to create and remove worktrees with the CLI.
- FR-41 — A registered worktree that git lists at
<primary>/.claude/worktrees/<name>, where earlier releases created them, and that has nothing at its FR-1 path, is listed bylsat that path in the statelegacy. - FR-42 —
create,init,removeand theWorktreeRemovehook on it are refused aslegacy_location, naming two acts: keep it, by making its FR-1 path's parent, moving it there withgit worktree moveand runninginitin it; or drop it, withgit worktree removeand thenprune. - FR-43 —
pruneleaves a worktree in the earlier location registered and runs no teardown for it. - FR-44 — After the keep act, the worktree reads
readyat its FR-1 path; after the drop act, its teardown has run and it is unregistered. - FR-45 — A directory under
<primary>/.claude/worktreesthat is not registered is not agent-worktree's:lsdoes not list it and no command touches it.
- FR-46 — A result goes to stdout as one TOON document, the envelope
command,ok: trueandresult, and exits 0, except thatdoctorexits 1 when a check fails (FR-70);initandprunehave a null result.hook(FR-36, FR-37) and--version(FR-66) print outside the envelope. - FR-47 — An error goes to stderr as one TOON document, after any note or hook output, the envelope
command, oragent-worktreefor an unknown command or--versiongiven an argument,ok: falseanderror, which holds itskind,code,message,metaandresolve, the act that resolves it, and exits 1. - FR-48 — An unknown command, an unknown option, or a wrong number of arguments is refused as
usage, with the usage as the act, and exits 2. - FR-49 —
agent-worktreewith no command prints whatlsprints. With--helpor-hits result iscommands, a table of every command's usage and summary, then--version's (FR-66);<command> --helpprints that command's usage and summary as its result. - FR-50 — Every error is a refusal, with a code, a message naming what was refused and why, and the act that resolves it, or a fault, whose act is to hand it to the owner.
- FR-51 — A project asks for ports by installing how many each task worktree needs, a whole number N from 1 to 1000, as its ports hook, which agent-worktree reads in the checkout being set up. Setting up a task worktree of such a project gives it the lowest free block of ⌈N/10⌉ consecutive units, a unit being 10 consecutive ports from 24000 to 24999 that starts at a multiple of 10, and records the block in the ledger; a block is never derived from a hash of the worktree's name or path. The primary checkout gets no block, so it keeps the project's default ports. Claiming a block notes
port block <first>-<last> for <name>on stderr. - FR-52 — A unit is free when no worktree of any project on the machine holds it and no program listens on any of its ports; a port in use skips its whole unit, and a block is free when each of its units is. A port is in use when a TCP connect to it on
127.0.0.1or::1succeeds, which a listener on0.0.0.0or::accepts too; agent-worktree never binds a port to probe it. A worktree holds its block until it is unregistered, or until apruneor a claim finds it stale (FR-58, FR-59, FR-60). - FR-53 — Two commands claiming at once never get a unit in common, and two setting up one worktree at once agree on one block.
- FR-54 — With no free block of the size the project asks for,
createandinitfirst remove the worktrees whose branch landed, as FR-28'sprunedoes, sparing the checkout they run in and the worktree they set up, notingworktree <name> not removed, though its branch landed: <cause>on stderr for each it could not remove, and claim again; with still none free they are refused asno_free_ports, namingremove, fixing a teardown that failed for a gone worktree (FR-60), andprunein another clone (FR-59); the worktree stays registered with setup pending and no block, and once a block is freed the nextcreateorinitclaims one and resumes setup. - FR-55 —
initin a registered task worktree whose block is not one run of the size its project asks for, such as one an earlier release registered with none, claims the lowest free block of that size, counting the units it holds as free, and frees the units it held outside that block, before setup runs; in one whose project asks for none, it frees the block the worktree holds. - FR-56 —
WORKTREE_PORTis the first port of the worktree's block, and the project's N ports run toWORKTREE_PORT+ N - 1. - FR-57 —
ls --fullshows a worktree's block as<first>-<last>, a block whose units are not one run as each run's<first>-<last>joined by commas, or null when it holds none. - FR-58 —
removefrees the block when it unregisters the worktree; a worktree left registered after a failed teardown keeps its block, and its teardown duringprune, or during a claim that takes the block over (FR-60), gets it. - FR-59 —
prunefrees every block whose worktree's ledger entry is gone, in any project on the machine, such as one whose clone was deleted, notingport block <first>-<last> freed from <entry>, which is goneon stderr for each. - FR-60 —
createandinittake over a stale block on their way to the lowest free one, asprunewould: a block whose ledger entry is gone, in any project, is freed as FR-59 frees it; a block held by a registered worktree of the same project whose directory is gone has that worktree's teardown run and the worktree unregistered (FR-28), notingport block <first>-<last> freed from <name>, whose worktree is goneon stderr. A worktree whose teardown fails stays registered and keeps its block, and the claim moves on to the next block. - FR-61 — A project with no ports hook gets no block for any worktree, and its
createandinitnever meetno_free_ports. One whose ports hook holds anything but a whole number from 1 to 1000 on one line is refused asinvalid_ports, naming the hook and what it holds, and so is one whose hook cannot be read, such as a directory; the worktree stays registered with setup pending and no block.
- FR-62 —
install [--local] [--setup <command>] [--teardown <command>] [--ports <n>]writes the hook each option names into.worktree-hooks/at the root of the checkout it runs in, for the project to commit, prints the path of each hook it wrote as its result'shooks, and leaves every hook no option names as it is, so a reinstall without an option keeps that hook. It never works out a command or a count, and never stages or commits. A setup or teardown hook is plain shell holding the command as the shell assignmentcommand='<command>', a quote in it written'\'', which it runs withsh -ec; the ports hook holds the number. - FR-63 —
installwith no option but--local, with an empty command, or with a--portsthat is not a whole number from 1 to 1000 is refused with the usage, and writes nothing. - FR-64 —
install --localwrites the same hooks intoworktree-hooks/in the git common directory instead, never into the tree, so nothing is committed and every checkout of the clone shares them. - FR-65 — A local hook takes the place of the committed hook of the same name in every checkout of the clone; a hook the clone lacks locally is read from the checkout as FR-32 says.
- FR-66 —
agent-worktree --versionprints the running build's version, aspackage.jsonstates it, alone on one line, and exits 0; with an argument after it, it is refused with its usage as the act, and exits 2.
- FR-67 —
scripts/build-compiled.sh <outfile>compiles the CLI into one executable that carries the version, so it runs every command with git alone, with no checkout of agent-worktree and no Bun on the machine. - FR-68 —
install.sh, run bycurl … | sh, downloads the latest release's build for this platform, checks it against its published sha256, runs it for its version, and installs it as~/.local/bin/agent-worktree, writing it beside that path and renaming it into place. A download that fails, does not match its checksum, or reports a version that is notx.y.zinstalls nothing. Run again, it replaces the install. - FR-69 — The npm package
@cniska/agent-worktreeinstalls the build for its platform from@cniska/agent-worktree-darwin-arm64or@cniska/agent-worktree-linux-x64, finds it wherenpmorbuninstalled it, and runs it with the arguments it was given, exiting as it exits, with nonodeorbunonPATH. On another platform, with the platform package missing, or with a platform build that is not executable, it refuses as FR-47 says, asno_build_for_platform,platform_package_missingorbinary_not_executable, naming the cause.
- FR-70 —
doctor [--full]reports its checks, each with a name, a state ofok,warnorfail, a detail, and a fix, null when it passes. Its result iscounts, how many checks areok,warnandfail, 0 included, thenchecks, a table of name, state, detail and fix: without--fullonly the checks that warn or fail, empty when every check passes, and with--fullevery check. It exits 1 when a check fails, and 0 otherwise. - FR-71 —
doctorreadsPATH, the ledger, the port blocks, the project's hooks and git, and changes none of them: it runs no hook, frees no block, contacts no git remote and probes no port. - FR-72 —
doctorlooks throughPATHfor an executable filegitandagent-worktree, each detail naming where it was found or the directories searched. A missing git fails, and the project's checks give way to one warning that they went unchecked. A missingagent-worktreewarns, naming the plugin's hooks (FR-40), sincedoctorcannot tell whether the plugin is installed; the fix names thePATHClaude Code starts with. - FR-73 —
doctorwarns of every port block whose ledger entry is gone, in any project on the machine, as FR-59 finds them, namingprune. - FR-74 — Run outside a git checkout,
doctorwarns that no project check ran; in a checkout FR-11 refuses, it fails with that refusal. In a checkout, it checks the setup and ports hooks as that checkout has them and the teardown hook as the primary has it (FR-32): an absent hook passes, and a setup or teardown hook FR-34 refuses ashook_not_executable, or a ports hook FR-61 refuses, fails with that refusal's message and act. - FR-75 — For each worktree
lslists,doctorpasses areadyone and warns of each other with the act that resolves it: apendingone namescreateto resume its setup, anorphanedoneprune, anunlistedone removing its directory and thenprune, anunmanagedone removing it, and alegacyone FR-42's two acts. - FR-76 — For each
readyorpendingworktree,doctorcompares its port block with the ports hook its own checkout has, and warns naminginitin it when it holds a block though no ports are asked for, holds none though some are, or holds one that is not one run of the size asked for (FR-55); a ports hook there that FR-61 refuses warns with that refusal.
- FR-77 — The release build knows the channel it was installed through from where its own executable resolves, starting no process to find out: under a Homebrew
Cellar/agent-worktree/, Homebrew; the platform package@cniska/agent-worktree-<platform>under Bun'sinstall/global/node_modules, bun; that package under any othernode_modules, npm;$HOME/.local/bin/agent-worktree, the curl installer (FR-68). Anywhere else, such as the CLI run by Bun from a checkout or a binary placed by hand elsewhere, it has no channel and runs no check. - FR-78 — At most once a day per machine, a command asks its channel for the latest version, giving up after one second: the release tag in the
urllines of thecniska/homebrew-tapformula for Homebrew, the npm registry'slatestof@cniska/agent-worktreefor npm and bun, and the latest GitHub release for curl, so it never names a version its own channel does not serve yet. It records when it asked, the channel and the latest version inupdate-check.jsonunder$XDG_STATE_HOME/agent-worktree, or$HOME/.local/state/agent-worktreewhenXDG_STATE_HOMEis unset, empty or not an absolute path. A check that fails, times out or reads no version counts as that day's check, prints nothing, keeps the latest version recorded before it and leaves the command as it would have run. The check is recorded before it asks, so where the record cannot be written no check runs; an install whose channel differs from the recorded one checks again. - FR-79 — While the running version is older than the latest recorded for its channel, every command prints one line on stderr before anything else it prints there, whether or not it ran the check:
agent-worktree <latest> is out (running <running>): run <upgrade>; if you cannot, tell the user., where<upgrade>isbrew upgrade agent-worktree,npm update -g @cniska/agent-worktree,bun update -g @cniska/agent-worktree, or the curl line of FR-68. Its stdout and exit code are those it would have without the line. - FR-80 — No check runs and no line prints when
CIorAGENT_WORKTREE_NO_UPDATE_CHECKis set to anything but empty, underhook(FR-36, FR-37), which the plugin runs, or in the test and acceptance suites, which setAGENT_WORKTREE_NO_UPDATE_CHECK.
- NF-1 — Every command reads the ledger, the filesystem and git afresh; nothing is remembered between commands.
- NF-2 — Doing the act a refusal names, then running the command again, does not meet the same refusal.
- NF-3 — The tests never read or write the real home:
HOMEand the XDG directories point at scratch directories. - NF-4 — agent-worktree reaches a remote only to settle a branch (FR-22, FR-28) or to cut one (FR-13): to read its default branch and branches, fetch that default branch, and delete a landed branch's remote branch, its upstream or, with none, its same-name branch on
originother than the default branch, only while that branch is still at the commit it judged. A command reads and fetches each remote at most once, besides the reruns NF-5 makes. git's own credential prompt is off, and each of these calls that has not finished within 60 seconds, its NF-5 reruns included, is stopped, so a remote that hangs or an ssh prompt nobody answers keeps the branch forremoveandprune, and refusescreateasremote_unreachable, naming the timeout either way, instead of holding the command. Otherwise it connects only to its install channel, to read the latest version (FR-78), and to the machine's own loopback addresses, to see whether a port is in use (FR-52). - NF-5 — A git call that fails because another process is making a worktree of the project, whose
commondirgit has created but not yet written, is run again, up to 50 runs in all; past that, git's own error is the command's.
- Moving a worktree between locations; the owner moves one from the earlier location with git (FR-42).
- Following a primary checkout that moved (FR-4 keys by path; see §9).
- Working out a project's setup, teardown or ports:
installwrites only the commands and the count it is given (FR-62). - Worktrees agent-worktree did not create, including Claude Code's own when the plugin is not installed; git, or Claude Code for its own, manages those.
- Bare repositories, submodules and separate git dirs (FR-11).
- Reading a hook's output (FR-35).
- AC-1 — With
XDG_DATA_HOMEset to an absolute scratch path,create task-aprints$XDG_DATA_HOME/agent-worktree/<key>/task-a, outside the primary, andpath task-aprints the same. (FR-1, FR-6, FR-8) - AC-2 — With
XDG_DATA_HOMEunset, empty and relative, the data directory is$HOME/.local/share/agent-worktree; withHOMEblank, it is under the account's home. (FR-2, FR-3) - AC-3 — The key from the primary and from one of its worktrees match and start with the primary's directory name; two clones named
repoat different paths get different keys. (FR-4, FR-5) - AC-4 — A registration written under
.git/agent-worktreesby hand is read asready. (FR-7) - AC-5 —
createwith each invalid name form, with a name differing only in case, and from a submodule, a separate git dir and a non-checkout:invalid_name,case_collision,unsupported_layout,not_a_checkout. (FR-9, FR-10, FR-11, FR-12) - AC-6 —
create task-aon a new and on an existing branch: the worktree on branchtask-a, setup recorded with its name, path and primary,lsreadingready; a secondcreateruns no setup. (FR-13, FR-14, FR-33) - AC-7 —
createwith a failing setup:setup_failedandpending; fix the hook and rerun: setup reruns in the same worktree and it readsready. (FR-15, NF-2) - AC-8 —
createandremoveover an unregistered directory, a registered one whose directory is gone, and a registered directory git does not list:unmanagedleaving it,orphaned,not_a_worktree; each converges after its act. (FR-16, FR-17, FR-18, FR-27, NF-2) - AC-9 —
initin the primary, in a pending task worktree, and in an unregistered worktree: setup without a name even withWORKTREE_NAMEinherited, setup with the name andready,not_a_managed_checkout. (FR-19, FR-20, FR-21, FR-33) - AC-10 —
removeon a clean worktree in a repo with no remote: teardown ran in the primary, the worktree is gone, the branch kept with the reason, the name unregistered; on a detached one it printsdetached. (FR-22, FR-32) - AC-11 —
removeon a dirty worktree, one with submodules, and a locked one, each with and without--force:dirtyandsubmoduleswithout force only;lockedboth times. (FR-23, FR-24, FR-25) - AC-12 —
removewith a failing teardown:teardown_failed, worktree kept; with--force: removed andorphaned; fix teardown andprune: unregistered. (FR-26, FR-28) - AC-13 —
removeof an unregistered name:not_registered. (FR-27) - AC-14 —
prunewith two gone worktrees, one whose teardown fails:removals_failednaming it and the exit, the other unregistered and gone fromgit worktree list; fix and rerun: both gone. (FR-28, FR-29, NF-2) - AC-15 —
lsover none: every state counted 0, an emptyworktreesand nothing on stderr; over registered and unregistered directories: the count in each state, and records sorted by name with name, state and path, and with--fullbranch, or null, port block and path; with no command: the same asls, and outside a checkoutnot_a_checkout. (FR-30, FR-31, FR-49) - AC-16 — A non-executable setup hook and one with a bad interpreter:
hook_not_executable,hook_unrunnable. A project with no hooks creates and removes. (FR-32, FR-34) - AC-17 — A setup hook that prints to stdout: the CLI's stdout holds only the envelope with the path. (FR-35, FR-46)
- AC-18 —
hookwith aWorktreeCreateevent: the path alone on stdout; with aWorktreeRemoveevent for a dirty worktree, run from inside it: removed, and the branch's outcome alone on stdout. (FR-36, FR-37) - AC-19 —
hookwith a foreign path, a nested name, another event and non-JSON stdin:foreign_path,invalid_name,hook_event,hook_input. (FR-38, FR-39) - AC-20 — The plugin's hooks file runs
agent-worktree hookfor both events and the note on the four session sources. (FR-40) - AC-21 — A registered worktree git lists under
<primary>/.claude/worktrees:lslists it there aslegacy;create,init,removeand theWorktreeRemovehook each meetlegacy_location;pruneruns no teardown for it and leaves it registered. (FR-41, FR-42, FR-43) - AC-22 — Do the keep act on it:
lsreads itreadyat its FR-1 path. Do the drop act on another: teardown ran withWORKTREE_PATHunset even when inherited, andlsno longer lists it; delete a third's directory and prune: teardown ran with its earlier path. (FR-33, FR-44, NF-2) - AC-23 — An unregistered directory under
<primary>/.claude/worktrees: absent fromls, untouched byprune. (FR-45) - AC-24 — The CLI with a result, a refusal, an unknown command, an unknown option and too many arguments: exit 0 with the result's envelope on stdout; exit 1 with the error's envelope on stderr; exit 2 with
usageand the usage as the act. (FR-46, FR-47, FR-48, FR-50) - AC-25 — The CLI with
--help,-handremove --help: every command's usage and summary, then--version's, or the one. (FR-49) - AC-26 — Change state on disk between two commands, such as deleting a worktree's directory: the second command reads the change. (NF-1)
- AC-27 — The test environment's
HOMEand XDG directories are scratch directories, not the account's. (NF-3) - AC-28 — The whole suite passes with the network unavailable. (NF-4)
- AC-29 —
createthroughbin/agent-worktreewith a setup hook that records its environment: the caller's environment and the FR-33 variables, nothing else. (FR-33) - AC-30 — In a project asking for 10 ports, as in AC-31 to AC-39:
create task-aandtask-b: setup gets the first two free blocks asWORKTREE_PORT, andlsshows each as<first>-<last>, andcreatenotes the block it claims;initin the primary withWORKTREE_PORTinherited: setup gets it unset. (FR-33, FR-51, FR-56, FR-57) - AC-31 —
remove task-a: its teardown gets its block, and the nextcreategets that block again. (FR-51, FR-58) - AC-32 — Another program listens on a port of the lowest free block on
0.0.0.0and on a port of the next on::, thencreate: the worktree gets the third block, and each program accepted a connection. (FR-52) - AC-33 — A second clone creates a worktree in the same data directory: it gets the next block; delete the first clone and
prunein the second: it notes the block freed, and the nextcreatethere gets the first clone's block. (FR-52, FR-59) - AC-34 — Several
creates started at once, in a project asking for 20 ports: each worktree gets a block of two units, and no unit is in two blocks; twoinits started at once in one of them, asking for 30: both setups get oneWORKTREE_PORTandls --fullshows a block of three units. (FR-53) - AC-35 — Hold a port in every free block, then
create:no_free_portswith the worktreependingand no block; free them and rerun: setup gets the lowest block and it readsready. (FR-54, NF-2) - AC-36 — A registration with no block, written by hand:
ls --fullshows null;initin it: setup gets a block andls --fullshows it; ask for 30 andinit:ls --fullshows a block of three units; delete the claim of its middle unit by hand:ls --fullshows the two runs left;init:ls --fullshows a block of three units again; delete the request andinit:ls --fullshows null. (FR-55, FR-57) - AC-37 —
remove --forcepast a failed teardown:ls --fullstill shows the block; fix teardown andprune: the teardown gets the block, and the nextcreategets it. (FR-58) - AC-38 — Delete a worktree's directory, then
createanother: the gone worktree's teardown gets its block,createnotes the block freed, the new worktree gets it, andlslists only the new one; with a teardown that fails instead: the gone worktree keeps its block and the new one gets the next. (FR-58, FR-60) - AC-39 — A second clone creates a worktree and is deleted, then
createin the first, with noprune: it notes the block freed and gets it. (FR-59, FR-60) - AC-40 — With a local bare repository as
origin:removeof a worktree whose branch was pushed with an upstream and then pushed toorigin'smainpast the localorigin/maindeletes the branch andorigin's copy;remove --forceof one with an unpushed commit keeps it, namingorigin/main; one squash-merged intomainwhoseoriginbranch was deleted is deleted, and one with a commit since is kept;pruneafter two worktrees are deleted by hand deletes the merged branch and keeps the unmerged one, noting each; withoriginpointing at no repository,removekeeps the branch, naming why. (FR-22, FR-28, NF-4) - AC-41 — A project with no ports hook, with a port held in every free unit:
createsucceeds,ls --fullshows null and setup getsWORKTREE_PORTunset; asking for 20:creategives setup the first port of two consecutive free units andls --fullshows<first>-<first + 19>; a ports hook holdingten:invalid_portsnaming the hook, setup pending andls --fullshowing null. (FR-51, FR-56, FR-61) - AC-42 — With a local bare repository as
origin:pruneremoves a clean worktree whose branch was pushed with an upstream and pushed toorigin'smain, running its teardown and deleting its branch here and onorigin, noting each; it keeps a worktree never pushed, one pushed without an upstream and then toorigin'smain, one with an untracked file, one pushed but not merged, one locked, and the one it runs in. (FR-28) - AC-43 — A project asking for 10 ports with a landed worktree holding the one free block:
createof another runs the landed one's teardown, removes it and takes its block; when the landed one's teardown leaves a file in it,createrefusesno_free_ports, noting it with git's refusal, and the landed one stays. (FR-54) - AC-44 —
install --setupwith a command holding a quote,--teardownand--ports 20: it prints the three paths under.worktree-hooks/, each holding its command;createruns the setup command with the FR-33 variables and a block of two units, andremoveruns the teardown command; the setup hook run by hand with no agent-worktree onPATHruns its command;install --teardownwith another command: the teardown hook holds it and the setup and ports hooks are unchanged. (FR-32, FR-62) - AC-45 —
installwith no option, with--setup "", and with--ports 0and--ports ten: exit 2 with the usage, and no.worktree-hooks/written. (FR-63) - AC-46 — In a clone whose committed hooks are a setup and a teardown,
install --local --setupand--ports 10: it prints the two paths underworktree-hooks/in the git common directory andgit statusstays clean;createruns the local setup, not the committed one, with a block of one unit;removeruns the committed teardown;createrun from inside a task worktree runs the local setup too. (FR-64, FR-65) - AC-47 —
--version, then--versionwith an argument after it: the version alone on stdout with exit 0 and nothing on stderr, then exit 2 with the usage as the act. (FR-66) - AC-48 — Compile the release binary and run it with only its own directory and git on
PATH:--versionprints the version,create task-ain a fresh repository prints the worktree's path, andremove task-aremoves it. (FR-67) - AC-49 — Run
install.shin a fresh home against a stand-in for the release downloads, twice, then once with a wrong checksum in another fresh home, then against builds that report an empty version,1.2.3.4and../x: the installedagent-worktree --versionprints the release's version and~/.local/binholds only it, and the bad download and each mislabelled build are refused with nothing written. (FR-68) - AC-50 — Build the npm packages from a release build, install the shim into a prefix and run it with git and the system's shell utilities on
PATHand nonodeorbun, first on a platform with no build, then without its platform package, then with it, nested under the shim, unexecutable, then whole: the first three are refused asno_build_for_platform,platform_package_missingandbinary_not_executable, each naming its cause and exiting 1; then--versionprints the package version,--version nowexits 2 with the build's usage refusal, andcreate task-aprints the worktree's path. (FR-69) - AC-51 — With
XDG_DATA_HOMEa symlink to a scratch directory,create task-aprints the worktree under the directory the symlink points to, andremove task-aremoves it. (FR-1) - AC-52 — With a local bare repository as
originwhosemainis ahead of the primary's HEAD and of the localorigin/main:create task-acutstask-aatorigin'smainwith no upstream; pushed with an upstream and pushed toorigin'smain,remove task-adeletes it here and onorigin. (FR-13, FR-22, NF-4) - AC-53 —
create --from v1cuts from the tag;--from HEADrun inside a task worktree cuts from that worktree's HEAD;--from origin/mainafter a push toorigincuts from the localorigin/mainand fetches nothing; none has an upstream;--from nowhereis refused asunknown_refand--from ""exits 2 with the usage, registering nothing. (FR-13) - AC-54 — With no remote, then an empty bare repository as
origin:createcuts from HEAD, noting each reason; withoriginpointing at no repository:remote_unreachablenaming--from HEAD, nothing registered;create --from HEADthen succeeds. (FR-13, NF-2, NF-4) - AC-55 —
create --from HEADon an existing branch:branch_exists, nothing registered; on a worktree whose setup failed, the rerun with the same--fromresumes setup, noting it ignored--from; run once more on the now ready worktree, it prints the path with the same note and runs no setup. (FR-13, FR-14, FR-15, NF-2) - AC-56 — With a local bare repository as
originthat would accept the delete of itsmain:removeof amainworktree with no upstream deletesmainhere and keepsorigin'smain; of a worktree whose branch was pushed without an upstream and then toorigin'smain, deletes the branch andorigin's copy; of one whoseorigincopy another clone pushed a commit to, keeps both copies, namingorigin's. (FR-22, NF-4) - AC-57 — With a local bare repository as
originand another worktree half made while each call runs once:create task-afetchingorigin,create task-bcutting its branch,create task-cchecking out an existing branch,lsandremove task-aeach succeed, running that git call twice; with one that stays half made,lsfails with git's own error after 50 runs. (NF-5) - AC-58 — With git and
agent-worktreeonPATH, in a project with a setup hook, asking for 10 ports, and a ready worktree:doctorprintscountsof nineok, none warning or failing, and an emptychecks, exit 0;doctor --fullprints all nine, eachokwith a null fix, the worktree's ports naming its block. (FR-70, FR-72, FR-74, FR-76) - AC-59 — With git and no
agent-worktreeonPATH:agent-worktreewarns naming the directories searched, the plugin's hooks and thePATHClaude Code starts with, exit 0; with no git: git fails,agent-worktreewarns and the project warns as unchecked, exit 1 withoktrue. (FR-70, FR-72) - AC-60 —
doctoroutside a checkout: the project warns that no project check ran, exit 0; in a project whose setup hook is not executable and whose ports hook holdsten: both fail with their refusal's message and act, exit 1. (FR-74) - AC-61 — A pending, an orphaned, an unlisted and an unmanaged worktree:
doctorwarns of each with its act; do each act:doctorprints an emptychecks. (FR-75, NF-2) - AC-62 — A deleted clone's worktree held a block, and a worktree's own ports hook asks for 20 where it holds one unit:
doctorwarns of the block namingpruneand of the worktree naminginitin it, and a seconddoctorwarns of both again;pruneandinitthere:doctorprints an emptychecks. (FR-71, FR-73, FR-76, NF-2) - AC-63 — Compile the release binary and place it as a Homebrew, an npm, a bun and a curl install, each with a check recorded a minute ago that knows a newer version:
--versionprints the version on stdout and exits 0, with that channel's line alone on stderr;lsoutside a checkout exits 1 with the line on stderr, thennot_a_checkout's envelope. (FR-77, FR-78, FR-79) - AC-64 — The curl install with that record: no line with
AGENT_WORKTREE_NO_UPDATE_CHECKset, as the acceptance suite sets it, nor withCIset, nor underhookgiven aWorktreeCreateevent; the binary placed elsewhere with a day-old record: no line, and the record is left as it was. (FR-77, FR-80) - AC-65 — The curl install with a day-old record that knows a newer version, and the network unavailable:
--versionprints the version and nothing on stderr, and the record holds the check just made with the version it knew; the next--versionprints the line. (FR-78, FR-79)
- D-1 — The
agent-worktreeCLI, run by Bun from a checkout and linked ontoPATHbymise installin the primary checkout, never in a worktree, and the release binaryscripts/build-compiled.shcompiles from it for darwin-arm64 and linux-x64, shipped by a GitHub release withinstall.sh, by the npm packages, installed with npm or bun, and by thecniska/tapHomebrew formula, asdocs/releasing.mdsays. - D-2 — The
cniska-worktreeClaude Code plugin inplugin/. - D-3 —
README.mdfor the install and the essential commands;docs/contract.mdfor the project contract, where worktrees live, ports and use without the CLI;docs/commands.mdfor the commands and their branch rules;docs/plugin.mdfor the plugin;docs/development.mdfor development;docs/releasing.mdfor releasing;AGENTS.md,src/README.mdand this spec. - D-4 — The test suite, run by
bun run check, and the acceptance suite, run bybun run acceptance, whose test names cite the criteria in §5. The acceptance suite drives the CLI from source, the compiled release binary,install.shagainst a stand-in for the release downloads, and the npm packages through their launcher.
- C-1 — TypeScript on Bun, which mise installs.
- C-2 — Task checkouts are git worktrees of the primary checkout.
- C-3 — The plugin brings neither Bun nor the CLI.
- C-4 — A project's setup and teardown hooks are plain shell that run without agent-worktree, needing nothing beyond the command
installwrote into them and the variables FR-33 lists. - C-5 — Every result and error the CLI prints is TOON, encoded with the
@toon-format/toonpackage pinned at one exact version, and no other output format is offered beside it; the npm shim, plain shell that runs before any build, prints each of its three refusals as a fixed TOON document with its strings quoted.
- The hash and its length in the key, provided FR-4 and FR-5 hold.
- The ledger's format, provided FR-7 holds.
- The wording of each state in
ls. - Where the ledger keeps a block, provided FR-51 to FR-53 and FR-59 hold.
- A worktree in the earlier location is refused, not handled where it is; agent-worktree never moves one itself (FR-42).
- The
WorktreeRemovehook removes with force, since Claude Code removes a worktree only when its session is done with it (FR-37). - Port blocks start at 24000, below the ephemeral ranges of Linux (from 32768) and macOS (from 49152) and Kubernetes' node ports (30000 to 32767), and apart from a range a project keeps for itself (FR-51).
- Ports are an add-on: a worktree gets a block only when its project asks for one, and agent-worktree provides the ports and never cares how the project uses them (FR-51, FR-61). The request is a hook beside setup and teardown, written by
installand versioned with the project, not in the environment, which is the caller's; it is read before setup runs, since the block must exist by then. install --localkeeps the hooks in the clone's git directory, for a project whose other developers do not use agent-worktree and so should not get its hooks; a local hook wins over the committed one of the same name, as a local settings file wins over the shared one (FR-64, FR-65).- A block is whole units of 10, so a request of N ports takes ⌈N/10⌉ units and keeps one claim per unit, which keeps claims atomic; a project sees only
WORKTREE_PORTand its own N (FR-51, FR-56). - A port is probed by connecting on loopback, never by binding it: binding
0.0.0.0or::can raise the macOS firewall prompt on everycreate, in front of an agent nobody is watching (FR-52). prunesweeps only a worktree whose branch has an upstream (FR-28): a branchcreatejust made sits at the default branch's tip, so by reachability alone it has landed, and a sweep would tear down a live agent's fresh worktree; a worktree landed without ever being pushed stays until itsremove. The checkoutpruneorcreateruns in is never swept, so an agent creating a worktree from its own keeps the one it stands in.- A claim takes over a stale block itself rather than leave it until a
prune(FR-60); two claims at once may both run a gone worktree's teardown, as twoprunes at once may. - A branch is judged against its remote's default branch as the remote has it now, not against a local ref that may be stale, so
removeandprunereach the remote (FR-22, NF-4); when it cannot be reached, the branch is kept rather than judged blind. A squash merge counts as landed only once its host deleted the branch, and only when the branch's net change is already in the default branch, so work committed since is never lost; a branch with no upstream is judged by reachability alone, since without one a branch its host deleted cannot be told from one never pushed. createcuts a new branch fromorigin's default branch asoriginhas it now, since the primary's HEAD can trail it; it cuts from the remote FR-22 judges the branch against, so a landed branch is found landed. Whenorigincannot be reached,createis refused rather than cut from a base that may trail, and the act names--from HEADfor the caller who accepts that base (FR-13, NF-4). With noorigin, or one with no default branch, nothing newer exists to trail, and theWorktreeCreatehook cannot pass--from, socreatecuts from the primary's HEAD.- The new branch gets no upstream, even when cut from a remote-tracking branch, so FR-22 never reads it as tracking the default branch and FR-28's sweep never removes a fresh worktree (FR-13).
--fromon a registered worktree is ignored with a note, so rerunning thecreatewhose setup failed resumes it (FR-14, FR-15, NF-2); on an existing branch with no worktree it is refused, since checking the branch out would not give the base asked for and moving the branch would drop its commits (FR-13).- The network timeout is 60 seconds:
ls-remoteand a fetch ofmainfrom GitHub over ssh each took about 1.5 seconds when measured, and the margin leaves room for a large fetch on a slow link (NF-4). - A git call that meets a half-made worktree is run again rather than kept out by a lock:
git worktree addcreatescommondirempty and writes it a moment later, and the process making the worktree may be Claude Code or the owner's own git, which no lock of agent-worktree's keeps out (NF-5). The bound counts runs, not time, since each run is a whole git process and slows under load as the writer does. It is 50 runs: when measured, a listing beside sixgit worktree adds at once failed this way twice in 150 rounds and passed on the very next run each time, and 50 failing runs took about a second. removedeletes a landed branch's remote branch itself, its upstream or, with none, its same-name branch onorigin, so an agent never deletes a remote branch by hand, whether it pushed with an upstream or not, and never meets one its host already deleted;origin's default branch is never that remote branch, so amainworktree pushed without an upstream never deletesorigin'smain(FR-22).hookprints outside the envelope for both events: Claude Code reads aWorktreeCreatehook's stdout as the worktree's path and discards aWorktreeRemovehook's, so the line each prints is for a person reading the hook's log (FR-36, FR-37).- A note stays a plain
agent-worktree: <note>line on stderr, among the hooks' own output, which agent-worktree never reads (FR-35); an error's envelope comes after them, so it is the end of stderr (FR-47). lslists by default only the fields an agent acts on, a worktree's name, state and path, none of them ever null, and--fulladds the branch and port block, which are null for many worktrees (FR-30).- An install's channel is read from where its executable resolves, so a binary placed by hand at
~/.local/bin/agent-worktreereads as a curl install; anything the installer could write there instead, such as a marker, would leave every earlier curl install with no check (FR-77). The update line comes before anything else on stderr, so an error's envelope stays the end of stderr (FR-47, FR-79). - Blocks are allocated across every project on the machine, not per clone, since ports are the machine's: two clones allocating apart would both take 24000 (FR-52).
Known gaps, where the code does not yet meet a requirement:
- A whitespace
HOME. WithXDG_DATA_HOMEunset andHOMEonly spaces, the data directory is under the spaces as a relative path, not under the account's home (FR-3); AC-2's test covers only an unset and an emptyHOME.
Open questions:
- A moved primary. FR-4 keys by path, so after the primary moves, its worktrees read as gone and
prune, or acreateorinitreaching their blocks (FR-60), would tear them down. Should agent-worktree refuse instead, naminggit worktree repair? - When FR-41 to FR-45 go. They serve only worktrees made before the move to FR-1; they can be deleted once the owner's machines hold none.