Development record
The worktree store: `abcd ahoy worktree add|list|prune`, one lane primitive, and a notes archive beside it
Summary
This spec delivers
itd-2609091014076309:
a session's or an agent's worktree lands in ~/.abcd.noindex/worktrees/<root-sha>/<name>/,
abcd can say what that lane holds, and abcd reclaims what has merged without
deleting anything it cannot prove belongs to it.
Three verbs land as a sub-verb of ahoy (decision 5): abcd ahoy worktree add <name> creates a worktree in the lane; bare abcd ahoy worktree and
list render every worktree git reports for the repository, marking the ones
outside the store, and --all walks every lane on the machine; abcd ahoy worktree prune removes a worktree that passes the store's proof of belonging,
whose branch has merged and whose tree is clean, after moving its local tier
into a notes archive. One core package owns both stores, and the build loop's
lane code moves into it, so there is one way a worktree enters the store. The
/abcd board gains one line, computed from the scan it already runs.
The rule enacted is adr-2609091248200336; the store's shape is the transcript store's (adr-2609091248201071), and its keying reasons are not repeated here.
Scope
In: a new package internal/core/ahoy/worktree (the lane, Add, List,
ListAll, Prune, the notes archive, the merged judgement, the quiet
dossier); internal/gitutil (the worktree record carries locked and
prunable, and the common-directory read moves here from internal/core/peers);
internal/core/peers (reads the common directory through gitutil, and exposes
the worktrees its scan saw); internal/core/implement/loop (the lane step calls
the store; a held-worktree reader for prune); internal/core/history (the
sanitise-then-verify pass lifted out of Capture so the archive calls the same
function); internal/core/ahoy (an exported reader of the registry's labels
and a read-only pull-request query through its existing gh runner); the
ahoy worktree sub-tree and the board line in internal/surface/cli; the plugin
page commands/ahoy.md; the concurrent-sessions convention in AGENTS.md; the
brief's 05-internals/03-configuration.md § The worktree store
and the surfaces register row that points at it; the regenerated surface
snapshot and command reference.
Out, as the intent draws it: deleting branches, tracking refs or remote branches; moving a worktree that already sits outside the store; worktrees other tools create; retention of the notes archive; any change to the isolation rule.
Approach
One package owns both stores
internal/core/ahoy/worktree is the only code that creates, lists or removes a
directory under ~/.abcd.noindex/worktrees/ or ~/.abcd.noindex/notes/. It lives under ahoy
because ahoy owns the machine's stores and their registry, and it imports
ahoy for the two reads ahoy already owns (the scrubbed index.json loader,
through a new exported RegistryLabels, and the gh runner, through a new
exported read-only pull-request query); ahoy imports nothing from it, so the
build loop, which already imports ahoy, can import it with no cycle. The
package never prints: it returns rows and typed refusals, and the front door
formats them.
The level-by-level store maker is one function serving both stores: each level
of ~/.abcd.noindex/worktrees/<root-sha>/ and of ~/.abcd.noindex/notes/<root-sha>/<entry>/ is
made one at a time with fsutil.EnsureRealDir at 0o700, re-read with
os.Lstat, and held to fsutil.CallersAlone (owned by this uid, no group or
other write bit) before the next level is made. A symlink or a file anywhere in
the chain, a level owned by another account, or a level writable by group or
others refuses the whole operation, and nothing is made beneath the level that
failed. A level that already exists keeps its mode and is judged as it stands.
The lane primitive moves out of the loop, and piece 6 becomes its consumer
laneWorktree, ensureStore, adoptWorktree and safeSegment move from
internal/core/implement/loop/lane.go into the package as:
func LaneFor(repoRoot string) (Lane, error) // key from the root commit, full SHA only
func (l Lane) Ensure() error // the level-by-level maker above
func Adopt(repoRoot string, l Lane, name, branch string) (bool, error)
func Add(repoRoot string, opts AddOptions) (Added, error) // name, new or existing branch, base
func ValidName(name string) bool // one plain path segment
A refusal is a typed *Refusal{Reason, Remedy}, home-redacted, which the
loop wraps in its own refuse(StepWorktree, …) so its receipts read as before.
spc-2609202134338445
piece 6, the lane, is already landed as a plain git worktree add into the
store's path and says it takes the store's verb once this intent ships. It
consumes the package, not the CLI verb: the loop keeps what is its own (the
run-id and lane-id shapes in laneName, the build/ branch prefix, the base
cut from the default branch, the pick commit) and calls LaneFor, Ensure,
Adopt and Add for everything that touches the store. Add sets the new
worktree's directory to 0o700, so a lane gets the mode as add does. The
loop's existing lane tests stay as the proof that its behaviour is unchanged,
and one new test proves the lane went through the package (criterion 5).
add
abcd ahoy worktree add <name> [--branch <branch>] resolves the checkout the
caller stands in, keys the lane on gitutil.RootCommit (a repository with no
root commit refuses), validates name with ValidName (letters, digits, .,
_, -; not led by - or .; no ..; not empty) and as a branch name with
git check-ref-format --branch, and refuses a name already present in the lane,
whether git lists it or not. Without --branch it makes a new branch named
<name>, cut from the default branch (gitutil.DefaultRef) as the loop's lanes
are; a branch of that name that already exists refuses, naming --branch <name>
as the way to check it out. With --branch naming an existing local branch it
checks that branch out and makes no new one; a --branch that names no local
branch refuses. It runs git worktree add the way the person's own git does,
through gitutil.WorkingTreeCommitCommand bounded by gitutil.CheckoutBound,
as the loop's lanes are: the repository's content filters and checkout hooks
run, and a hook that waits past the bound ends add with a refusal and no
lock left behind (iss-2610111729375108, iss-2610111729399920). It records the
branch's start commit in the lane (<lane>/.starts/<name>), lays the new
worktree's .abcd/.work.local/, then chmod 0o700 on the directory git made, and prints the path
home-redacted (text) and as path (JSON). The only directories it creates are
the store's levels and the worktree itself, so nothing is written beside the
checkout. Exit 0 on success, 2 on any refusal or fault.
The proof of belonging
A worktree belongs to the store when all three hold:
git worktree list --porcelainrun from this checkout lists it;- its real path (
filepath.EvalSymlinks) is a direct child of the real path of this repository's lane~/.abcd.noindex/worktrees/<root-sha>/; git -C <worktree> rev-parse --git-common-dir, the worktree's own answer, and this checkout's common directory resolve to the same real path.
The third test is the one internal/core/peers/read.go already makes before it
reads a peer, including its refusal of anything but one line of output; the
function commonDir moves to gitutil.CommonDir and both packages call it.
gitutil.Worktree and ParseWorktreeList are extended to carry Locked,
LockReason, Prunable, PrunableReason and Detached, in both the -z and
the newline form (the newline form's C-quoted reason is unquoted), because
list renders them and prune refuses on them.
list and list --all
Bare abcd ahoy worktree and list render one row per worktree git reports for
the repository, the main checkout excepted: name, branch (or detached), path,
clean or dirty, merged or unmerged, and a where column of store or
outside. Clean is git -C <worktree> status --porcelain -z --untracked-files=all returning nothing, the same judgement git worktree remove makes, so git-ignored files (the local tier) do not make a tree dirty. A
lane row that fails the proof renders its reason in place of the state columns:
a dead .git pointer reads "checkout not found; run git worktree repair from
the checkout", a directory git lists as prunable reads "directory gone; git
still lists it". A directory in the lane that git does not list renders as
"not a worktree of this repository", and a directory directly under
~/.abcd.noindex/worktrees/ whose name is a prefix of this repository's root commit but
not the full key renders as "not a lane", naming this repository's full-key
lane and saying its worktrees are retired by hand with git worktree remove.
Moving the checkout changes nothing here: the lane is found from the root
commit, and git's own records travel with the common directory, so the moved
checkout lists the same lane; a worktree whose .git pointer still names the old
path fails proof 3 and says to run git worktree repair (criterion 18).
--all walks every directory under ~/.abcd.noindex/worktrees/ without following
symlinks. A directory named by a full root commit is a lane, labelled with the
repository's name from ahoy.RegistryLabels() (the loader that already scrubs
index.json) where that commit is registered and by the SHA where it is not.
Which checkout a lane belongs to is read from each worktree's own .git file
(gitdir: <common>/worktrees/<id>), never from the registry's path label, so
a label naming a directory that no longer exists changes no row. A lane whose
.git pointers are dead renders as one "checkout not found; run git worktree repair from the checkout" row under its SHA with no clean or merged column. A
directory whose name is not a full root commit renders as "not a lane"; when it
is a prefix of the current repository's root commit the row names that
repository's full-key lane, and every such row says its worktrees are retired by
hand. Under --all the clean and merged columns are computed from each lane's
own checkout, found through its .git pointer.
--json emits the same rows as an array under worktrees (and lanes for
--all), every collection an array, never null. Exit 0 on success, 2 on a fault.
Paths on a machine stream
A store row's path is fsutil.RedactHome (~/.abcd.noindex/worktrees/…), because the
reader must be able to cd into it. An outside row's path is
fsutil.DisplayPath: home-relative under HOME, the base name outside it, so it
stays recognisable wherever it lives without printing an absolute local path. A
branch name, a lock reason and a path are another checkout's bytes, and pass
through termsafe.Sanitize before they reach a terminal or the JSON envelope.
Merged
A worktree's branch is merged into the default branch when any of these holds, checked in order (decision 2):
- The forge records it. Where a forge is reachable, the branch's pull
request is merged and its recorded head commit is the local branch tip. The
head-commit test keeps a branch that gained commits after its merge from
reading as merged. The query is one read-only
gh pr listper run for the lane's branches, throughahoy's existing runner and its timeout, made by the caller's own identity. - By ancestry, with commits of its own. The tip is an ancestor of the default branch, and the branch holds a commit of its own: a commit, cherry-pick or merge entry in its reflog that the tip still holds, counted only when the reflog still reaches the branch's start (its creation or copy entry; a rename is read past). A branch cut and never committed to, or only fast-forwarded, is not merged, however clean; a branch whose reflog no longer reaches its start is judged by the content tests alone. A reflog can still carry another branch's history in one shape (iss-2610231011465325), so step 2 records the branch's start commit in the lane and this judgement reads the branch's own commits from it (dcn-2610231011532659).
- By content, commit by commit.
git cherry <default> <branch>lists at least one commit and every commit is marked equivalent (a rebase merge). - By content, as one change. The stable patch-id of the branch's whole diff from its merge base equals the patch-id of a commit on the default branch since that merge base (a squash merge).
"No forge reachable" is any of: gh not on PATH, not authenticated, no GitHub
remote, or no answer within the timeout. The run then judges by 2 to 4 alone
and says so once. The merged column carries how it was judged (forge,
ancestry, content), so a reader can tell a forge answer from a content one.
The list and prune pages document the forge read as part of what the verb
does, which is what keeps it inside
invariant 7: a fetch only when
the user invokes a verb whose documented meaning is that fetch. The board never
asks the forge.
prune
abcd ahoy worktree prune [--dry-run] [--yes <name>]… [--json] reads the lane,
applies the proof and the merged judgement, and for each worktree decides one
row: reclaimed, quiet (a candidate, below) or kept with a reason. Kept
reasons, each named in the row:
- fails the proof of belonging (not listed by git, outside the lane, another
checkout's common directory, a dead
.gitpointer): reported, untouched; - outside the store: listed and never touched;
- locked, with git's lock reason;
- prune's own working directory (the real path of the cwd is the worktree or inside it);
- held by the build loop: a lane of a run whose state is not complete names this
worktree. The loop exports
HeldWorktrees(repoRoot), read across the local tier of every checkout git lists for the repository, because a run's state lives in the checkout that started it; the front door hands it toPruneas a seam, the shapestatusblock.LaneReaderalready takes, so the store never imports the loop that imports it. A state file that cannot be read holds every worktree named after its run id; - dirty;
- unmerged (and not a quiet candidate);
- the notes could not be archived (the archive's reason).
A reclaimable worktree is removed by archiving its notes, then running git worktree remove <path> without --force. Git re-checks the tree and the lock
at that moment and refuses a tree that changed since it was judged; the refusal
becomes a kept row with git's reason, and the archive entry already written
stays and is named, since the tier still stands in the kept worktree and
nothing is lost. git worktree remove deletes that worktree's administrative
entry with it, which is git's metadata prune for that one worktree; the
repository-wide git worktree prune is never run, because it would also drop
the entry of an outside worktree whose directory is only temporarily absent.
Prune never passes --force, never deletes a branch, and deletes nothing git
did not delete. The invariant this enacts is the brief's: the store deletes only
what passes its proof of belonging.
A store refusal (a symlinked level, a level another account owns or others can
write) stops the run before anything is judged: exit 2, nothing removed.
Otherwise the exit code is 0 when at least one worktree was reclaimed, 1 when the
run completed and reclaimed nothing (an empty lane included), and 2 on a
refusal or fault (decision 8). --dry-run makes no archive and removes nothing,
and reports the rows a real run would, with would reclaim in place of
reclaimed; its exit code is the one the real run would return. --json emits
the same rows under rows, with archive naming the notes entry on a reclaimed
row.
The notes archive
A worktree's .abcd/.work.local/ is walked without following symlinks. When it
holds anything, prune makes ~/.abcd.noindex/notes/<root-sha>/<YYYYMMDDTHHMMSSZ>-<name>/
through the shared level maker (the entry itself made exclusively, so two runs
in one second cannot share it) and writes into it, inside an os.Root opened on
the entry, each regular file at its relative path, 0o600, and a
manifest.json: schema_version, worktree, branch, source
(home-redacted), archived_at (UTC), root_sha, and files (path, size and
the SHA-256 of what was written). A symlink is recorded in the manifest as a
link, target home-redacted, and not followed. The tier's top-level bin/,
where scripts/abcd.sh builds the checkout's binary, is a build product, not
a note: the walk skips it, and it goes with the worktree (iss-2611022247131866).
Every file and every manifest field is redacted on write through the same pass
the transcript store uses. The body of history.Capture that does it (refuse a
degraded scanner; scan with the per-repository scanner and the armed gitleaks
adapter; scanner.Redact; the caller-home sweep and its survivor refusal; the
stage-two re-scan that refuses any blocking residual) is lifted into one
exported function in internal/core/history, and both Capture and the
archive call it, so there is one redaction path, not two. A file that is not
text, is over the per-file cap, or leaves a residual fails the archive, and a
failed archive keeps the worktree, named with the reason and the file's
relative path; nothing from the tier is ever deleted by a failed step. A tier
that is absent or empty writes no entry, and the row says there were no notes.
The report names the archive path, home-redacted.
Quiet candidates
An unmerged worktree in the lane is quiet when its last activity is older than
the quiet window and no pull request for its branch is open (where a forge is
reachable; without one the window alone decides) (decision 3). A worktree on a
detached HEAD is never quiet: its commits may be on no branch, and a removal
drops the HEAD reflog that is their last reference. Last activity is the latest
of the branch tip's committer date, the modification time of the worktree's
.git link file (written when the worktree is added, so a checkout added from
an old tip is as recent as its creation), and the newest modification time of
the files git status reports changed or untracked. The window is the layered
configuration key worktrees.quiet_days in .abcd/config.json, read through
internal/core/layered (the repository file, then ~/.abcd.noindex/config.json, then
the bundled default of 14), which claims the worktrees namespace, so a
misspelt key or a value below 1 is refused naming its file, never replaced by
the default.
Prune lists each quiet candidate with a dossier, in this order: its dirty state
first (counts of modified and untracked files, and the first paths), the paths
git ignores outside the local tier (a removal deletes them, and only the tier is
archived), the subjects of the branch's commits not on the default branch, a diff summary
(--shortstat from the merge base), the record ids the subjects and changed
paths cite, its last activity, and a recommendation ("commit or discard the
changes, then name it", "reclaim with prune --yes <name>", "open a pull
request if the work is wanted", or, when the branch's commits could not be
read, "check its branch for work first"), which names any ignored paths that go
with it (a wholly ignored .abcd/ counted by its entries beside the local tier),
or says they could not be listed when git refuses the listing. A candidate is left in place unless named with
--yes <name> (repeatable); the core never prompts. A named candidate takes the
same archive step and the same refusals as a merged worktree, a dirty tree
included, so uncommitted work stays until the person commits or discards it.
Every --yes name is checked before anything is removed: a name that is not in
the lane refuses the run (exit 2, nothing removed), and a name in the lane that
is not a quiet candidate this run is kept and named with why (merged ones need no
name; an active one is not quiet).
The board line
The /abcd board's boardPeers already runs peers.Scan for the checkout.
The scan is run once and feeds two members: peers, unchanged, and a new
worktrees member, {"held": N, "reclaimable": M}, rendered as one text line
("worktrees: N in the store, M can be cleared (abcd ahoy worktree prune)") and
omitted when N is 0. peers.Report gains an accessor listing each linked
worktree its scan saw, with its path and whether the scan judged it spent
(merged by ancestry, record folders clean); the board counts those whose real
path lies inside this repository's lane as held, and the spent ones among them
as reclaimable. The lane path costs one root-commit read (gitutil.RootCommit)
for the checkout, not a read per lane, and the line starts no status read of its own.
The member is on boardOutput in internal/surface/cli and nowhere else: the
Now / Next / Later block the site renders (statusblock.Block) does not carry
it.
The surfaces
commands/ahoy.md gains a worktree section documenting add, list
(--all), prune (--dry-run, --yes), the forge read, and the host-run step
(decision 6): an agent that needs an isolated checkout (a lane, a subagent, a
peer session) runs abcd ahoy worktree add <name> and works in the printed
path, never a host's default worktree location. The prose stays host-agnostic.
AGENTS.md's concurrent-sessions convention replaces "The store has no verbs
yet. Aim a plain git worktree add at the path and create the lane by hand…"
with the verb: a session gets its own checkout with go run ./cmd/abcd ahoy worktree add <name> (in this source checkout) and works in the printed path;
bare ahoy worktree lists the lane and ahoy worktree prune reclaims what has
merged. The paragraph keeps its reasons for the location and names no sibling
directory as a place to work.
The brief's worktree-store section carries the store primitive and the
invariant (decision 1), and the surfaces register's row for the store points at
the ahoy chapter once the verb ships. The surface snapshot and the generated
command reference are regenerated with the sub-tree.
How each acceptance criterion is met
Addmakes the worktree at~/.abcd.noindex/worktrees/<root-sha>/<name>/on the branch, the front door prints the path, and the only directories created are the store's levels and the worktree, so the checkout's parent is unchanged.- The shared level maker makes and re-verifies each level in turn; a symlink at any level refuses before anything beneath it is made.
fsutil.CallersAloneon every level refuses another account's level or one group or others can write, before anything is written.ValidName, the lane-occupancy check and the root-commit check refuse with the reason;--branchon an existing branch checks it out with no new branch.- The loop's lane step calls
LaneFor,Ensure,AdoptandAdd; a test proves the lane was made through the package. - Bare
listrenders every worktree git reports, with thewherecolumn marking the sibling as outside, and branch, clean and merged on each. --alllabels lanes fromRegistryLabels()or by SHA, reads ownership from each worktree's.gitfile (so a stale registry path changes no row), renders a dead pointer as "checkout not found" with the repair hint, and an abbreviated directory as "not a lane", naming the full-key lane on a prefix match and saying its worktrees are retired by hand.- A merged, clean worktree passing the proof is removed by
git worktree remove, which drops git's entry for it; the branch is never deleted; the row names it. - The squash test (patch-id of the whole branch diff against the default branch's commits) judges it merged with no forge, and it is reclaimed.
- The archive writes the redacted files and the manifest under
~/.abcd.noindex/notes/<root-sha>/<timestamp>-<name>/before removal and the row names the path; a failed archive keeps the worktree with the reason. - The quiet rule lists the candidate with its dossier (dirty state first) and
leaves it;
--yes <name>archives and removes that one only; a dirty named candidate is kept because removal is never forced. - A store refusal stops the run before judgement: exit 2, nothing removed.
- Dirty, unmerged and outside rows are kept with reasons and the run exits 1; with a merged, clean worktree added it exits 0.
- The proof of belonging fails for a directory git does not list, or whose common directory is another checkout's; it is reported and untouched.
- The locked, own-cwd and held-by-the-loop refusals keep all three, and no
removal passes
--force. --dry-runcomputes the same rows and skips the archive and the removal;--jsonemits those rows.- The board's
worktreesmember comes from the onepeers.Scan, is omitted on an empty lane, and is not part ofstatusblock.Block. - The lane is found from the root commit, so the moved checkout lists the same lane.
Addsets the worktree directory to0o700after git creates it.AGENTS.md's convention names the verb and no sibling-directory form.commands/ahoy.mdcarries the host-run step.
Settled here, not by the intent
These are the facilitator's design calls, each in the direction that keeps a worktree rather than loses one; none changes a criterion.
addcuts its new branch from the default branch, as the loop's lanes are.- A branch with no commits of its own is not merged by ancestry (reflog creation point), so a freshly added, clean worktree is never reclaimed.
- A forge answer counts only when the pull request's head commit is the local tip.
listasks the forge asprunedoes, documented as part of the verb, so the merged column means one thing everywhere; the board never asks.- The intent's "git's own metadata prune for it" is
git worktree remove, which drops that worktree's entry; the repository-widegit worktree pruneis never run. - A file the scanner cannot redact (not text, over the cap, a residual) fails the archive and keeps the worktree.
- An absent or empty local tier writes no archive entry.
--yesadmits only a quiet candidate; a name not in the lane refuses the run.--dry-runreturns the exit code the real run would.- "Held by the build loop" is a lane of a run whose state is not complete, read across every checkout of the repository.
- 2026-10-04: the store's paths are spelled under the renamed home,
~/.abcd.noindex/worktrees/and~/.abcd.noindex/notes/, the location adr-2610031751065746 sets when it supersedes adr-2609091248200336's~/.abcd/spelling (spc-2610031309233367, step 4). Nothing else in this record changes.
Open point
None open. The board's "can be cleared" count is the peers scan's judgement, not prune's: it can miss a squash-merged branch and can count a locked or freshly cut worktree, or a detached one whose HEAD is on the default branch, which prune keeps. The product thinker ruled on 2026-09-30 that the line keeps the words "can be cleared" as an estimate, and that prune's own report is the authority on what was cleared. The --json member keeps the field name reclaimable.
Footprint
- packages: internal/core/ahoy/worktree, internal/core/implement/loop/lane.go, internal/core/implement/loop/lane_test.go, internal/core/implement/loop/lane_store_test.go, internal/core/peers/read.go, internal/core/peers/peers_test.go, internal/gitutil/worktree.go, internal/gitutil/worktree_test.go, internal/gitutil/commondir.go, internal/gitutil/commondir_test.go
- packages later: widened step by step as each step is reached (dcn-2610222227336378): internal/README.md, internal/core/ahoy/registry_labels.go, internal/core/ahoy/pull_requests.go, internal/core/implement/loop/held_worktrees.go, internal/core/implement/loop/held_worktrees_test.go, internal/core/history/history.go, internal/core/history/sanitize.go, internal/core/history/sanitize_test.go, internal/core/statusblock/statusblock_test.go, internal/surface/cli/ahoy_worktree.go, internal/surface/cli/ahoy_worktree_test.go, commands/ahoy.md, AGENTS.md, .abcd/development/brief/05-internals/03-configuration.md, .abcd/development/brief/04-surfaces/README.md, .abcd/development/brief/04-surfaces/01-ahoy.md, .abcd/development/brief/02-constraints/03-invariants.md, and, once the merge gate lands, internal/core/board/board.go, internal/core/board/board_test.go, internal/surface/cli/cli.go, internal/surface/cli/board.go
- tests: the level maker over a symlinked, foreign-owned and group-writable level; add's path, mode, branch and name refusals; the loop's lane tests unchanged plus one proving the lane goes through the package; list over a lane of two plus a sibling; list --all over registered, unregistered, moved and abbreviated lanes; the merged judgement over ancestry, a never-committed branch, a rebase merge and a squash merge with no forge; prune's reclaim, keep reasons, exit codes and dry-run; the notes archive's redaction, manifest and failure path; quiet candidates and --yes; the board line from one scan and its absence from the site block; the worktree parser's locked and prunable fields in both forms
Steps
- The store package and the lane primitive
- criteria: 2, 3, 5
- needs: none
- packages: internal/core/ahoy/worktree, internal/core/implement/loop, internal/core/peers, internal/gitutil
- tests: the level maker refuses a symlinked, foreign-owned or group-writable level and makes nothing beneath it;
ParseWorktreeListcarries locked and prunable in the-zand newline forms;gitutil.CommonDirrefuses a multi-line answer; the loop's existing lane tests pass unchanged and a new one proves the lane goes through the package - landed: 1009cdd4a312
ahoy worktree add- criteria: 1, 4, 19
- needs: 1
- packages: internal/core/ahoy/worktree, internal/surface/cli
- tests: add makes the worktree in the lane, prints its path, leaves the checkout's parent unchanged and sets
0o700;../x,-x, a taken name, a name a sibling store holds in the lane (embargo, read frominternal/core/embargo/storepath.Name, never copied; iss-2610230014564359) and a repository with no commits refuse and write nothing;--branchchecks out an existing branch and makes none; add records the new branch's start commit in the lane, and the merged judgement reads the branch's own commits from it, not the reflog, so a copy of a created branch, force-reset and left until its reflog expires, is not merged (iss-2610231011465325) - landed: 5ce7c0e882e8
list,list --alland the merged judgement- criteria: 6, 7, 18
- needs: 1
- packages: internal/core/ahoy/worktree, internal/core/ahoy, internal/surface/cli
- tests: three rows for a lane of two plus a sibling, the sibling outside; registered and unregistered lanes under their labels, a stale registry path changing nothing, a moved checkout's lane as "checkout not found", an abbreviated directory as "not a lane" naming the full key; the same lane from a moved checkout; the merged judgement over ancestry, a never-committed branch, a rebase merge and a squash merge with the forge unreachable, and a forge answer whose head is not the tip; the embargo store's directory listed as abcd's own sibling store, never as a lane or a candidate (iss-2610230014564359); a worktree's lock or prune reason carrying control bytes rendered escaped, in the text and JSON forms alike (from step 1's review)
- landed: 5ce7c0e882e8
prunefor merged worktrees, with the notes archive- criteria: 8, 9, 10, 12, 13, 14, 15, 16
- needs: 3
- packages: internal/core/ahoy/worktree, internal/core/history, internal/core/implement/loop, internal/surface/cli
- tests: a merged clean worktree removed with its branch standing; a squash-merged one reclaimed with no forge; the tier archived redacted beside its manifest and a failed archive keeping the worktree; a store refusal exits 2; dirty, unmerged and outside kept with exit 1 and exit 0 once one is reclaimed; a directory failing the proof untouched; locked, own-cwd and loop-held kept; the embargo store's directory never judged, archived or removed (iss-2610230014564359); dry-run and
--jsonmatching a real run's rows;history.Capturestill passing its suite through the lifted pass - landed: 5ce7c0e882e8
- Quiet candidates
- criteria: 11
- needs: 4
- packages: internal/core/ahoy/worktree, internal/surface/cli
- tests: a 15-day-quiet unmerged worktree listed with its dossier, dirty state first, and left;
--yes <name>removes that one only; a dirty named candidate kept;worktrees.quiet_daysread through the layered resolver and a bad value refused; a--yesname not in the lane refusing the run - landed: 5ce7c0e882e8
- The board line
- criteria: 17
- needs: 1, 4
- packages: internal/core/peers, internal/surface/cli
- tests: one line with the held and reclaimable counts from one scan; absent on an empty lane; absent from the site's rendered status block
- landed: 5ce7c0e882e8
- The surfaces
- criteria: 20, 21
- brief: when the store ships,
02-constraints/03-invariants.mdgains the invariant "the store deletes only what passes its proof of belonging" (adr-2609091248200336), held by the prune refusal tests; it is not added before the code that holds it exists. - needs: 2, 5, 6
- packages: commands/, AGENTS.md, .abcd/development/brief/05-internals, .abcd/development/brief/04-surfaces, internal/surface/cli, internal/README.md (the package map gains
internal/core/ahoy/worktree), and05-internals/03-configuration.md's store section drops the deletedWorktreeStoreRel(both found by step 1's review) - tests: the plugin page carries the host-run step;
AGENTS.mdnames the verb and no sibling form; the surface snapshot and command reference regenerated; docs-lint and record-lint clean
Frontmatter
| id | spc-2609301811532881 |
|---|---|
| type | spec |
| lifecycle | open |
.abcd/development/specs/open/spc-2609301811532881-session-and-agent-worktrees-live-in-a.md
open on GitHub ↗ · commit history ↗
References1
- itd-2609091014076309Session and agent worktrees live in a machine-scoped store that abcd lists and reclaims, never beside the user's own projectsimplements
mentions15
- adr-2609091248200336A tool never creates directories in user-owned project space; the store's location binds now and its verbs bind when the store ships
- adr-2609091248201071The transcript corpus is a sibling store that creates itself through the canonical directory primitive
- adr-2610031751065746The worktree store lives under the renamed home, `~/.abcd.noindex/worktrees/<root-sha>/<name>/`, and the rest of its rule is unchanged
- dcn-26093000000000222026-09-30 — The worktree store is planned (itd-2609091014076309, spc-2609301811532881), after two adversarial reviews and a planning interview with the product thinker and technical facilitator, asked one at a time. Decomposition: file as is, with no new ADR or principle; the build loop's lane primitive moves into the store's package, so the intent `refines` itd-2609201916151817. Rulings: the verb is `abcd ahoy worktree add|list|prune`; "merged" is the forge's recorded pull-request state or patch-equivalence, and it works with no forge; an unmerged worktree quiet for 14 days (repo-configurable) is surfaced with a dossier and removed only when named (`--yes <name>`), never forced; a reclaimed worktree's local tier is moved, never deleted, into a new sibling store `~/.abcd/notes/<root-sha>/<timestamp>-<name>/`, redacted on write with a manifest; the plugin page carries `add` as a host-run step; the worktree directory is `0o700`; prune exits 0 when it reclaimed something, 1 when it reclaimed nothing, 2 on a refusal; a directory keyed on an abbreviated root commit is listed as "not a lane" and names the full-key lane; the board line keeps "can be cleared" as an estimate. The 21 acceptance criteria are the product thinker's. Grounds: "pursued: working copies keep piling up with nothing to list or clear them; wrong if, after it ships, copies still accumulate outside the store." Carried from itd-148's 2026-08-26 interview and confirmed here: the merge proof and the quiet sweep. Also ruled: itd-148 keeps its mint-visibility criterion as peer awareness (its Decision 5).
- dcn-26102222273363782026-10-22 — The worktree store's footprint is narrowed to the files its steps change (abcd-ae's ruling as coordinator of 2026-10-22, spc-2609301811532881, itd-2609091014076309): the spec's footprint named whole packages, among them internal/core/implement/loop and internal/surface/cli, which the merge gate's spec also names, so the first to reserve would have held the other off for days; each spec now names the files its steps' packages and tests lines change, read from those lines and never guessed narrow, and a file still in both after narrowing goes to the merge gate first, the store sequencing its step on that file after the merge gate lands; agreed with abcd-43 the same night: internal/core/board/board.go and its test, internal/surface/cli/cli.go and internal/surface/cli/board.go are the merge gate's first, so the store's footprint leaves them out until the merge gate lands and its steps 2 and 6, which need them, are not driven before then, the footprint then widening to take them; and the generated docs/reference/cli/commands.md and .abcd/development/release/surface.json are reserved by neither spec, since they derive from the code and whoever lands second regenerates them; and since other live lanes hold the brief and command-page files only step 7 needs, the footprint names step 1's files first and widens as each step is reached, which the build's reservation supports by reserving again with the widened footprint (grounds: AGENTS.md concurrent sessions, reserve with the footprint the work will change; the reservation's overlap rule)
- dcn-26102222282518882026-10-22 — The technical facilitator on the product thinker's delegation (ruled by the coordinating session, abcd-ae, 2026-10-22): spc-2610161609350616 (the merge gate) narrows its Footprint packages line from directories to the files its six steps change, because a directory footprint over-reserves and serialises unrelated work (it collided with the worktree store spec spc-2609301811532881 on internal/core/implement/loop and internal/surface/cli). Files still named by both specs (internal/core/board/board.go and its test, internal/surface/cli/cli.go, internal/surface/cli/board.go) are the merge gate's first; the store sequences its steps on them after the merge gate lands. Generated output (docs/reference/cli/commands.md, .abcd/development/release/surface.json and any other linguist-generated file) is reserved by no one and regenerated by whoever lands second.
- dcn-26102310115326592026-10-23 — The worktree store's steps 3 and 4 land with the merged judgement reading a branch's own commits from its reflog, after four review rounds closed every reflog shape that gives a false merged except one (iss-2610231011465325): a copy of a created branch, force-reset and left until its reflog expires. It is deferred to step 2 rather than chased further, because each closed shape exposed another and the sound fix is a different source, not a better reflog read: step 2's add records the branch's start commit in the lane, and the judgement then reads the branch's own commits from that. The case needs three rare acts together, every commit it holds is already on the default branch, and the store never deletes the branch. Facilitator, on the technical delegation.
- iss-2610111729375108abcd's lane worktrees and restarts in a repository that uses Git LFS hold LFS pointer text instead of the real files. Lane creation (`git worktree add`, internal/core/implement/loop/lane.go) and a restart's `reset --hard` (restart.go) run through gitutil's isolated environment, which sets GIT_CONFIG_GLOBAL=/dev/null (internal/gitutil/repo.go), so the filter that a normal, global `git lfs install` defines is never seen and nothing is smudged. Reproduced 2026-10-11 with git-lfs 3.7.1: in a repository whose image is tracked by LFS, the person's own `git worktree add` writes the image's bytes, while the same command with GIT_CONFIG_GLOBAL=/dev/null writes `version https://git-lfs.github…` pointer text. The implementer then builds and tests against placeholders. This is abcd changing how a repository works, which the security scope rule (principle abcd-fixes-what-it-adds) forbids.
- iss-2610111729399920Several hardenings shipped in v0.13.4 switch a repository's own git settings off for abcd's commands, and need re-assessing against the security scope rule adopted on 2026-10-11 (principle abcd-fixes-what-it-adds: abcd never changes how a repository works on its own, and prevents an existing risk only where it can do so without such a change). Known instances: a sync's merge makes every configured merge driver git's built-in merge (gitutil.MergeDriverOverrides), so a repository's own lockfile or union-style driver is ignored on abcd's sync; the pick commit runs with commit.gpgsign=false and hooks off (gitutil.ExecPins via pickGit), so it is unsigned where the repository requires signed commits; the toplevel check refuses a checkout whose core.worktree names an ancestor, which refuses a deliberately configured layout. Reads that blank content filters (gitutil.FilterOverrides) change no repository behaviour and are likely fine.
- iss-2610230014564359The worktree store's spec (spc-2609301811532881) never mentions the embargo store, which lives inside the same lane as ~/.abcd.noindex/worktrees/<root-sha>/embargo/ (internal/core/embargo/storepath, Name = "embargo"): a later step's add could take the name embargo, and list, prune and quiet-candidate scans could judge or reclaim the embargo store's detached worktree as a stale lane, so an unpublished advisory's private records could be archived or removed; found by the ruthless review of the store's step 1 on 2026-10-23, which judged nothing reaches it yet because lane names cannot be embargo
- iss-2610231011465325The worktree store's merged judgement reads a branch's own commits from its reflog, and a reflog can carry another branch's history: a branch copied from a branch that was itself created, then force-reset and left until its unreachable reflog entries expire, keeps the source's creation entry and commit entries, so it is judged merged by ancestry with no commit of its own, and prune could remove its clean worktree with its ignored files. Every commit it holds is already on the default branch, so no commit is lost. Found by the Opus review of the store's steps 3 and 4 on 2026-10-23, after three earlier reflog shapes were closed.
- iss-2611022247131866ahoy worktree prune keeps merged, clean worktrees it should reclaim when their notes hold the locally built bin/abcd: on 2026-11-02 about ten merged worktrees were kept because that binary is over the notes archive's 4 MiB per-file cap, though it is a build product, not a note.
- iss-2611030744332925Older build runs landed spec steps without marking them landed: on 2026-11-03 four open specs held merged steps with no landed line (spc-2610240951109420 steps 1-2 from #1145 and #1193, spc-2610161949559050 steps 1-2 from #1179 and #1187, spc-2609301811532881 steps 1-6 from #1110 and #1197, spc-2610230730269295 step 1 from #1197), so the board offered them as ready from step 1 and abcd build would have rebuilt delivered work. Found only by grepping merge messages.
- spc-2609202134338445one-verb-takes-a-single-intent-from-ready-to-delivered-witho
- spc-2610031309233367abcd-keeps-its-home-folder-out-of-desktop-search-by-default