Development record
A tool never creates directories in user-owned project space; agent and session scratch is machine-scoped
Context
AGENTS.md's concurrent-sessions convention makes the checkout the unit of
isolation: one checkout has one working tree, one HEAD and one index, the
record's gates read the whole tree, and a second session in the same checkout
fails both sessions' gates in both directions. A parallel agent therefore
needs its own worktree. That much is settled and is not reopened here.
What was never settled is where the worktree goes, and the answer by default
is git's: a sibling directory, next to the checkout, in whatever directory the
user keeps their projects in. On 2026-09-01 twenty-one spent worktrees
(1.4 GB) were removed by hand from the maintainer's project directory
(iss-2609020721142452).
On 2026-09-06 a single session created twenty-two more, beside four projects
that have nothing to do with abcd, and git worktree list on that checkout
names twenty-seven. The maintainer's objection, verbatim: "I don't want a user
to be surprised that a folder is all of a sudden full of stuff."
The record already has a home for machine-scoped state and a key for it.
~/.abcd/history/<root-sha>/, ~/.abcd/transcripts/<root-sha>/ and
~/.abcd/voyage/<root-sha>/ are each keyed on a repository's root-commit SHA,
for the reason internal/core/history/location.go gives: a checkout moves, is
renamed and is cloned twice on one machine, while its root commit does none of
that (adr-2609090717039680).
The voyage store moved there because an in-tree operations log would have
carried absolute paths into the repository (adr-35),
and the same record made embark refuse to overwrite a directory abcd did not
produce. The read side of this rule is already in force: the loader refuses a
configuration root the caller does not own, and only a declaration in the
caller's own home (~/.abcd/trusted-roots) re-admits it. What the record lacks
is the write side, stated once: whose space a tool may create things in.
A neighbouring principle answers a different question and is not restated
here. durable-state-lives-where-the-platform-says-it-survives
chooses a location by what the platform documents as surviving an update, a
re-clone or a sweep, priced against the 2026-08-21 plugin-update post-mortem.
It says where state survives. This record says whose space it is. A worktree
store under ~/.abcd/ satisfies both, and a location can pass one and fail the
other: a sibling directory survives every platform event and still belongs to
the user.
Decision
A tool never creates a directory in user-owned project space. The parent
of a checkout, the directory the user keeps their projects in, and any
directory the user did not hand over are theirs. The checkout itself is handed
over only on the record's terms: .abcd/.work.local/ is scratch by
declaration, .abcd/work/ and .abcd/development/ hold what the tiers say,
and the repository root holds nothing a tool put there on a guess (a stray
root file is already a stray_root_docs finding). Beside the checkout there
is no declared tier at all, so nothing goes there.
Agent and session scratch is machine-scoped. A worktree for a parallel
session, a verifier's scratch copy, an export nobody has asked for a home for:
each lives under the caller's own ~/.abcd/, keyed on the repository's
root-commit SHA where it belongs to a repository, on the shape the history,
transcript and voyage stores already take. The store creates itself through
one seam, one level at a time, never through a symlink, exactly as the
transcript store does, because it needs no authority the caller does not hold.
What a tool puts in its own space, it can list and reclaim. Machine scope
is invisible to ls where the user works, which is the point, and the price
of that invisibility is a verb that lists what the store holds and a verb that
reclaims what is spent, with a line on the status board so the user learns the
count without asking. A store nobody can list is the same pile somewhere less
visible. itd-2609091014076309
carries the worktree store, its list verb, its prune verb and the board line;
until it ships, the rule is followed by hand and says so in the principle.
A directory the user made is theirs even inside the tool's own store. The list verb reports it; nothing moves or removes it. The store deletes only what it can prove it created, on the ground embark's destination gate already stands on.
The rule binds an agent's conventions as much as the binary. The
directories in the maintainer's project folder were created by agents
following AGENTS.md, not by abcd, and the rule would be hollow if it bound
only the Go. AGENTS.md's concurrent-sessions convention names the store as
the place a session's worktree goes, and names no sibling-directory form.
Alternatives Considered
- Git's default: a sibling directory, wherever the checkout is. The status quo, and the shape every agent reaches for unprompted. Rejected: it is the thing the maintainer objected to. It also gives nothing a stable place to look, so a spent worktree is found by a human noticing a folder and remembering what it was.
- Inside the checkout, under
.abcd/.work.local/worktrees/. Gitignored, per-worktree, and with its owner. Rejected: a worktree inside the main working tree is walked by every tree scan — the payload gate, the name guard, docs-lint — which already flakes during worktree creation (iss-2608261331317889) and would slow every gate by a full tree per lane. The local tier is the right answer for small scratch and the wrong one for a second copy of the repository. - A configured location, chosen per user in
config.json. Flexible, and a user who wants siblings could have them. Rejected: the default would still have to be something, and a setting that defaults to the user's project space reintroduces the surprise for everyone who never sets it; a setting that defaults to~/.abcd/is the chosen option with a knob nobody has asked for. - Chosen: a machine-scoped store under
~/.abcd/, keyed on the root commit, with list and reclaim. The location the record already uses for everything machine-wide, the key that survives a checkout moving, and the two verbs that keep the store from being a pile.
Consequences
- The worktree store is an intent, not a fait accompli. The rule is accepted today; its enforcement on the write side is itd-2609091014076309 in drafts, and the principle that carries the stance says the enforcement does not yet exist rather than implying it does.
- The twenty-seven sibling worktrees on the maintainer's machine are a hand
cleanup, not a migration. The store never moves what it did not create, so
nothing abcd ships will relocate them; they are removed with
git worktree removeby the person whose directory they sit in. AGENTS.mdchanges. The concurrent-sessions convention gains the store as the place a worktree goes, and loses any wording that reads as a sibling directory. That edit lands with the intent, because a convention naming a verb that does not exist is a phantom gate.- The rule reaches beyond worktrees. A verifier's scratch copy
(
git archive HEAD | tar -x -C <scratch>, perAGENTS.md) and any export whose home is unclear are covered by the same sentence; today's guidance routes them to.abcd/.work.local/scratch/, which remains right for what fits in a checkout and is joined by the machine scope for what does not. - The read side and the write side are now one stance.
trusted-rootsrefuses a root the caller does not own on the way in; this record refuses writing into space the caller was not handed on the way out. Neither cites the other yet; the principle names both. - What the harness itself creates is outside abcd's reach. A harness's own
checkout-isolation feature puts its worktree wherever the harness decides.
The convention can only say which route a session should take; it cannot
move what another tool made, and the intent's open questions carry whether
the plugin surface should offer
addas a host-run step for that reason.
Status note
Accepted by the maintainer's ruling of 2026-09-09, recorded in
dcn-2609090000000002 and split there into
three records: this decision (the trust rule), the intent (the store and its
verbs) and the principle
the-users-directory-is-theirs
(the stance, with its enforcement stated as future work).
Frontmatter
| id | adr-2609091014087993 |
|---|---|
| type | adr |
| status | superseded |
| date | 2026-09-09 |
.abcd/development/decisions/adrs/2609091014087993-a-tool-never-creates-directories-in-user.md
open on GitHub ↗ · commit history ↗
References4
- adr-35The lifeboat is a coverage experiment — read-only, out-of-tree, and proven before it is packed (supersedes adr-4)related
- adr-2609090717039680The transcript corpus is a sibling store that creates itself, and the per-repo location is an opt-in pullrelated
- itd-118Merged work leaves no residue: abcd-managed repos delete a PR branch on merge, and abcd tidies the stale local branch, tracking ref, and worktreerelated
- itd-2609091014076309Session and agent worktrees live in a machine-scoped store that abcd lists and reclaims, never beside the user's own projectsrelated
Referenced by2
- adr-2609091248200336A tool never creates directories in user-owned project space; the store's location binds now and its verbs bind when the store shipsrelated
- adr-2609091248200336A tool never creates directories in user-owned project space; the store's location binds now and its verbs bind when the store shipssuperseded by
mentions9
- adr-2610031751065746The worktree store lives under the renamed home, `~/.abcd.noindex/worktrees/<root-sha>/<name>/`, and the rest of its rule is unchanged
- dcn-26090900000000022026-09-09 — A tool never creates directories in user-owned project space, and agent and session scratch is machine-scoped (maintainer, on finding twenty-two agent worktrees created as siblings of the checkout beside their other projects: "I don't want a user to be surprised that a folder is all of a sudden full of stuff"). The isolation stands — the checkout is the unit of isolation — and the location moves: a session's worktree goes under `~/.abcd/worktrees/<root-sha>/<name>/`, keyed the way the history, transcript and voyage stores are keyed, and a store nobody can list is refused as the same pile somewhere less visible, so listing and reclaiming are part of the capability. Decomposed on the maintainer's ruling into three records: adr-2609091014087993 (the trust rule, accepted), itd-2609091014076309 (the store, its list and prune verbs and the status-board line, in drafts pending adoption) and the principle `the-users-directory-is-theirs` (the stance, with its write-side enforcement stated as the intent's future work). Rejected: the in-tree `.abcd/.work.local/worktrees/` placement, because every tree scan walks it; a per-user configured location, because its default would still have to be something. The existing sibling worktrees are a hand cleanup, never a migration: the store moves nothing it did not create.
- dcn-26090900000000032026-09-09 — adr-2609091014087993 is superseded by adr-2609091248200336, which states the split its Consequences left unstated (maintainer, ruling option (b) on iss-2609091129426411 against accepting the bullet as a prediction fulfilled in two steps or reverting the `AGENTS.md` edit): the location half of the worktree rule binds now — § Concurrent sessions names `~/.abcd/worktrees/<root-sha>/<name>/` and refuses the sibling and in-checkout forms, as of `805bb023` — and the verb half binds when the store ships, because the original's deferral was about naming a VERB that does not exist, never a location plain git already reaches. The successor carries the trust rule, its five declarations and its rejected alternatives whole, and folds in the helper correction iss-2609091155525689 owed the same original, since an original's `superseded_by` names one record. The original's decision text is untouched; only its status and its forward link change.
- iss-2608261331317889TestPayloadTreeImplementationsResolveIdentically failed transiently during a pre-push preflight with 'the payload carries 109 rejected file(s)', then passed in isolation minutes later with an identical two-markdown-file diff and a clean tree. Between the two runs a peer session created .claude/worktrees/pr513-conflicts in the shared checkout, so the plausible mechanism is the payload walk sweeping a sibling worktree mid-population — the iss-213 class (a peer's activity silently invalidating a verification result), now reaching the payload scan through the preflight gate. Not reproduced deterministically; captured as a sighting so the flake has a marker if it recurs.
- iss-2609020721142452Worktrees for parallel lanes are created one directory above the repository (../abcd-wt-<lane>, ../abcd-f185, ../abcd-integration), which litters the parent directory with a dozen sibling checkouts named by convention only, leaves them behind when a session dies (21 spent worktrees, 1.4G, were removed by hand on 2026-09-01), and gives nothing a stable place to look for them. Assessment of where they should go: (1) inside the repository under .abcd/.work.local/worktrees/ keeps them with their owner and gitignored, but a worktree inside the main working tree is walked by every tree scan (the payload gate, name-guard, docs-lint), which already flakes during worktree creation (iss-2608261331317889) and would slow every gate; (2) under the user-level home, ~/.abcd/worktrees/<root-sha>/<branch-slug>/, keyed the way the history store already keys a repository by its root commit, keeps them out of every scan, survives a session, and gives an abcd verb a place to list, prune and reattach them; (3) the git default (anywhere) is the status quo. Option 2 fits the existing home layout and the one-canonical-primitive rule (the root-sha keying exists), and it makes the concurrent-sessions convention in AGENTS.md checkable: a session lists its peers' worktrees by reading one directory. It needs: a verb or documented one-liner (abcd worktree add <lane> / list / prune), the AGENTS.md convention updated, the lane brief updated, and a prune rule for worktrees whose branch is merged.
- iss-2609091128479544internal/fsutil is the declared home for directory validation, and .abcd/development/principles/one-canonical-primitive.md names that primitive in its rule list: a second copy is a flagged consolidation target, a third is forbidden. There are three ensureRealDir implementations at this tip. internal/core/lifeboat/voyage.go:115 and internal/core/history/location.go:175 are near-identical (os.Mkdir at 0o700 followed by fsutil.IsRealDir, differing only in the error each returns), and the history one was added this cycle, so it is the copy that crossed the line the principle draws. internal/core/intent/lifecycle.go:824 is the divergent variant: an Lstat symlink check followed by os.MkdirAll at 0o755, whose own doc comment records that a symlinked ANCESTOR is not caught. The third copy is therefore not mere duplication but the weakening the principle predicts: the intent variant's guarantee is strictly weaker than the other two, the difference is invisible at its five call sites, and a hardening landed in either of the other homes reaches none of them. The principle's Promotion clause asks for a check over private redefinitions of the canonical names, and internal/fsutil/canonical_test.go's TestNoNonCanonicalAtomicWritePrimitives is exactly that check for writeFileAtomic, durableWrite, isRealDir and createExclusiveIn; ensureRealDir is simply absent from its name list, which is how three copies accumulated under an armed detector. Two accepted decision records now bless the divergence rather than flag it: adr-2609090717039680:56-60 presents the local helper as a virtue and adr-2609091014087993:71-73 commits a future worktree store to a fourth copy. Fix direction: one primitive in internal/fsutil that takes the create mode as a parameter, because 0o700 for a private store under the caller's home and 0o755 for a record directory in a shared worktree are both legitimate and must not be silently unified, plus a chain form that verifies every level it creates so the intent variant's documented ancestor gap closes rather than being carried forward; and ensureRealDir joins the canonical-primitive detector's name list so a fourth copy cannot land quietly.
- iss-2609091129426411adr-2609091014087993 says two things about `AGENTS.md` that cannot both be read at face value. Among its normative clauses, in the present indicative: "`AGENTS.md`'s concurrent-sessions convention names the store as the place a session's worktree goes, and names no sibling-directory form." Among its Consequences: "The concurrent-sessions convention gains the store as the place a worktree goes, and loses any wording that reads as a sibling directory. That edit lands with the intent, because a convention naming a verb that does not exist is a phantom gate." The first states as fact what the second schedules, and when the ADR was accepted neither was true of the file: § Concurrent sessions was byte-unchanged, named no store, and treated creating a worktree inside the checkout as ordinary — the shape the ADR rejects as its alternative 2.
- iss-2609091155525689Two accepted decision records describe the directory primitive as it stood before the consolidation, and this repository supersedes rather than amends, so each owes a correction rather than an edit. adr-2609090717039680 (the transcript corpus is a sibling store that creates itself) names ensureRealDir as a helper of internal/core/history and presents its behaviour — walks the chain top-down, os.Mkdir per level rather than MkdirAll, re-verifies every level on every resolve — as a property of that package. Every one of those behaviours still holds, and the symbol does not: it is fsutil.EnsureRealDir, called in a loop, and the package that used to own the sequence now owns only the layout. A reader following the ADR to the code finds nothing at the name it gives. adr-2609091014087993 (a tool never creates directories in user-owned project space) commits a future machine-scoped worktree store to creating itself one level at a time, never through a symlink, exactly as the transcript store does. That sentence is now satisfied by calling the canonical primitive, and it was the reading of it that would have produced a fourth copy: it describes a mechanism where it could name the thing that implements it. What is owed is one correction record covering both — the mechanism is unchanged, the home moved, and a store that wants this behaviour calls internal/fsutil rather than reproducing it. Detector: an ADR that names a package-local symbol should not survive that symbol's removal, which no gate currently checks; the record-lint rule family has no cross-reference from decision prose to Go identifiers.
- iss-2610102021042657Once the drain lane for iss-2610090642392144 lands, this repository's AGENTS.md and .abcd/rules.json say a verifier's copy goes outside every working tree, never under .abcd/.work.local/scratch/. The bundled default CONCURRENCY rule every managed repository receives and the principle the-users-directory-is-theirs.md still allow it in the checkout's .abcd/.work.local/ tier, and itd-193 and adr-2609091014087993 quote the archive command without saying where the copy goes. Changing the bundled rule changes what every managed repository is taught, so that lane left it alone.