Skip to content

Latest commit

 

History

History
286 lines (227 loc) · 53.6 KB

SPEC.md

File metadata and controls

286 lines (227 loc) · 53.6 KB

agent-worktree Specification

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.

1. Purpose & context

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.

2. Functional requirements

2.1 Where worktrees live

  • FR-1 — A task worktree is created at <data>/<key>/<name>, where <data> is $XDG_DATA_HOME/agent-worktree with each symlink in its existing part resolved, so the path is the one git lists for the worktree.
  • FR-2 — An XDG_DATA_HOME that is unset, empty or not an absolute path counts as $HOME/.local/share.
  • FR-3 — A HOME that 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_HOME itself 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's path, where the worktree <name> lives.

2.2 Names and projects

  • 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 .git directory inside the primary checkout, such as a submodule or a separate git dir, is refused as unsupported_layout.
  • FR-12 — A command run outside any git checkout is refused as not_a_checkout.

2.3 create

  • 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 from origin's default branch, read and fetched as FR-22 reads and fetches it; when origin does not exist or has no default branch, it is cut from the primary's HEAD, noting branch <name> cut from HEAD, no remote origin or , origin has no default branch on stderr; when origin cannot be reached, create is refused as remote_unreachable, naming create --from HEAD <name>. With --from <ref>, the new branch is cut from the commit <ref> names in the checkout create runs in, reaching no remote; a ref that names no commit there is refused as unknown_ref, and --from with an existing branch as branch_exists. create registers the worktree, runs setup in it, records setup as finished, and prints the path as its result's path. A create refused as unknown_ref, branch_exists or remote_unreachable registers nothing and makes no worktree.
  • FR-14 — create on a worktree whose setup finished prints its path and runs nothing. On a registered worktree, --from is ignored, noting worktree <name> exists, so --from <ref> is ignored on stderr.
  • FR-15 — A setup hook that fails is refused as setup_failed; the worktree stays registered with setup pending, and the next create reruns 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, naming prune.
  • FR-18 — A registered worktree whose directory git does not list as a worktree is refused as not_a_worktree.

2.4 init

  • FR-19 — init in the primary checkout runs setup there, with no worktree name.
  • FR-20 — init in a registered task worktree runs setup there with its name, and records setup as finished.
  • FR-21 — init in any other checkout is refused as not_a_managed_checkout.

2.5 remove

  • FR-22 — remove <name> runs teardown, removes the worktree, unregisters it, and settles its branch against its remote: the branch's upstream remote, else origin. 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 on origin other than origin'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. --force does not change which branch is deleted. Its result is the worktree's name, its branch, null when it is detached, and the branch's outcome with the facts that outcome has: deleted with landing, merged or squash-merged, into, the remote's default branch, and remoteBranch, the remote branch it deleted or null; or why the branch is kept: unmerged or tracks_default with into, no_remote or no_default_branch with remote, remote_unreachable with remote and detail, remote_branch_ahead with remoteBranch, remote_branch_kept with remoteBranch and detail, or checked_out with at; or detached. Remote branches are named <remote>/<branch>.
  • FR-23 — A worktree with uncommitted or untracked files is refused as dirty unless --force is given.
  • FR-24 — A worktree that contains submodules is refused as submodules unless --force is given.
  • FR-25 — A locked worktree is refused as locked, with or without --force.
  • FR-26 — A teardown that fails is refused as teardown_failed and the worktree is kept. With --force the worktree is removed and stays registered, so prune retries 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.

2.6 prune

  • FR-28 — prune drops 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, as remove does, 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 checkout prune runs in, noting worktree <name> removed, its branch landed on stderr; a worktree whose branch has no upstream, as create makes it, is never removed by prune. 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 while prune goes on with the rest, and prune is then refused as removals_failed, naming every such worktree and its cause.

2.7 ls

  • FR-30 — ls prints as its result counts, the count of listed worktrees in each state, 0 included, in the order ready, pending, orphaned, unlisted, unmanaged, legacy, and worktrees, 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 — ls with nothing to list prints counts with every state at 0 and an empty worktrees, and nothing on stderr.

2.8 Project hooks

  • FR-32 — A project needs no hook, and each of its three is optional on its own: .worktree-hooks/setup, .worktree-hooks/teardown and .worktree-hooks/ports at the checkout's root, which install writes (FR-62), or the same three in the clone's git common directory, which install --local writes and which take their place (FR-64, FR-65). With no setup hook create and init run no setup, and with no teardown hook remove and prune run 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 worktree WORKTREE_NAME, and for a worktree holding a port block WORKTREE_PORT (FR-56). WORKTREE_PATH is 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 with git worktree remove, it is unset. For the primary, WORKTREE_NAME and WORKTREE_PORT are 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 as hook_unrunnable; neither is skipped.
  • FR-35 — A hook's output goes to stderr and is never read; its exit status alone decides success.

2.9 Claude Code hook and plugin

  • FR-36 — hook given a WorktreeCreate event on stdin creates the worktree it names, as create does, 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 — hook given a WorktreeRemove event removes the worktree at its worktree_path with force, as remove --force does, and prints its branch's outcome on one line, outside the envelope (FR-46): branch <name> deleted, merged into <remote>/<branch>, or squash-merged into, followed by , and <remote>/<remote-branch> deleted when it deleted that, or branch <name> kept, and why, or detached.
  • FR-38 — A WorktreeRemove path that is not where agent-worktree keeps that name's worktree, nor a worktree in the earlier location (FR-41), is refused as foreign_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 as hook_event.
  • FR-40 — The cniska-worktree plugin runs agent-worktree hook for WorktreeCreate and WorktreeRemove, and adds a note when a session starts, resumes, clears or compacts telling the agent to create and remove worktrees with the CLI.

2.10 Worktrees in the earlier location

  • 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 by ls at that path in the state legacy.
  • FR-42 — create, init, remove and the WorktreeRemove hook on it are refused as legacy_location, naming two acts: keep it, by making its FR-1 path's parent, moving it there with git worktree move and running init in it; or drop it, with git worktree remove and then prune.
  • FR-43 — prune leaves a worktree in the earlier location registered and runs no teardown for it.
  • FR-44 — After the keep act, the worktree reads ready at its FR-1 path; after the drop act, its teardown has run and it is unregistered.
  • FR-45 — A directory under <primary>/.claude/worktrees that is not registered is not agent-worktree's: ls does not list it and no command touches it.

2.11 Output and errors

  • FR-46 — A result goes to stdout as one TOON document, the envelope command, ok: true and result, and exits 0, except that doctor exits 1 when a check fails (FR-70); init and prune have 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, or agent-worktree for an unknown command or --version given an argument, ok: false and error, which holds its kind, code, message, meta and resolve, 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-worktree with no command prints what ls prints. With --help or -h its result is commands, a table of every command's usage and summary, then --version's (FR-66); <command> --help prints 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.

2.12 Port blocks

  • 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.1 or ::1 succeeds, which a listener on 0.0.0.0 or :: accepts too; agent-worktree never binds a port to probe it. A worktree holds its block until it is unregistered, or until a prune or 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, create and init first remove the worktrees whose branch landed, as FR-28's prune does, sparing the checkout they run in and the worktree they set up, noting worktree <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 as no_free_ports, naming remove, fixing a teardown that failed for a gone worktree (FR-60), and prune in another clone (FR-59); the worktree stays registered with setup pending and no block, and once a block is freed the next create or init claims one and resumes setup.
  • FR-55 — init in 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_PORT is the first port of the worktree's block, and the project's N ports run to WORKTREE_PORT + N - 1.
  • FR-57 — ls --full shows 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 — remove frees the block when it unregisters the worktree; a worktree left registered after a failed teardown keeps its block, and its teardown during prune, or during a claim that takes the block over (FR-60), gets it.
  • FR-59 — prune frees every block whose worktree's ledger entry is gone, in any project on the machine, such as one whose clone was deleted, noting port block <first>-<last> freed from <entry>, which is gone on stderr for each.
  • FR-60 — create and init take over a stale block on their way to the lowest free one, as prune would: 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), noting port block <first>-<last> freed from <name>, whose worktree is gone on 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 create and init never meet no_free_ports. One whose ports hook holds anything but a whole number from 1 to 1000 on one line is refused as invalid_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.

2.13 install

  • 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's hooks, 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 assignment command='<command>', a quote in it written '\'', which it runs with sh -ec; the ports hook holds the number.
  • FR-63 — install with no option but --local, with an empty command, or with a --ports that is not a whole number from 1 to 1000 is refused with the usage, and writes nothing.
  • FR-64 — install --local writes the same hooks into worktree-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.

2.14 The version

  • FR-66 — agent-worktree --version prints the running build's version, as package.json states 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.

2.15 The release build

  • 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 by curl … | 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 not x.y.z installs nothing. Run again, it replaces the install.
  • FR-69 — The npm package @cniska/agent-worktree installs the build for its platform from @cniska/agent-worktree-darwin-arm64 or @cniska/agent-worktree-linux-x64, finds it where npm or bun installed it, and runs it with the arguments it was given, exiting as it exits, with no node or bun on PATH. On another platform, with the platform package missing, or with a platform build that is not executable, it refuses as FR-47 says, as no_build_for_platform, platform_package_missing or binary_not_executable, naming the cause.

2.16 doctor

  • FR-70 — doctor [--full] reports its checks, each with a name, a state of ok, warn or fail, a detail, and a fix, null when it passes. Its result is counts, how many checks are ok, warn and fail, 0 included, then checks, a table of name, state, detail and fix: without --full only the checks that warn or fail, empty when every check passes, and with --full every check. It exits 1 when a check fails, and 0 otherwise.
  • FR-71 — doctor reads PATH, 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 — doctor looks through PATH for an executable file git and agent-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 missing agent-worktree warns, naming the plugin's hooks (FR-40), since doctor cannot tell whether the plugin is installed; the fix names the PATH Claude Code starts with.
  • FR-73 — doctor warns of every port block whose ledger entry is gone, in any project on the machine, as FR-59 finds them, naming prune.
  • FR-74 — Run outside a git checkout, doctor warns 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 as hook_not_executable, or a ports hook FR-61 refuses, fails with that refusal's message and act.
  • FR-75 — For each worktree ls lists, doctor passes a ready one and warns of each other with the act that resolves it: a pending one names create to resume its setup, an orphaned one prune, an unlisted one removing its directory and then prune, an unmanaged one removing it, and a legacy one FR-42's two acts.
  • FR-76 — For each ready or pending worktree, doctor compares its port block with the ports hook its own checkout has, and warns naming init in 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.

2.17 The update notice

  • 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's install/global/node_modules, bun; that package under any other node_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 url lines of the cniska/homebrew-tap formula for Homebrew, the npm registry's latest of @cniska/agent-worktree for 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 in update-check.json under $XDG_STATE_HOME/agent-worktree, or $HOME/.local/state/agent-worktree when XDG_STATE_HOME is 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> is brew 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 CI or AGENT_WORKTREE_NO_UPDATE_CHECK is set to anything but empty, under hook (FR-36, FR-37), which the plugin runs, or in the test and acceptance suites, which set AGENT_WORKTREE_NO_UPDATE_CHECK.

3. Non-functional requirements

  • 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: HOME and 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 origin other 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 for remove and prune, and refuses create as remote_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 commondir git 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.

4. Out of scope

  • 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: install writes 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).

5. Acceptance criteria

  • AC-1 — With XDG_DATA_HOME set to an absolute scratch path, create task-a prints $XDG_DATA_HOME/agent-worktree/<key>/task-a, outside the primary, and path task-a prints the same. (FR-1, FR-6, FR-8)
  • AC-2 — With XDG_DATA_HOME unset, empty and relative, the data directory is $HOME/.local/share/agent-worktree; with HOME blank, 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 repo at different paths get different keys. (FR-4, FR-5)
  • AC-4 — A registration written under .git/agent-worktrees by hand is read as ready. (FR-7)
  • AC-5 — create with 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-a on a new and on an existing branch: the worktree on branch task-a, setup recorded with its name, path and primary, ls reading ready; a second create runs no setup. (FR-13, FR-14, FR-33)
  • AC-7 — create with a failing setup: setup_failed and pending; fix the hook and rerun: setup reruns in the same worktree and it reads ready. (FR-15, NF-2)
  • AC-8 — create and remove over an unregistered directory, a registered one whose directory is gone, and a registered directory git does not list: unmanaged leaving it, orphaned, not_a_worktree; each converges after its act. (FR-16, FR-17, FR-18, FR-27, NF-2)
  • AC-9 — init in the primary, in a pending task worktree, and in an unregistered worktree: setup without a name even with WORKTREE_NAME inherited, setup with the name and ready, not_a_managed_checkout. (FR-19, FR-20, FR-21, FR-33)
  • AC-10 — remove on 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 prints detached. (FR-22, FR-32)
  • AC-11 — remove on a dirty worktree, one with submodules, and a locked one, each with and without --force: dirty and submodules without force only; locked both times. (FR-23, FR-24, FR-25)
  • AC-12 — remove with a failing teardown: teardown_failed, worktree kept; with --force: removed and orphaned; fix teardown and prune: unregistered. (FR-26, FR-28)
  • AC-13 — remove of an unregistered name: not_registered. (FR-27)
  • AC-14 — prune with two gone worktrees, one whose teardown fails: removals_failed naming it and the exit, the other unregistered and gone from git worktree list; fix and rerun: both gone. (FR-28, FR-29, NF-2)
  • AC-15 — ls over none: every state counted 0, an empty worktrees and 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 --full branch, or null, port block and path; with no command: the same as ls, and outside a checkout not_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 — hook with a WorktreeCreate event: the path alone on stdout; with a WorktreeRemove event for a dirty worktree, run from inside it: removed, and the branch's outcome alone on stdout. (FR-36, FR-37)
  • AC-19 — hook with 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 hook for both events and the note on the four session sources. (FR-40)
  • AC-21 — A registered worktree git lists under <primary>/.claude/worktrees: ls lists it there as legacy; create, init, remove and the WorktreeRemove hook each meet legacy_location; prune runs no teardown for it and leaves it registered. (FR-41, FR-42, FR-43)
  • AC-22 — Do the keep act on it: ls reads it ready at its FR-1 path. Do the drop act on another: teardown ran with WORKTREE_PATH unset even when inherited, and ls no 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 from ls, untouched by prune. (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 usage and the usage as the act. (FR-46, FR-47, FR-48, FR-50)
  • AC-25 — The CLI with --help, -h and remove --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 HOME and 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 — create through bin/agent-worktree with 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-a and task-b: setup gets the first two free blocks as WORKTREE_PORT, and ls shows each as <first>-<last>, and create notes the block it claims; init in the primary with WORKTREE_PORT inherited: setup gets it unset. (FR-33, FR-51, FR-56, FR-57)
  • AC-31 — remove task-a: its teardown gets its block, and the next create gets that block again. (FR-51, FR-58)
  • AC-32 — Another program listens on a port of the lowest free block on 0.0.0.0 and on a port of the next on ::, then create: 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 prune in the second: it notes the block freed, and the next create there 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; two inits started at once in one of them, asking for 30: both setups get one WORKTREE_PORT and ls --full shows a block of three units. (FR-53)
  • AC-35 — Hold a port in every free block, then create: no_free_ports with the worktree pending and no block; free them and rerun: setup gets the lowest block and it reads ready. (FR-54, NF-2)
  • AC-36 — A registration with no block, written by hand: ls --full shows null; init in it: setup gets a block and ls --full shows it; ask for 30 and init: ls --full shows a block of three units; delete the claim of its middle unit by hand: ls --full shows the two runs left; init: ls --full shows a block of three units again; delete the request and init: ls --full shows null. (FR-55, FR-57)
  • AC-37 — remove --force past a failed teardown: ls --full still shows the block; fix teardown and prune: the teardown gets the block, and the next create gets it. (FR-58)
  • AC-38 — Delete a worktree's directory, then create another: the gone worktree's teardown gets its block, create notes the block freed, the new worktree gets it, and ls lists 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 create in the first, with no prune: it notes the block freed and gets it. (FR-59, FR-60)
  • AC-40 — With a local bare repository as origin: remove of a worktree whose branch was pushed with an upstream and then pushed to origin's main past the local origin/main deletes the branch and origin's copy; remove --force of one with an unpushed commit keeps it, naming origin/main; one squash-merged into main whose origin branch was deleted is deleted, and one with a commit since is kept; prune after two worktrees are deleted by hand deletes the merged branch and keeps the unmerged one, noting each; with origin pointing at no repository, remove keeps 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: create succeeds, ls --full shows null and setup gets WORKTREE_PORT unset; asking for 20: create gives setup the first port of two consecutive free units and ls --full shows <first>-<first + 19>; a ports hook holding ten: invalid_ports naming the hook, setup pending and ls --full showing null. (FR-51, FR-56, FR-61)
  • AC-42 — With a local bare repository as origin: prune removes a clean worktree whose branch was pushed with an upstream and pushed to origin's main, running its teardown and deleting its branch here and on origin, noting each; it keeps a worktree never pushed, one pushed without an upstream and then to origin's main, 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: create of another runs the landed one's teardown, removes it and takes its block; when the landed one's teardown leaves a file in it, create refuses no_free_ports, noting it with git's refusal, and the landed one stays. (FR-54)
  • AC-44 — install --setup with a command holding a quote, --teardown and --ports 20: it prints the three paths under .worktree-hooks/, each holding its command; create runs the setup command with the FR-33 variables and a block of two units, and remove runs the teardown command; the setup hook run by hand with no agent-worktree on PATH runs its command; install --teardown with another command: the teardown hook holds it and the setup and ports hooks are unchanged. (FR-32, FR-62)
  • AC-45 — install with no option, with --setup "", and with --ports 0 and --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 --setup and --ports 10: it prints the two paths under worktree-hooks/ in the git common directory and git status stays clean; create runs the local setup, not the committed one, with a block of one unit; remove runs the committed teardown; create run from inside a task worktree runs the local setup too. (FR-64, FR-65)
  • AC-47 — --version, then --version with 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: --version prints the version, create task-a in a fresh repository prints the worktree's path, and remove task-a removes it. (FR-67)
  • AC-49 — Run install.sh in 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.4 and ../x: the installed agent-worktree --version prints the release's version and ~/.local/bin holds 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 PATH and no node or bun, 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 as no_build_for_platform, platform_package_missing and binary_not_executable, each naming its cause and exiting 1; then --version prints the package version, --version now exits 2 with the build's usage refusal, and create task-a prints the worktree's path. (FR-69)
  • AC-51 — With XDG_DATA_HOME a symlink to a scratch directory, create task-a prints the worktree under the directory the symlink points to, and remove task-a removes it. (FR-1)
  • AC-52 — With a local bare repository as origin whose main is ahead of the primary's HEAD and of the local origin/main: create task-a cuts task-a at origin's main with no upstream; pushed with an upstream and pushed to origin's main, remove task-a deletes it here and on origin. (FR-13, FR-22, NF-4)
  • AC-53 — create --from v1 cuts from the tag; --from HEAD run inside a task worktree cuts from that worktree's HEAD; --from origin/main after a push to origin cuts from the local origin/main and fetches nothing; none has an upstream; --from nowhere is refused as unknown_ref and --from "" exits 2 with the usage, registering nothing. (FR-13)
  • AC-54 — With no remote, then an empty bare repository as origin: create cuts from HEAD, noting each reason; with origin pointing at no repository: remote_unreachable naming --from HEAD, nothing registered; create --from HEAD then succeeds. (FR-13, NF-2, NF-4)
  • AC-55 — create --from HEAD on an existing branch: branch_exists, nothing registered; on a worktree whose setup failed, the rerun with the same --from resumes 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 origin that would accept the delete of its main: remove of a main worktree with no upstream deletes main here and keeps origin's main; of a worktree whose branch was pushed without an upstream and then to origin's main, deletes the branch and origin's copy; of one whose origin copy another clone pushed a commit to, keeps both copies, naming origin's. (FR-22, NF-4)
  • AC-57 — With a local bare repository as origin and another worktree half made while each call runs once: create task-a fetching origin, create task-b cutting its branch, create task-c checking out an existing branch, ls and remove task-a each succeed, running that git call twice; with one that stays half made, ls fails with git's own error after 50 runs. (NF-5)
  • AC-58 — With git and agent-worktree on PATH, in a project with a setup hook, asking for 10 ports, and a ready worktree: doctor prints counts of nine ok, none warning or failing, and an empty checks, exit 0; doctor --full prints all nine, each ok with 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-worktree on PATH: agent-worktree warns naming the directories searched, the plugin's hooks and the PATH Claude Code starts with, exit 0; with no git: git fails, agent-worktree warns and the project warns as unchecked, exit 1 with ok true. (FR-70, FR-72)
  • AC-60 — doctor outside 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 holds ten: 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: doctor warns of each with its act; do each act: doctor prints an empty checks. (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: doctor warns of the block naming prune and of the worktree naming init in it, and a second doctor warns of both again; prune and init there: doctor prints an empty checks. (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: --version prints the version on stdout and exits 0, with that channel's line alone on stderr; ls outside a checkout exits 1 with the line on stderr, then not_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_CHECK set, as the acceptance suite sets it, nor with CI set, nor under hook given a WorktreeCreate event; 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: --version prints the version and nothing on stderr, and the record holds the check just made with the version it knew; the next --version prints the line. (FR-78, FR-79)

6. Deliverables

  • D-1 — The agent-worktree CLI, run by Bun from a checkout and linked onto PATH by mise install in the primary checkout, never in a worktree, and the release binary scripts/build-compiled.sh compiles from it for darwin-arm64 and linux-x64, shipped by a GitHub release with install.sh, by the npm packages, installed with npm or bun, and by the cniska/tap Homebrew formula, as docs/releasing.md says.
  • D-2 — The cniska-worktree Claude Code plugin in plugin/.
  • D-3 — README.md for the install and the essential commands; docs/contract.md for the project contract, where worktrees live, ports and use without the CLI; docs/commands.md for the commands and their branch rules; docs/plugin.md for the plugin; docs/development.md for development; docs/releasing.md for releasing; AGENTS.md, src/README.md and this spec.
  • D-4 — The test suite, run by bun run check, and the acceptance suite, run by bun run acceptance, whose test names cite the criteria in §5. The acceptance suite drives the CLI from source, the compiled release binary, install.sh against a stand-in for the release downloads, and the npm packages through their launcher.

7. Constraints (fixed)

  • 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 install wrote into them and the variables FR-33 lists.
  • C-5 — Every result and error the CLI prints is TOON, encoded with the @toon-format/toon package 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.

8. Open decisions (left to the builder)

  • 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.

Policies chosen (not open)

  • A worktree in the earlier location is refused, not handled where it is; agent-worktree never moves one itself (FR-42).
  • The WorktreeRemove hook 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 install and 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 --local keeps 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_PORT and its own N (FR-51, FR-56).
  • A port is probed by connecting on loopback, never by binding it: binding 0.0.0.0 or :: can raise the macOS firewall prompt on every create, in front of an agent nobody is watching (FR-52).
  • prune sweeps only a worktree whose branch has an upstream (FR-28): a branch create just 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 its remove. The checkout prune or create runs 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 two prunes 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 remove and prune reach 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.
  • create cuts a new branch from origin's default branch as origin has 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. When origin cannot be reached, create is refused rather than cut from a base that may trail, and the act names --from HEAD for the caller who accepts that base (FR-13, NF-4). With no origin, or one with no default branch, nothing newer exists to trail, and the WorktreeCreate hook cannot pass --from, so create cuts 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).
  • --from on a registered worktree is ignored with a note, so rerunning the create whose 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-remote and a fetch of main from 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 add creates commondir empty 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 six git 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.
  • remove deletes a landed branch's remote branch itself, its upstream or, with none, its same-name branch on origin, 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 a main worktree pushed without an upstream never deletes origin's main (FR-22).
  • hook prints outside the envelope for both events: Claude Code reads a WorktreeCreate hook's stdout as the worktree's path and discards a WorktreeRemove hook'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).
  • ls lists by default only the fields an agent acts on, a worktree's name, state and path, none of them ever null, and --full adds 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-worktree reads 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).

9. Open questions and known gaps

Known gaps, where the code does not yet meet a requirement:

  • A whitespace HOME. With XDG_DATA_HOME unset and HOME only 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 empty HOME.

Open questions:

  • A moved primary. FR-4 keys by path, so after the primary moves, its worktrees read as gone and prune, or a create or init reaching their blocks (FR-60), would tear them down. Should agent-worktree refuse instead, naming git 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.