diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 2f943d8bd..3681dd0ea 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -37,7 +37,7 @@ are wiring rather than user-facing surface are listed separately under | 22 | `/abcd:site` | shipped | Render the project website from the repository's own text, and gate what it publishes | [`22-site.md`](22-site.md) | | 23 | `/abcd:reading` | shipped | Assemble what a cold reading may see, prove it, and validate what comes back | [`23-reading.md`](23-reading.md) | | 24 | `/abcd:decide` | shipped | Mint a decision record with its id, date and skeleton, ready to write the decision into | [`24-decide.md`](24-decide.md) | -| 25 | `/abcd:worktree` | staged | Keep session and agent worktrees in a machine-scoped store rather than beside the checkout (design target — [itd-2609091014076309](../../intents/drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md)) | [`../05-internals/03-configuration.md` § The worktree store](../05-internals/03-configuration.md#the-worktree-store) | +| 25 | `/abcd:worktree` | staged | Keep session and agent worktrees in a machine-scoped store rather than beside the checkout (design target — [itd-2609091014076309](../../intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md)) | [`../05-internals/03-configuration.md` § The worktree store](../05-internals/03-configuration.md#the-worktree-store) | | 26 | `/abcd:mode` | shipped | Say whose answer the agent loop is waiting on, so the status line and the board show it | [`08-abcd.md`](08-abcd.md) | | 27 | `/abcd:implement` | shipped | Share one autonomous run between two sessions: claim a record before its lane, keep the second session inside its bounds, and compare the ways of dividing the work from the run log | [`27-implement.md`](27-implement.md) | | 28 | `/abcd:peers` | shipped | See what the sibling worktrees and local branches hold before capturing, fixing or filing anything | [`08-abcd.md`](08-abcd.md) | diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index de42b6f4c..dac40aca5 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -365,7 +365,7 @@ apart. Three properties are load-bearing: ## The worktree store -**Design target (itd-2609091014076309, `intents/drafts/`; unbuilt).** No +**Design target (itd-2609091014076309, `intents/planned/`; unbuilt).** No `worktree` verb exists in the shipped binary, and nothing in it creates or reads this store. What follows is the layout the intent commits to, on the rule [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) @@ -398,6 +398,25 @@ Three properties are load-bearing, and each is the intent's to deliver: Worktrees already sitting beside a checkout are outside the store by definition: listed as such, never moved, and retired by the user's own `git worktree remove`. +**One primitive owns the store and its notes archive.** One package under `ahoy` +derives the lane from the root commit, makes every level of +`~/.abcd/worktrees//` and of the notes archive +`~/.abcd/notes//` one at a time as a real directory that is the +caller's alone, and adds, lists and removes worktrees through git. The build +loop's lanes are made through it, so a worktree enters the store one way. Before +`prune` removes a worktree it moves the worktree's git-ignored +`.abcd/.work.local/` into `~/.abcd/notes//-/` +beside a manifest, redacted on write by the transcript store's pass; nothing +reclaims the archive. + +**Invariant: the store deletes only what passes its proof of belonging.** A +directory leaves the lane only when git lists it as a worktree of this +repository, its real path lies inside this repository's lane, and its own common +directory is this checkout's; anything else is reported and left in place, and no +removal is forced. It is the reclaim half of +[adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md): +a tool that must not create in the user's space must not delete there either. + ## The two `.abcd/` scopes `.abcd/` is **one namespace pattern instantiated at two scopes**. abcd lives in diff --git a/.abcd/development/brief/06-delivery/03-out-of-scope.md b/.abcd/development/brief/06-delivery/03-out-of-scope.md index 96f39c725..58e5c0544 100644 --- a/.abcd/development/brief/06-delivery/03-out-of-scope.md +++ b/.abcd/development/brief/06-delivery/03-out-of-scope.md @@ -109,7 +109,6 @@ gate. That is what keeps "not hand-counted" true after the day it was written. - `itd-2609151838327688` — an opt-in adapter to a local message broker brings push delivery and cross-machine reach to the session mailbox (sequenced after the mailbox) - `itd-2609151541116052` — every question an interview puts to a human shows the thing being decided before it asks, at every step (refines itd-201) - `itd-2609151658486398` — a release cut publishes the security advisories its fixes close, and closes those resolved as won't-fix (the publication step the 2026-08-27 advisory-handling pilot named as its target) -- `itd-2609091014076309` — Session and agent worktrees live in a machine-scoped store (`~/.abcd/worktrees///`) that abcd lists and reclaims, never beside the user's own projects (the rule is adr-2609091248200336; `builds_on` itd-118, whose worktree clause it supplies the store and the reclaim verb for) - `itd-2609091416304128` — `capture resolve` and `capture wontfix` refuse a record already terminal at the local `origin/main` ref as last fetched, stating the ref's age and performing no fetch; the same judgement rendered read-only on `abcd ` (split from itd-2609091034175565 on the same ruling; the third clause of iss-2609020716570699's remedy, RS001's answer moved earlier) - `itd-2609091034175565` — A record says who is working on it before anyone else starts: the claim verb, the session lease and the write-verb refusals, with the `claimed_by` stamp bounded by a two-release migration (promoted from iss-2609020716570699; the read-only listing and the upstream refusal were split out on 2026-09-09; not ready — carries the refusal-surface, liveness and pushed-price questions as open questions) - `itd-2609150819439571` — errata as a fourth terminal disposition on a durable record, appended rather than edited, so a correction is distinguishable from the error it corrects (promoted from iss-2609100505146979) diff --git a/.abcd/development/decisions/adrs/2609091014087993-a-tool-never-creates-directories-in-user-owned-project-space.md b/.abcd/development/decisions/adrs/2609091014087993-a-tool-never-creates-directories-in-user-owned-project-space.md index 9b9975f1e..a9edc9bb4 100644 --- a/.abcd/development/decisions/adrs/2609091014087993-a-tool-never-creates-directories-in-user-owned-project-space.md +++ b/.abcd/development/decisions/adrs/2609091014087993-a-tool-never-creates-directories-in-user-owned-project-space.md @@ -77,7 +77,7 @@ 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](../../intents/drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) +visible. [itd-2609091014076309](../../intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) 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. diff --git a/.abcd/development/decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md b/.abcd/development/decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md index c9ddc0830..a854c0710 100644 --- a/.abcd/development/decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md +++ b/.abcd/development/decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md @@ -56,7 +56,7 @@ gives the original record and the principle as its grounds, refuses the sibling and in-checkout forms, and says in as many words that the store has no verbs: a plain `git worktree add` aimed at the path, with nothing to enumerate the lane or prune a spent worktree until -[itd-2609091014076309](../../intents/drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) +[itd-2609091014076309](../../intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) ships. That made the normative clause true. What it left is a Consequence bullet that reads as though the whole edit is still ahead, when its location half is behind and only its verb half remains, and nothing in the record says diff --git a/.abcd/development/intents/drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md b/.abcd/development/intents/drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md deleted file mode 100644 index 90b3d7419..000000000 --- a/.abcd/development/intents/drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -id: itd-2609091014076309 -slug: session-and-agent-worktrees-live-in-a-machine-scoped-store-t -spec_id: null -kind: null -suggested_kind: null -reclassification_history: [] -builds_on: [itd-118] -severity: major -impact: additive -origin: researcher-authored -production_mode: hand-written -related_adrs: [adr-2609091248200336, adr-2609091248201071] ---- - -# Session and agent worktrees live in a machine-scoped store that abcd lists and reclaims, never beside the user's own projects - -Typed links: `related_adrs` [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) (the rule this store enacts), [adr-2609091248201071](../../decisions/adrs/2609091248201071-the-transcript-corpus-is-a-sibling-store-that-creates-itself.md) (the root-SHA-keyed sibling store whose shape this copies); `builds_on` [itd-118](../drafts/itd-118-merged-work-leaves-no-residue-abcd-managed-repos-delete-a-pr.md) (its worktree clause: the worktree a merge leaves behind lives in the store and is reclaimed by the verb this intent adds, so the post-merge tidy calls that verb rather than growing a second reclaim). - -## Press Release - -> **abcd keeps every session and agent worktree in one place on your machine, and can tell you what is there.** `abcd worktree add ` creates a worktree at `~/.abcd/worktrees///`, keyed on the repository's root commit the way the history and transcript stores already are, and writes nothing beside your checkout. Bare `abcd worktree` lists what the store holds for this repository — name, branch, path, whether the tree is clean, whether its branch has merged — and names any worktree of the repository that sits outside the store; `abcd worktree list --all` walks every lane on the machine. `abcd worktree prune` reclaims a worktree whose branch has merged and whose tree is clean, reports each removal by name, and leaves everything else where it is, with the reason. The `/abcd` status board carries one line: how many worktrees the store holds for this repository and how many are reclaimable. Your project directory holds what you put there. -> -> "I came back from a run to find twenty-two new folders beside my projects, none of which I had made, and no way to tell which were still in use," said Maya, autonomous-development practitioner. "The isolation was right — parallel agents need separate checkouts. The location was not. Now the checkouts live where abcd keeps the rest of its machine state, one command lists them, one command clears the ones whose work has merged, and my folder is mine again." - -## Why This Matters - -Parallel agents need separate checkouts: `AGENTS.md`'s concurrent-sessions convention makes the checkout the unit of isolation, and the record's gates read the whole tree, so two sessions in one checkout fail each other's gates in both directions. Nothing says where a checkout goes, so each agent picks, and what an agent picks is git's default, a sibling directory. On 2026-09-01 twenty-one spent worktrees (1.4 GB) were removed by hand from the maintainer's project directory ([iss-2609020721142452](../../../work/issues/resolved/iss-2609020721142452-worktrees-for-parallel-lanes-are-created-one-directory-above.md)); on 2026-09-06 one session created twenty-two more, beside four unrelated projects; `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." - -That objection is the rule [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) records: a tool never creates directories in space that belongs to the user, and agent and session scratch is machine-scoped. This intent is the capability that makes the rule followable — an agent that has somewhere to put a worktree, and a user who can see and reclaim what was put there. - -The shape is not new. `~/.abcd/history//`, `~/.abcd/transcripts//` and `~/.abcd/voyage//` are each keyed on the repository's root-commit SHA, and `internal/core/history/location.go` states why: a checkout moves, is renamed, and is cloned twice on one machine, while its root commit changes under none of that. The key is proving itself on this machine as this is written — `~/.abcd/history/index.json` registers abcd under a checkout path that no longer exists, and every store keyed on the root commit is unaffected, because the path is a mutable label and the SHA is the key. `index.json` is the sole user-scope registry of every repository abcd knows on the machine, so a cross-repository listing is a walk that already exists. There is a de facto precedent on disk as well: `~/.abcd/worktrees/488a0aa9/phase-9` and `.../park-gates`, created by other tooling under abcd's own root commit. That is corroboration that the location is the natural one, not authority for the shape — the precedent abbreviates the key, and this store uses the full SHA the sibling stores use. - -Two things separate the store from the same pile somewhere less visible, and the intent owns both. - -**Discoverability.** A worktree under `~/.abcd/` is invisible to `ls` where the user works, which is the point, and so it has to be visible somewhere else. Git already knows every worktree of a checkout wherever it sits (`git worktree list --porcelain`), so the store adds no registry of its own. It adds a verb that reads git's answer and says which entries are in the store, which are outside it, and what state each is in; and a line on the `/abcd` status board, so a user who never types the verb still learns the count. - -**Lifecycle.** A worktree whose branch has merged is garbage, and nothing today notices. Reclaiming is `abcd worktree prune`: it removes a worktree only when its branch is merged into the default branch and its tree is clean, and it names what it declined and why. It runs when the user runs it; the status board's reclaimable count is what prompts them. Reclaiming automatically on merge is [itd-118](../drafts/itd-118-merged-work-leaves-no-residue-abcd-managed-repos-delete-a-pr.md)'s post-merge tidy; when that ships it calls this verb rather than inventing a second reclaim. - -## What's In Scope - -- **`abcd worktree add [--branch ]`** creates a worktree at `~/.abcd/worktrees///` for the repository the caller stands in, on a new branch named after the worktree unless `--branch` names an existing one, and prints the path. The store is created on first use the way the transcript store is: each level made individually and re-verified as a real directory, never through a symlink, never with a single recursive make. A name already in the lane refuses. Nothing is written beside the checkout. -- **Bare `abcd worktree` and `abcd worktree list`** render the lane for this repository: name, branch, path (home-redacted on a machine stream), clean or dirty, merged or unmerged. Every worktree git reports for the checkout is a row; one outside the store is marked as outside and never touched. `--all` walks every lane under `~/.abcd/worktrees/`, labelling each through `~/.abcd/history/index.json` where the root commit is registered and by SHA where it is not; the worktree's own `.git` file, not the registry's path label, says which checkout a lane belongs to. `--json` emits the same rows. -- **`abcd worktree prune [--dry-run]`** removes each worktree in the lane whose branch is merged into the repository's default branch and whose tree is clean, runs git's own metadata prune for it, and reports each removal by name. A dirty tree, an unmerged branch, a worktree outside the store, and a directory in the lane that git does not recognise as a worktree of this repository are each left in place and named with the reason. The branch is left standing. -- **One line on the `/abcd` status board**: the count of worktrees in the lane and the count reclaimable, absent when the lane is empty. -- **The concurrent-sessions convention in `AGENTS.md` points at the verb**, and the plugin page carries the surface, so an agent asked for isolation has a place to put it and no reason to pick one. - -## What's Out of Scope - -- **Deleting branches, tracking refs or remote branches.** That is the rest of itd-118's post-merge tidy; prune reclaims the directory and git's record of it, and nothing else. -- **Moving a worktree that already sits beside a checkout.** The store never moves what it did not create. A sibling worktree is listed as outside and left alone; retiring it is the user's own `git worktree remove`, and the twenty-seven on the maintainer's machine are a hand cleanup, not a migration. -- **Worktrees other tools create**, in the store or out of it. The precedent lanes stay as they are. -- **Any change to the isolation rule itself.** The checkout stays the unit of isolation; this intent changes where a checkout lands. - -## Mechanism - -We expect a machine-scoped, root-SHA-keyed store with a list verb and a reclaim verb to end the surprise because the surprise is location, not existence: the same isolation, in a directory abcd owns, found by asking abcd. It fails if agents keep creating worktrees by hand outside the store, and that failure is visible rather than silent — the list verb marks every such worktree as outside, and the status board counts them. - -## Scope Conditions - -- The verbs act on worktrees created through abcd or by an agent following its conventions. A worktree the user placed by hand is theirs: listed as outside, never moved, never reclaimed. -- The store is under the caller's own home and needs no authority the caller lacks. Where the home is unwritable, or a level of the chain is a symlink, `add` refuses and creates nothing. -- The lane is keyed on the root commit, so a re-founded repository gets a new lane, as the history store gives it a new entry. -- "Merged" is judged against the repository's default branch. The repository allows squash and rebase merges, which leave no ancestry, so the spec decides whether a patch-identical branch or a branch whose upstream is gone counts; until it does, only an ancestry merge reclaims, which errs toward leaving a worktree in place. -- One machine. The store is never synced, and its paths never enter a committed file (the privacy-hygiene rule already refuses `/Users//`). - -## Acceptance Criteria - -- **Given** a checkout of a repository with commits, **when** `abcd worktree add ` runs, **then** a worktree exists at `~/.abcd/worktrees///` on the named branch, its path is printed, and the checkout's parent directory holds nothing it did not hold before. -- **Given** the store does not yet exist, **when** `add` runs, **then** each level is created individually and verified as a real directory, and a symlink at any level refuses the whole creation with nothing written. -- **Given** a lane holding two worktrees and a third worktree of the same repository beside the checkout, **when** bare `abcd worktree` runs, **then** three rows render, the third marked as outside the store, each carrying its branch, clean-or-dirty state and merged-or-unmerged state. -- **Given** lanes for two registered repositories and one unregistered root commit, **when** `abcd worktree list --all` runs, **then** the registered lanes render under their `index.json` names and the unregistered one under its SHA, and a registry `path` label that no longer exists changes nothing in the rows. -- **Given** a worktree whose branch is merged into the default branch and whose tree is clean, **when** `prune` runs, **then** the directory is gone, git no longer lists it, the branch still exists, and the removal is reported by name. -- **Given** a worktree with uncommitted changes, one on an unmerged branch, and one outside the store, **when** `prune` runs, **then** all three remain, each named with its reason, and the exit code says nothing was reclaimed unless something was. -- **Given** a directory inside the lane that git does not recognise as a worktree of this repository, **when** `prune` runs, **then** it is left untouched and reported — the store never deletes what it cannot prove it created. -- **Given** a repository whose lane holds worktrees, **when** the `/abcd` board renders in that checkout, **then** one line names the count held and the count reclaimable; **given** an empty lane, the line is absent. -- **Given** the checkout has been moved to another path, **when** `abcd worktree` runs from the moved checkout, **then** the same lane renders, because the key is the root commit and not the path. -- **Given** `AGENTS.md`'s concurrent-sessions convention, **when** it is read, **then** it names the verb as the way a session gets its own checkout, and names no sibling-directory form. - -## Prior Art - -- [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) — the rule: a tool never creates directories in user-owned project space; agent and session scratch is machine-scoped. This intent is its enforcement on the write side. -- [adr-2609091248201071](../../decisions/adrs/2609091248201071-the-transcript-corpus-is-a-sibling-store-that-creates-itself.md) and `internal/core/history/location.go` — the sibling store this copies: user-level, root-SHA-keyed as a directory, self-creating through one seam, never through a symlink. The reasoning for the key is written there and is not repeated here. -- [adr-35](../../decisions/adrs/0035-lifeboat-as-coverage-experiment.md) — the voyage store moved to `~/.abcd/` on the same ground, and embark's destination gate "never overwrites a directory abcd did not produce": the same stance, at the destination, that prune takes in the lane. -- [iss-2609020721142452](../../../work/issues/resolved/iss-2609020721142452-worktrees-for-parallel-lanes-are-created-one-directory-above.md) — the captured defect and its three options; this intent is its option 2, with the list and prune verbs and the merged-branch rule the issue asked for. -- [itd-118](../drafts/itd-118-merged-work-leaves-no-residue-abcd-managed-repos-delete-a-pr.md) — the post-merge tidy, which names the worktree among the residue a merge leaves; this intent supplies the store it tidies and the verb it calls. -- `AGENTS.md` § Concurrent sessions — the isolation rule this intent gives a location to. -- `git worktree list --porcelain` and `git worktree prune` — the primitives; the verbs wrap them and add the store, the state columns and the refusal reasons, and no registry of their own. -- [`durable-state-lives-where-the-platform-says-it-survives`](../../principles/durable-state-lives-where-the-platform-says-it-survives.md) — adjacent: that rule chooses a home by what survives the platform's lifecycle; this store's home is chosen by whose space it is. The store satisfies both, and neither is the other. - -## Open Questions - -- Whether the verb is `abcd worktree` or a sub-verb of `ahoy`, which already owns the rest of the machine-scope layout. A top-level verb reads better at the prompt; a sub-verb keeps one owner for `~/.abcd/`. -- Whether the plugin surface should carry `add` as a host-run step, so a harness's own checkout-isolation feature lands in the store rather than wherever the harness defaults to. The prose stays host-agnostic either way. -- Whether the lane is `0o700` like the transcript store. A worktree holds the same bytes as the checkout, so the checkout's own mode is the nearer precedent. -- Whether prune treats a squash-merged branch as merged (see Scope Conditions); the conservative default is stated there and the spec may widen it. -- Whether the clean-up also surfaces an unmerged worktree that has gone quiet. itd-148 carried such a sweep until the product thinker's rulings of 2026-09-29 gave clean-up to this draft: an unmerged worktree with no open pull request and no activity for 14 days (repo-configurable) named as a candidate, with a dossier per candidate (dirty state first, then branch subjects, diff summary, referenced records, last activity) and a recommendation, removed only on per-item confirmation. This planning interview decides whether that joins prune, and whether prune's merge proof takes the forge's recorded pull-request state and patch-equivalence that itd-148's 2026-08-26 interview ruled for it (the squash question above). The product thinker's ruling Q4 plans this draft first: nothing from it, the merged-worktree clean-up included, is built before this interview. itd-148 is `blocked_by` this draft because it uses the store's verbs (ruling Q2). -- Whether the listing names a hand-laid lane keyed on an abbreviated root commit beside the full-SHA lane of the same repository. Lanes laid by hand before the verb exists sit under the eight-digit short form, and an autonomous run once split one store's log across the two spellings (iss-2609240646458365); a listing that reads only the full key leaves the short one unseen. - -## Audit Notes - -_Empty. Populated by intent-auditor when intent moves to shipped/._ diff --git a/.abcd/development/intents/drafts/itd-2609091034175565-nothing-tells-an-agent-that-a-record-it-is-about-to-fix-has.md b/.abcd/development/intents/drafts/itd-2609091034175565-nothing-tells-an-agent-that-a-record-it-is-about-to-fix-has.md index e010ffe16..a1b5e863e 100644 --- a/.abcd/development/intents/drafts/itd-2609091034175565-nothing-tells-an-agent-that-a-record-it-is-about-to-fix-has.md +++ b/.abcd/development/intents/drafts/itd-2609091034175565-nothing-tells-an-agent-that-a-record-it-is-about-to-fix-has.md @@ -16,7 +16,7 @@ related_adrs: [adr-2609091248200336] # A record says who is working on it before anyone else starts, on this machine and on the shared branch -Typed links: `related_issues` [iss-2609020716570699](../../../work/issues/open/iss-2609020716570699-nothing-tells-an-agent-that-a-record-it-is-about-to-fix-has.md) (the pushed-claim mechanism, whose remedy this record carries and whose scope the maintainer's ruling of 2026-09-09 first widened and then split); `related_intents` [itd-2609091416295622](../shipped/itd-2609091416295622-a-session-sees-the-records-its-sibling-worktrees-hold-before.md) (the read-only sibling-worktree listing, split out of this record), [itd-2609091416304128](itd-2609091416304128-capture-resolve-refuses-a-record-the-default-branch-has-alre.md) (the upstream-terminal refusal, split out of this record), [itd-2609091014076309](itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) (the machine-scoped worktree store this record's lane would sit beside and read); `related_adrs` [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) (agent and session scratch is machine-scoped — the rule the lane's home rests on). Prose cross-reference, not a typed link, because no schema field carries the relation ([iss-2609091256264547](../../../work/issues/open/iss-2609091256264547-three-of-the-four-mandated-typed-relations-cannot-be-written.md)): [iss-2608220750029993](../../../work/issues/open/iss-2608220750029993-session-presence-detection-for-shared-checkouts-each-live-se.md), the session-presence lease this record would give a home and a surface. +Typed links: `related_issues` [iss-2609020716570699](../../../work/issues/open/iss-2609020716570699-nothing-tells-an-agent-that-a-record-it-is-about-to-fix-has.md) (the pushed-claim mechanism, whose remedy this record carries and whose scope the maintainer's ruling of 2026-09-09 first widened and then split); `related_intents` [itd-2609091416295622](../shipped/itd-2609091416295622-a-session-sees-the-records-its-sibling-worktrees-hold-before.md) (the read-only sibling-worktree listing, split out of this record), [itd-2609091416304128](itd-2609091416304128-capture-resolve-refuses-a-record-the-default-branch-has-alre.md) (the upstream-terminal refusal, split out of this record), [itd-2609091014076309](../planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) (the machine-scoped worktree store this record's lane would sit beside and read); `related_adrs` [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) (agent and session scratch is machine-scoped — the rule the lane's home rests on). Prose cross-reference, not a typed link, because no schema field carries the relation ([iss-2609091256264547](../../../work/issues/open/iss-2609091256264547-three-of-the-four-mandated-typed-relations-cannot-be-written.md)): [iss-2608220750029993](../../../work/issues/open/iss-2608220750029993-session-presence-detection-for-shared-checkouts-each-live-se.md), the session-presence lease this record would give a home and a surface. **Status: not ready.** This draft carries three open questions that gate its scope, stated as questions under Open Questions and answered nowhere in this record. The press release below states the shape the draft proposes so a reader can see what is being asked; any of the three answers may change it. @@ -75,7 +75,7 @@ Whatever the interview decides about the pushed half, a `claimed_by` key on an i - **A lock.** Every refusal names a session and can be overridden by releasing the claim from either side; nothing here holds after a session has ended. - **Cross-machine presence without a push.** Two machines learn of each other's claims when a claim commit lands, and no sooner. - **Reading any host's session registry, transcript store or process table.** The host reaches abcd through the hooks it fires; what it does not say, abcd does not know. -- **Moving or reclaiming worktrees**, which [itd-2609091014076309](itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) carries. +- **Moving or reclaiming worktrees**, which [itd-2609091014076309](../planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) carries. ## Mechanism @@ -106,7 +106,7 @@ The criteria below hold whatever the three open questions decide; criteria that - [iss-2609020716570699](../../../work/issues/open/iss-2609020716570699-nothing-tells-an-agent-that-a-record-it-is-about-to-fix-has.md) — the source record: the three remote-mediated parts, of which the stamp and the duplicate guard stay here under the open questions, and the resolve-time refusal is now its own record. - [iss-2608220750029993](../../../work/issues/open/iss-2608220750029993-session-presence-detection-for-shared-checkouts-each-live-se.md) — the lease, and its open question of where it lives: the issue rules out the per-worktree local tier because the hazard is two sessions in one checkout, and this draft answers with the machine lane keyed on the root commit. -- [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) and [itd-2609091014076309](itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) — agent and session scratch is machine-scoped, keyed on the root commit, listable and reclaimable; the lane copies the shape. +- [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) and [itd-2609091014076309](../planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) — agent and session scratch is machine-scoped, keyed on the root commit, listable and reclaimable; the lane copies the shape. - [adr-2609091248201071](../../decisions/adrs/2609091248201071-the-transcript-corpus-is-a-sibling-store-that-creates-itself.md) and `internal/core/history/location.go` — the root-commit key and the self-creating store; the session-start hook already resolves it, which is the seam the lease rides. - `internal/core/issueschema/issueschema.go` and `internal/core/lint/schema.go` — the closed issue schema and its lint mirror; the reason a stamp is a two-release migration. - `internal/core/banlist/worktree.go` — abcd already reads `git worktree list --porcelain` and confirms a worktree from its own `--git-common-dir`. diff --git a/.abcd/development/intents/planned/itd-148-every-change-starts-in-its-own-worktree-the-primary-checkout.md b/.abcd/development/intents/planned/itd-148-every-change-starts-in-its-own-worktree-the-primary-checkout.md index 6b4bd24a2..bfe3235e8 100644 --- a/.abcd/development/intents/planned/itd-148-every-change-starts-in-its-own-worktree-the-primary-checkout.md +++ b/.abcd/development/intents/planned/itd-148-every-change-starts-in-its-own-worktree-the-primary-checkout.md @@ -14,7 +14,7 @@ impact: additive # Every change starts in its own worktree in abcd's store: the primary checkout is a read-only surface, and abcd blocks mutations there for every session but a declared coordinator -Typed links: `blocked_by` [itd-2609091014076309](../drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) (the store under the home folder, which owns adding, listing and clearing away worktrees; this intent uses those verbs, so it waits on that draft); `related_adrs` [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) (the rule the store enacts). This record was rewritten on 2026-09-29 to the product thinker's rulings (see `## Decisions`); the rewrite **reverses** the scope its 2026-08-26 planning interview gave it, a worktree verb family and a sweep of its own, with worktrees placed inside the primary checkout. +Typed links: `blocked_by` [itd-2609091014076309](itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md) (the store under the home folder, which owns adding, listing and clearing away worktrees; this intent uses those verbs, so it waits on that draft); `related_adrs` [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) (the rule the store enacts). This record was rewritten on 2026-09-29 to the product thinker's rulings (see `## Decisions`); the rewrite **reverses** the scope its 2026-08-26 planning interview gave it, a worktree verb family and a sweep of its own, with worktrees placed inside the primary checkout. ## Press Release @@ -194,6 +194,7 @@ of autonomous run A; recorded in `.abcd/work/DECISIONS.md` under that date): interview. This intent's own wait is not part of the ruling: it follows from decision 2, because this intent uses the store's verbs, and is carried by its `blocked_by` on the store draft. +5. **The mint-visibility criterion stays, as peer awareness** (ruled 2026-09-30 at the store draft's planning interview): timestamp ids make a clash impossible (adr-45), and the criterion is kept for what a peer learns, not for collision safety. ## Open Questions @@ -201,11 +202,6 @@ of autonomous run A; recorded in `.abcd/work/DECISIONS.md` under that date): second one?** Decision 3 rules that the exemption exists; its mechanism (a flag, a mode, a local-tier file) and the refusal of a second declaration are the spec's, to be settled after the store draft's planning interview. -- **Does the mint-visibility criterion still earn its place?** It was the - bridge until every record family minted timestamp ids, and that migration - has since landed (adr-45): AGENTS.md now says record ids need no - coordination between checkouts. Keep it as peer awareness, or drop it at - the next walk of the criteria. ## Audit Notes diff --git a/.abcd/development/intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md b/.abcd/development/intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md new file mode 100644 index 000000000..7673779f6 --- /dev/null +++ b/.abcd/development/intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md @@ -0,0 +1,131 @@ +--- +id: itd-2609091014076309 +slug: session-and-agent-worktrees-live-in-a-machine-scoped-store-t +spec_id: spc-2609301811532881 +kind: standalone +suggested_kind: null +reclassification_history: [] +refines: [itd-2609201916151817] +related_intents: [itd-118, itd-148] +severity: major +impact: additive +origin: researcher-authored +production_mode: hand-written +related_adrs: [adr-2609091248200336, adr-2609091248201071] +--- + +# Session and agent worktrees live in a machine-scoped store that abcd lists and reclaims, never beside the user's own projects + +Typed links: `related_adrs` [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) (the rule this store enacts), [adr-2609091248201071](../../decisions/adrs/2609091248201071-the-transcript-corpus-is-a-sibling-store-that-creates-itself.md) (the root-SHA-keyed sibling store whose shape this copies); `refines` [itd-2609201916151817](itd-2609201916151817-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md) (the build loop, whose lane primitive becomes this store's one primitive: its spec's piece 6 takes the store's verb once this ships); `related_intents` [itd-118](../drafts/itd-118-merged-work-leaves-no-residue-abcd-managed-repos-delete-a-pr.md) (the post-merge tidy that will call `prune`) and [itd-148](itd-148-every-change-starts-in-its-own-worktree-the-primary-checkout.md) (every change in its own worktree, which uses this store's verbs and is `blocked_by` this draft). + +## Press Release + +> **abcd keeps every session and agent worktree in one place on your machine, and can tell you what is there.** `abcd ahoy worktree add ` creates a worktree at `~/.abcd/worktrees///`, keyed on the repository's root commit the way the history and transcript stores already are, and writes nothing beside your checkout. Bare `abcd ahoy worktree` lists what the store holds for this repository — name, branch, path, whether the tree is clean, whether its branch has merged — and names any worktree of the repository that sits outside the store; `abcd ahoy worktree list --all` walks every lane on the machine. `abcd ahoy worktree prune` reclaims a worktree whose branch has merged and whose tree is clean, moves the worktree's private notes into a dated archive rather than deleting them, reports each removal by name, and leaves everything else where it is, with the reason; a worktree that has gone quiet without merging is pointed out with a summary and goes only when you name it. The `/abcd` status board carries one line: how many worktrees the store holds for this repository and how many are reclaimable. Your project directory holds what you put there. +> +> "I came back from a run to find twenty-two new folders beside my projects, none of which I had made, and no way to tell which were still in use," said Maya, autonomous-development practitioner. "The isolation was right — parallel agents need separate checkouts. The location was not. Now the checkouts live where abcd keeps the rest of its machine state, one command lists them, one command clears the ones whose work has merged, and my folder is mine again." + +## Why This Matters + +Parallel agents need separate checkouts: `AGENTS.md`'s concurrent-sessions convention makes the checkout the unit of isolation, and the record's gates read the whole tree, so two sessions in one checkout fail each other's gates in both directions. Nothing says where a checkout goes, so each agent picks, and what an agent picks is git's default, a sibling directory. On 2026-09-01 twenty-one spent worktrees (1.4 GB) were removed by hand from the product thinker's project directory ([iss-2609020721142452](../../../work/issues/resolved/iss-2609020721142452-worktrees-for-parallel-lanes-are-created-one-directory-above.md)); on 2026-09-06 one session created twenty-two more, beside four unrelated projects, and `git worktree list` on that checkout named twenty-seven. The product thinker's objection, verbatim: "I don't want a user to be surprised that a folder is all of a sudden full of stuff." + +That objection is the rule [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) records: a tool never creates directories in space that belongs to the user, and agent and session scratch is machine-scoped. This intent is the capability that makes the rule followable — an agent that has somewhere to put a worktree, and a user who can see and reclaim what was put there. + +The shape is not new. `~/.abcd/history//`, `~/.abcd/transcripts//` and `~/.abcd/voyage//` are each keyed on the repository's root-commit SHA, and `internal/core/history/location.go` states why: a checkout moves, is renamed, and is cloned twice on one machine, while its root commit changes under none of that. `index.json` is the sole user-scope registry of every repository abcd knows on the machine, so a cross-repository listing is a walk that already exists. There is a de facto precedent on disk as well: `~/.abcd/worktrees/488a0aa9/phase-9` and `.../park-gates`, laid by hand by an autonomous run under abcd's own root commit before the verb exists (DECISIONS.md, 2026-09-26). That is corroboration that the location is the natural one, not authority for the shape — the precedent abbreviates the key, and this store uses the full SHA the sibling stores use. + +Two things separate the store from the same pile somewhere less visible, and the intent owns both. + +**Discoverability.** A worktree under `~/.abcd/` is invisible to `ls` where the user works, which is the point, and so it has to be visible somewhere else. Git already knows every worktree of a checkout wherever it sits (`git worktree list --porcelain`), so the store adds no registry of its own. What stands in for one is a proof git can give: a worktree belongs to the store when git lists it as a worktree of this repository, its real path lies inside this repository's lane, and its own common directory is this checkout's. It adds a verb that reads git's answer and says which entries are in the store, which are outside it, and what state each is in; and a line on the `/abcd` status board, so a user who never types the verb still learns the count. + +**Lifecycle.** A worktree whose branch has merged is garbage, and nothing today notices. Reclaiming is `abcd ahoy worktree prune`: it removes a worktree only when its branch is merged into the default branch and its tree is clean, moves the worktree's local tier into the notes archive first, and names what it declined and why. It runs when the user runs it; the status board's reclaimable count is what prompts them. Reclaiming automatically on merge is [itd-118](../drafts/itd-118-merged-work-leaves-no-residue-abcd-managed-repos-delete-a-pr.md)'s post-merge tidy; when that ships it calls this verb rather than inventing a second reclaim. + +## What's In Scope + +- **`abcd ahoy worktree add [--branch ]`** creates a worktree at `~/.abcd/worktrees///` for the repository the caller stands in, on a new branch named after the worktree unless `--branch` names an existing one, and prints the path. The store is created on first use the way the transcript store is: each level made individually and re-verified as a real directory, never through a symlink, never with a single recursive make, and each level owned by the caller with no group or other write bit. A name that is not one plain path segment (`../x`, `-x`, an empty or dotted name) refuses, a name already in the lane refuses, and a repository with no root commit refuses. The build loop's lane code (`laneWorktree`, `ensureStore`, `adoptWorktree` in `internal/core/implement/loop/lane.go`) already does this for its own lanes; it moves into the store's package and the loop calls it, so there is one primitive and not two. Nothing is written beside the checkout. +- **Bare `abcd ahoy worktree` and `abcd ahoy worktree list`** render the lane for this repository: name, branch, path, clean or dirty, merged or unmerged. On a machine stream a store row's path is home-redacted and an outside row's path uses the display form that stays recognisable wherever it lives. Every worktree git reports for the checkout is a row; one outside the store is marked as outside and never touched. `--all` walks every lane under `~/.abcd/worktrees/`, labelling each through `~/.abcd/history/index.json` (read through the loader that already scrubs it) where the root commit is registered and by SHA where it is not; the worktree's own `.git` file, not the registry's path label, says which checkout a lane belongs to. A lane whose checkout has moved, so that its `.git` pointer is dead, renders as "checkout not found; run `git worktree repair` from the checkout" under its SHA, with no clean or merged column. A directory under `~/.abcd/worktrees/` whose name is not a full root commit (the hand-laid eight-digit form) renders as a "not a lane" row rather than being skipped, naming this repository's full-key lane where the abbreviation matches it and saying its worktrees are retired by hand. `--json` emits the same rows. +- **`abcd ahoy worktree prune [--dry-run]`** removes each worktree that belongs to the store (by the proof above) whose branch is merged into the repository's default branch and whose tree is clean, moves its local tier (below) into the notes archive, runs git's own metadata prune for it, and reports each removal by name. A dirty tree, an unmerged branch, a worktree outside the store, a directory in the lane that fails the proof, a worktree git holds locked (with git's lock reason), the worktree prune is run from, and a lane the build loop's state file still holds are each left in place and named with the reason. Prune never forces a removal, so git's own re-check at the moment of removal closes the gap between judging a worktree clean and removing it. `--dry-run` removes nothing and reports the same rows; `--json` emits them. The branch is left standing. "Merged" means the forge records the branch's pull request as merged, or every commit of the branch is already on the default branch by content (patch-equivalence); with no forge reachable, patch-equivalence alone decides. Exit codes: 0 when at least one worktree was reclaimed, 1 when the run completed and reclaimed nothing (an empty lane included), 2 on a refusal or fault. +- **The notes archive.** A worktree's `.abcd/.work.local/` is ignored by git, so a tree git calls clean can still hold a handover note, a per-machine banlist, logs and scratch. Prune never deletes it: before removing the worktree it moves the tier into a sibling store, `~/.abcd/notes//-/`, created through the same canonical directory primitive as the transcript store (each level `0o700`, never through a symlink), redacted on write the way transcripts are, and accompanied by a manifest recording the worktree name, branch, source path (home-redacted), time and file list. The report names where the notes went. If the move fails, the worktree is kept and named with the reason. +- **Quiet worktrees.** An unmerged worktree in the lane with no activity for 14 days (repo-configurable) and no open pull request (where a forge is reachable; without one, the 14 days alone) is listed by `prune` as a candidate with a dossier: dirty state first, then branch subjects, diff summary, referenced records and last activity, and a recommendation. It is removed only when named on the command line (`prune --yes `, repeatable); the core never prompts. A named removal takes the same notes-archive step and the same refusals as a merged one, a dirty tree included: prune never forces, so uncommitted work is committed or discarded by the person first, and the dossier shows it so they know. +- **One line on the `/abcd` status board**: the count of worktrees in the lane and the count reclaimable, absent when the lane is empty. Both counts come from the worktree and merged-branch scan the bare board already runs for `peers`, never from a status read per lane, and the line is the CLI board's alone: it is not part of the status block the site renders. +- **The concurrent-sessions convention in `AGENTS.md` points at the verb, and the plugin page carries it as a host-run step**: an agent that needs an isolated checkout (a lane, a subagent, a peer session) runs `abcd ahoy worktree add ` and works in the printed path, never a host's default worktree location. The prose stays host-agnostic. +- **The worktree directory is `0o700`**, like the store levels above it: `add` sets the mode after git creates the directory. + +## What's Out of Scope + +- **Deleting branches, tracking refs or remote branches.** That is the rest of itd-118's post-merge tidy; prune reclaims the directory and git's record of it, and nothing else. +- **Moving a worktree that already sits beside a checkout.** The store never moves what it did not create. A sibling worktree is listed as outside and left alone; retiring it is the user's own `git worktree remove`, and the sibling worktrees already on the product thinker's machine are a hand cleanup, not a migration. +- **Worktrees other tools create**, in the store or out of it. The precedent lanes stay as they are. +- **Retention of the notes archive.** Nothing reclaims archived notes; a later tidy of `~/.abcd/notes/` is its own decision. +- **Any change to the isolation rule itself.** The checkout stays the unit of isolation; this intent changes where a checkout lands. + +## Mechanism + +We expect a machine-scoped, root-SHA-keyed store with a list verb and a reclaim verb to end the surprise because the surprise is location, not existence: the same isolation, in a directory abcd owns, found by asking abcd. It fails if agents keep creating worktrees by hand outside the store, and that failure is visible rather than silent — the list verb marks every such worktree as outside, and the status board counts them. + +## Scope Conditions + +- The verbs act on worktrees created through abcd or by an agent following its conventions. A worktree the user placed by hand is theirs: listed as outside, never moved, never reclaimed. +- The store is under the caller's own home and needs no authority the caller lacks. Where the home is unwritable, or a level of the chain is a symlink, `add` refuses and creates nothing. +- The lane is keyed on the root commit, so a re-founded repository gets a new lane, as the history store gives it a new entry. +- "Merged" is judged against the repository's default branch, by the forge's recorded pull-request state or by patch-equivalence, so a squash or rebase merge counts; with no forge reachable, patch-equivalence alone decides, and a branch rewritten after its merge so that its content no longer matches stays unmerged. +- One machine. The store is never synced, and its paths never enter a committed file (the privacy-hygiene rule already refuses `/Users//`). + +## Acceptance Criteria + +- **Given** a checkout of a repository with commits, **when** `abcd ahoy worktree add ` runs, **then** a worktree exists at `~/.abcd/worktrees///` on the named branch, its path is printed, and the checkout's parent directory holds nothing it did not hold before. +- **Given** the store does not yet exist, **when** `add` runs, **then** each level is created individually and verified as a real directory, and a symlink at any level refuses the whole creation with nothing written. +- **Given** a store level owned by another account or writable by group or others, **when** `add` runs, **then** it refuses and writes nothing. +- **Given** a name `../x`, `-x`, or a name already in the lane, or a repository with no commits, **when** `add` runs, **then** it refuses naming the reason and writes nothing; **given** `--branch` naming an existing branch, the worktree is on that branch and no new branch is made. +- **Given** the build loop creates a lane, **when** it does, **then** it goes through the same store primitive `add` uses. +- **Given** a lane holding two worktrees and a third worktree of the same repository beside the checkout, **when** bare `abcd ahoy worktree` runs, **then** three rows render, the third marked as outside the store, each carrying its branch, clean-or-dirty state and merged-or-unmerged state. +- **Given** lanes for two registered repositories and one unregistered root commit, **when** `abcd ahoy worktree list --all` runs, **then** the registered lanes render under their `index.json` names and the unregistered one under its SHA, and a registry `path` label that no longer exists changes nothing in the rows; a lane whose checkout has moved renders as "checkout not found" with the repair hint, and a directory named by an abbreviated root commit renders as "not a lane", naming this repository's full-key lane when the abbreviation matches it and saying its worktrees are retired by hand. +- **Given** a worktree whose branch is merged into the default branch and whose tree is clean, **when** `prune` runs, **then** the directory is gone, git no longer lists it, the branch still exists, and the removal is reported by name. +- **Given** a clean worktree whose branch was squash-merged (no ancestry, every change on the default branch by content), **when** `prune` runs with no forge reachable, **then** it is reclaimed. +- **Given** a reclaimable worktree whose `.abcd/.work.local/` holds a handover note and a log, **when** `prune` runs, **then** both files exist, redacted, under `~/.abcd/notes//-/` beside a manifest naming the worktree, branch, time and files, the report names that path, and nothing from the tier is deleted; **given** the archive cannot be written, the worktree is kept and named with the reason. +- **Given** an unmerged worktree with no activity for 15 days and no open pull request, **when** `prune` runs, **then** it is listed as a quiet candidate with its dossier (dirty state first) and left in place; **when** `prune --yes ` runs, **then** its notes are archived and it is removed, and no other quiet candidate is touched; **given** the named candidate holds uncommitted changes, it is kept and named with that reason, because prune never forces. +- **Given** a store refusal (a symlinked level, another account's directory), **when** `prune` runs, **then** it exits 2 and removes nothing. +- **Given** a worktree with uncommitted changes, one on an unmerged branch, and one outside the store, **when** `prune` runs, **then** all three remain, each named with its reason, and the exit code is 1; **given** the same lane plus one merged, clean worktree, the exit code is 0. +- **Given** a directory inside the lane that git does not list as a worktree of this repository, or whose common directory is another checkout's, **when** `prune` runs, **then** it is left untouched and reported — the store never deletes what fails its proof of belonging. +- **Given** a merged, clean worktree that git holds locked, one that is prune's own working directory, and one the build loop's state file still holds, **when** `prune` runs, **then** all three remain, each named with its reason, and no removal is forced. +- **Given** a lane holding a reclaimable worktree, **when** `prune --dry-run` runs, **then** nothing is removed and the rows are the ones a real run would report; `--json` emits the same rows. +- **Given** a repository whose lane holds worktrees, **when** the `/abcd` board renders in that checkout, **then** one line names the count held and the count reclaimable; **given** an empty lane, the line is absent; the line never appears in the site's rendered status block, and rendering it starts no per-lane status read. +- **Given** the checkout has been moved to another path, **when** `abcd ahoy worktree` runs from the moved checkout, **then** the same lane renders, because the key is the root commit and not the path. +- **Given** `abcd ahoy worktree add ` has run, **when** the new worktree's directory is inspected, **then** its mode is `0o700`. +- **Given** `AGENTS.md`'s concurrent-sessions convention, **when** it is read, **then** it names the verb as the way a session gets its own checkout, and names no sibling-directory form. +- **Given** the plugin page for `ahoy`, **when** it is read, **then** it carries the host-run step: an agent needing an isolated checkout runs `abcd ahoy worktree add ` and works in the printed path, never a host default location. + +## Prior Art + +- [adr-2609091248200336](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) — the rule: a tool never creates directories in user-owned project space; agent and session scratch is machine-scoped. This intent is its enforcement on the write side. +- [adr-2609091248201071](../../decisions/adrs/2609091248201071-the-transcript-corpus-is-a-sibling-store-that-creates-itself.md) and `internal/core/history/location.go` — the sibling store this copies: user-level, root-SHA-keyed as a directory, self-creating through one seam, never through a symlink. The reasoning for the key is written there and is not repeated here. +- [adr-35](../../decisions/adrs/0035-lifeboat-as-coverage-experiment.md) — the voyage store moved to `~/.abcd/` on the same ground, and embark's destination gate "never overwrites a directory abcd did not produce": the same stance, at the destination, that prune takes in the lane. +- [iss-2609020721142452](../../../work/issues/resolved/iss-2609020721142452-worktrees-for-parallel-lanes-are-created-one-directory-above.md) — the captured defect and its three options; this intent is its option 2, with the list and prune verbs and the merged-branch rule the issue asked for. +- [itd-118](../drafts/itd-118-merged-work-leaves-no-residue-abcd-managed-repos-delete-a-pr.md) — the post-merge tidy, which names the worktree among the residue a merge leaves; this intent supplies the store it tidies and the verb it calls. +- `AGENTS.md` § Concurrent sessions — the isolation rule this intent gives a location to. +- `git worktree list --porcelain` and `git worktree prune` — the primitives; the verbs wrap them and add the store, the state columns and the refusal reasons, and no registry of their own. +- [`durable-state-lives-where-the-platform-says-it-survives`](../../principles/durable-state-lives-where-the-platform-says-it-survives.md) — adjacent: that rule chooses a home by what survives the platform's lifecycle; this store's home is chosen by whose space it is. The store satisfies both, and neither is the other. + +## Decisions + +Ruled at this draft's planning interview, 2026-09-30 (the product thinker and technical facilitator, one question at a time): + +1. **Decomposition: file as is.** One intent; the store primitive and an invariant citing adr-2609091248200336 go to the brief's internals; no new ADR or principle. +2. **Merged means** the forge's recorded pull-request state or patch-equivalence, working without a forge (the ruling itd-148's 2026-08-26 interview made, carried here). +3. **Quiet worktrees** (14 days, repo-configurable, no open pull request) are surfaced with a dossier and removed only when named (itd-148's 2026-08-26 ruling, carried here). +4. **The local tier is moved, never deleted**, into a notes archive that is a sibling of the transcript store, recorded and timestamped. +5. **The verb is a sub-verb of `ahoy`**, which owns both stores. +6. **The plugin page carries `add` as a host-run step.** +7. **The worktree directory is `0o700`.** +8. **Exit codes:** 0 reclaimed something, 1 reclaimed nothing, 2 refusal or fault. +9. **A short-key directory's row names the full-key lane.** + +## Open Questions + +_None open._ + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ + +## 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. diff --git a/.abcd/development/principles/the-users-directory-is-theirs.md b/.abcd/development/principles/the-users-directory-is-theirs.md index d3f56c5d0..d723b7a1c 100644 --- a/.abcd/development/principles/the-users-directory-is-theirs.md +++ b/.abcd/development/principles/the-users-directory-is-theirs.md @@ -43,7 +43,7 @@ refuses a configuration root the caller does not own, and only `~/.abcd/trusted-roots` re-admits it. The store this rule points at — a root-SHA-keyed `~/.abcd/worktrees/` lane, a verb that lists it, a verb that reclaims a merged worktree, a line on the status board — is -[itd-2609091014076309](../intents/drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md), +[itd-2609091014076309](../intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md), in drafts. Until it ships the rule is applied by hand: a session that needs a worktree puts it under `~/.abcd/worktrees///`, a verifier's copy goes to `.abcd/.work.local/scratch/`, and a reviewer who sees a directory diff --git a/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md b/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md index b48246d7f..b713909a7 100644 --- a/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md +++ b/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md @@ -2105,3 +2105,26 @@ Per hand-run, append: - **Notes:** filing without the person was the ruling's own instruction (plan it, interview owed), so the table is the lane's prediction only. The metering flag and the sanitiser's ADR row are the two places the prediction most expects the interview to change. + +### Run: the worktree store (itd-2609091014076309, 2026-09-30, product thinker's planning interview) + +- **Input:** the draft, after two adversarial reviews (design/feasibility, then record-discipline). The + record-discipline reviewer proposed the table and a HOLD on three record findings, all applied before the + interview. + + | Part | Type | Home | Link | + | --- | --- | --- | --- | + | `abcd ahoy worktree add/list/prune`, the board line, the AGENTS.md pointer | capability | intent itd-2609091014076309 | refines itd-2609201916151817 | + | The build loop's lane primitive moving into the store package | plumbing | the brief's internals | refines itd-2609201916151817 (spc-2609202134338445 piece 6) | + | Delete only what passes the proof of belonging | trust rule | already adr-2609091248200336; a brief invariant cites it | none new | + | The user's directory is theirs | stance | already the principle `the-users-directory-is-theirs` | none new | + | Quiet-worktree sweep, the local tier on reclaim, exit codes | scope decisions | this interview | carried from itd-148's 2026-08-26 rulings | + +- **Verdict:** FILE-AS-IS, confirmed by the product thinker after a plain-language restatement (the first + statement, in record ids and package names, was sent back unanswered for plain words). +- **Grade:** the prediction held on routing. What it did not predict: the interview added a capability row + that was not in the draft, a notes archive (a sibling store of the transcript store) that takes a reclaimed + worktree's local tier instead of deleting it. It joined this intent as part of `prune`, not as a new record, + because it has no user moment apart from the reclaim. +- **Notes:** the decomposition question is the one most likely to be written for the facilitator by habit. + Put to the product thinker, it needs an everyday analogy for "record homes" before the rows mean anything. diff --git a/.abcd/development/specs/open/spc-2609301811532881-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md b/.abcd/development/specs/open/spc-2609301811532881-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md new file mode 100644 index 000000000..807fe3c5b --- /dev/null +++ b/.abcd/development/specs/open/spc-2609301811532881-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md @@ -0,0 +1,469 @@ +--- +id: spc-2609301811532881 +slug: session-and-agent-worktrees-live-in-a-machine-scoped-store-t +intent: itd-2609091014076309 +origin: researcher-authored +production_mode: hand-written +--- +# The worktree store: `abcd ahoy worktree add|list|prune`, one lane primitive, and a notes archive beside it + +## Summary + +This spec delivers +[itd-2609091014076309](../../intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md): +a session's or an agent's worktree lands in `~/.abcd/worktrees///`, +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 `** 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](../../decisions/adrs/2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md); +the store's shape is the transcript store's +([adr-2609091248201071](../../decisions/adrs/2609091248201071-the-transcript-corpus-is-a-sibling-store-that-creates-itself.md)), +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](../../brief/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/worktrees/` or `~/.abcd/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/worktrees//` and of `~/.abcd/notes///` 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: + +```go +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](spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md) +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 [--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 +``, cut from the default branch (`gitutil.DefaultRef`) as the loop's lanes +are; a branch of that name that already exists refuses, naming `--branch ` +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` through gitutil's isolated +environment, 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: + +1. `git worktree list --porcelain` run from this checkout lists it; +2. its real path (`filepath.EvalSymlinks`) is a direct child of the real path of + this repository's lane `~/.abcd/worktrees//`; +3. `git -C 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 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/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/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: /worktrees/`), 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/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): + +1. **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 list` per run for the + lane's branches, through `ahoy`'s existing runner and its timeout, made by the + caller's own identity. +2. **By ancestry, with commits of its own.** The tip is an ancestor of the + default branch and is not the point the branch was created from (the oldest + entry of the branch's reflog). A branch cut and never committed to is not + merged, however clean; a branch with no reflog to prove its creation point is + judged by the content tests alone. +3. **By content, commit by commit.** `git cherry ` lists at + least one commit and every commit is marked equivalent (a rebase merge). +4. **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](../../brief/02-constraints/03-invariants.md): 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 ]… [--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 `.git` pointer): 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 to `Prune` as a + seam, the shape `statusblock.LaneReader` already 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 ` 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/notes//-/` +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. + +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). Last activity is +the later of the branch tip's committer date 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/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 +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 `", or "open a pull +request if the work is wanted"). A candidate is left in place unless named with +`--yes ` (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 reclaimable (`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 ` 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 ` (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 + +1. `Add` makes the worktree at `~/.abcd/worktrees///` 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. +2. The shared level maker makes and re-verifies each level in turn; a symlink at + any level refuses before anything beneath it is made. +3. `fsutil.CallersAlone` on every level refuses another account's level or one + group or others can write, before anything is written. +4. `ValidName`, the lane-occupancy check and the root-commit check refuse with + the reason; `--branch` on an existing branch checks it out with no new branch. +5. The loop's lane step calls `LaneFor`, `Ensure`, `Adopt` and `Add`; a test + proves the lane was made through the package. +6. Bare `list` renders every worktree git reports, with the `where` column + marking the sibling as outside, and branch, clean and merged on each. +7. `--all` labels lanes from `RegistryLabels()` or by SHA, reads ownership from + each worktree's `.git` file (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. +8. 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. +9. 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. +10. The archive writes the redacted files and the manifest under + `~/.abcd/notes//-/` before removal and the row + names the path; a failed archive keeps the worktree with the reason. +11. The quiet rule lists the candidate with its dossier (dirty state first) and + leaves it; `--yes ` archives and removes that one only; a dirty named + candidate is kept because removal is never forced. +12. A store refusal stops the run before judgement: exit 2, nothing removed. +13. Dirty, unmerged and outside rows are kept with reasons and the run exits 1; + with a merged, clean worktree added it exits 0. +14. 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. +15. The locked, own-cwd and held-by-the-loop refusals keep all three, and no + removal passes `--force`. +16. `--dry-run` computes the same rows and skips the archive and the removal; + `--json` emits those rows. +17. The board's `worktrees` member comes from the one `peers.Scan`, is omitted on + an empty lane, and is not part of `statusblock.Block`. +18. The lane is found from the root commit, so the moved checkout lists the same + lane. +19. `Add` sets the worktree directory to `0o700` after git creates it. +20. `AGENTS.md`'s convention names the verb and no sibling-directory form. +21. `commands/ahoy.md` carries 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. + +- `add` cuts 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. +- `list` asks the forge as `prune` does, 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-wide `git worktree prune` is 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. +- `--yes` admits only a quiet candidate; a name not in the lane refuses the run. +- `--dry-run` returns 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. + +## Open point + +_None open._ The board's "reclaimable" 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. 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. + +## Footprint + +- packages: internal/core/ahoy/worktree, internal/core/ahoy, internal/core/implement/loop, internal/core/peers, internal/core/history, internal/gitutil, internal/surface/cli, commands/, AGENTS.md, .abcd/development/brief/05-internals, .abcd/development/brief/04-surfaces +- 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 + +1. The store package and the lane primitive + - criteria: 2, 3, 5 + - 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; `ParseWorktreeList` carries locked and prunable in the `-z` and newline forms; `gitutil.CommonDir` refuses a multi-line answer; the loop's existing lane tests pass unchanged and a new one proves the lane goes through the package +2. `ahoy worktree add` + - criteria: 1, 4, 19 + - 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 and a repository with no commits refuse and write nothing; `--branch` checks out an existing branch and makes none +3. `list`, `list --all` and the merged judgement + - criteria: 6, 7, 18 + - 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 +4. `prune` for merged worktrees, with the notes archive + - criteria: 8, 9, 10, 12, 13, 14, 15, 16 + - 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; dry-run and `--json` matching a real run's rows; `history.Capture` still passing its suite through the lifted pass +5. Quiet candidates + - criteria: 11 + - 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 ` removes that one only; a dirty named candidate kept; `worktrees.quiet_days` read through the layered resolver and a bad value refused; a `--yes` name not in the lane refusing the run +6. The board line + - criteria: 17 + - 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 +7. The surfaces + - criteria: 20, 21 + - brief: when the store ships, `02-constraints/03-invariants.md` gains 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. + - packages: commands/, AGENTS.md, .abcd/development/brief/05-internals, .abcd/development/brief/04-surfaces, internal/surface/cli + - tests: the plugin page carries the host-run step; `AGENTS.md` names the verb and no sibling form; the surface snapshot and command reference regenerated; docs-lint and record-lint clean diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 86bd090e7..ca4a1f067 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2623,3 +2623,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-30 — Correcting three points of the entry above after its review (lane fix-drainOwnRule of autonomous run A). The drain-rule offer of `ahoy install` is asked only of a person at a terminal, the itd-131 precedent the git identity question set, rather than behind a named opt-in flag: off a terminal neither its category question nor the offer is asked, so a piped answer stream keeps the order it had before the offer existed and a scripted yes never writes the record, and the run reports `drain_rule.offered` under `optional_skipped` naming the terminal as the way to be asked. The terminal gate was chosen over a `--drain-rule` flag because the record decides what an unattended agent may do, which a scripted answer is not a person's yes to, and a flag would hide the offer from the person at a terminal it is for. A checkout holding no release tag (a shallow clone fetches none) marks the anchor unknown rather than reading every deferral as lapsed: every record carrying a deferral is handed back as `deferred`, naming the missing tags and `git fetch --tags`, which keeps the rest of the dry run readable where refusing the whole plan would not. The rule's reader refuses, as malformed, a record that states any frontmatter key twice (not only a `drain_` key) and one whose frontmatter `id` disagrees with its file name, and reads each record through the capped trust-boundary reader, so a record that is a symlink or past the size cap refuses; every refusal of the rule exits 2 on the dry run as on the bare verb. - 2026-09-30 — Six entries above appear twice, verbatim: the five dated 2026-09-29 from "Two itd-111 follow-ups from its fidelity audit" to "Ruling J13", and the 2026-09-30 entry beginning "The 2026-09-29 itd111Follow entry above". Two histories carried them in opposite order relative to the 2026-09-30 BU1/BT1 entry (main below them, the implement-loop lanes above them), so joining them in integration 24b-3 kept main's order and repeated the six after BU1 in the lanes' order, the one merge result the append-only gate admits (every parent's lines kept in their order, DA002; no line beyond what the merge base held plus what each side added, DA003). Each pair is one decision recorded once: the first copy is the record, and the second repeats it (recorded by the integration lane of autonomous run A). - 2026-09-30 — An older site interface-string file keeps building: `abcd site setup` and `abcd site build` add to `site-src/ui.json` each label the allowlist declares and the file does not carry, with abcd's default words, name each on stderr, and change nothing else in it (the product thinker's ruling TG1 of 2026-09-30, relayed verbatim: "(b) ABCD ADDS THE MISSING LABELS: on the next site setup or site build, abcd adds only the missing required labels (with the default words); the project's own wording elsewhere in ui.json is never changed. No failure, no manual step; both intents stay impact: additive."). It is the one exception to "a file the repository owns once it exists is kept", recorded as adr-2609301720596683, which refines adr-47 and leaves decision 2's closed allowlist untouched: a blank declared label and an unknown key are still refused, and the site gate's own render never completes the file. itd-2609212103568351 and itd-2609212103572513 keep `impact: additive` (lane tgLabels of autonomous run A). +- 2026-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 `), never forced; a reclaimed worktree's local tier is moved, never deleted, into a new sibling store `~/.abcd/notes//-/`, 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). diff --git a/AGENTS.md b/AGENTS.md index 0729c976e..fbc1ab970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,7 +250,7 @@ irreversible; guessing downward costs nothing.** is the stance. **The store has no verbs yet.** Aim a plain `git worktree add` at the path and create the lane by hand; the store's own `add`, its listing and its reclaim are - [itd-2609091014076309](.abcd/development/intents/drafts/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md), + [itd-2609091014076309](.abcd/development/intents/planned/itd-2609091014076309-session-and-agent-worktrees-live-in-a-machine-scoped-store-t.md), in `drafts/`, so until it ships nothing enumerates the lane or prunes a spent worktree for you, and a worktree in the store is retired with `git worktree remove` like any other.