Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions docs/adr/0027-a-working-directory-is-disposable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# 0027: A Working Directory Is Disposable, and Nothing of Value Stays In It

Status: Accepted

Date: 2026-09-12

## Context

ADR-0022 gave a change its own working directory. The default put it
beside the repository — `<repo>.worktrees/<change>` — and that default
has now met a real disk. `C:\Prog` holds seventeen directories, of which
eleven are repositories; the rest are working directories, leftover
copies, and a second clone of one project. The owner's phrase was that a
folder which used to hold repositories became a zoo.

Only one of those entries comes from this tool's default. The others
have no `.git` at all and were made by something else. A new default
will not tidy them, which is worth saying plainly before choosing one.

Two things were also discovered while looking:

- A working directory's `.openspec-ui/` is gitignored, so `audit.jsonl`
— the run history every recommendation, timeline and quality figure is
built from — never leaves it by commit.
- `git worktree remove` without `--force` refuses uncommitted *tracked*
work but does not see ignored files. Measured in a throwaway
repository: the ignored log leaves the directory clean, removal exits
`0`, and the log is gone. The tool destroys its own evidence at
exactly the moment that evidence first becomes interesting.

So "where do these directories live" and "what has to leave one before
it goes" are one question. Choosing a location without answering the
second would be choosing where to lose things.

## Decision

**One root for every repository's working directories.**

They live at `<root>/<repository>/<change>`: one directory in the
person's workspace folder for every repository, rather than one beside
each. The root is configurable.

It stays **outside** the repository. `change-worktrees.ts` already
records why: a complete second copy inside is something every recursive
tool in the repository walks into.

**The root is a property of a machine, not of the repository.**

It is read from the environment and from a user-level file, never from
`openspec/config.yaml`. A repository's config travels to every checkout,
and one person's disk layout is not a fact about the project. A setting
in the wrong file is a setting that arrives where nobody wanted it.

**Not the system temporary directory.**

It was considered, and the reasoning for it is sound as far as it goes:
these directories are disposable, and once their artifacts have been
transferred the directory itself is worth nothing.

It is rejected because that sentence has an "once" in it. The system
temporary directory is swept — by Storage Sense, by disk cleanup, by
anything that deletes files older than a few days — and the sweeper
cannot know whether the transfer happened. Handing deletion to it means
the only mechanism that removes these directories is the one mechanism
that cannot check the precondition for removing them safely.

It would also bypass a refusal this project deliberately built.
`worktreeRemove` passes no `--force`, so a directory holding uncommitted
tracked work is refused. A filesystem sweep refuses nothing.

And on Windows it is long. A temporary path is some thirty characters
deeper than a short root, before a monorepo's nested `node_modules`
begins, against a limit of 260.

Disposable is right. Disposed of *by the tool, after the transfer* is
the part that has to stay true.

**Removal harvests what is not in git, and says what it discards.**

Before a working directory is removed, its run history is copied into
the repository's own. Entries already record their own `cwd` and
`changeDir`, so a combined log is unambiguous and de-duplicable by
`runId`; nothing has to be rewritten to merge.

Whatever is not harvested is **named** at the point of removal rather
than deleted quietly. A destroyed thing that was announced is a
decision; one that was not is a discovery, made later, by whoever needed
it.

**The log is the artifact; most of what one would list separately is
inside it.**

When asked what else should be transferred — timestamps, failures — the
answer for both is that they are already in `audit.jsonl`: it carries
the run's identity, agent, agent version, timings, usage, outcome and
summary. Timelines, costs, verify quality and recommendations are all
derived from it and from nothing else. Transferring it transfers them.

What is genuinely separate, and what becomes of it:

- **The run journal** (`workbench-runs.json`) — recovery state for runs
in flight. Its value expires when they end. Named at removal, not
transferred.
- **Checkpoints** — the before-and-after a rollback would use. Large,
and their purpose ends when the change archives. Named, not
transferred.
- **The lease** — ephemeral by construction. Not named; there is nothing
to say about it.
- **Failure artifacts from the browser suite** — traces and videos,
produced only when something failed, which is exactly when somebody
wants them. Harvested when present.

**Commits are not at risk, and saying so bounds the problem.**

A working directory's branch and commits live in the shared object
store, not in the directory. Removing it loses neither. What can be lost
is exactly two things: uncommitted work, which removal already refuses,
and ignored files, which this ADR is about.

## Consequences

A person's workspace folder gains one directory for all repositories
instead of one per repository.

Existing working directories do not move by themselves. `git worktree
move` relocates them, offered as a command; directories the tool never
created are reported and left alone, because what is in them is not
known here.

Removal costs a copy of a log that is measured in tens of kilobytes.

A combined log contains entries from directories that no longer exist.
That is the point, and each entry says where it ran.

## Alternatives considered

**The system temporary directory.** Rejected above: deletion by
something that cannot know whether the transfer happened, a refusal
bypassed, and a long path on Windows.

**Keeping the current sibling default.** Rejected: it is one directory
per repository in a folder that holds many, which is the complaint.

**Writing every run's audit entries straight into the primary
checkout.** Rejected: it reintroduces the shared mutable file that
per-directory isolation removed, with concurrent appends from several
runs — on Windows without a guarantee of atomicity — and would need a
lock in the hot path to replace a copy at a rare moment.

**Refusing to remove a directory until its artifacts are transferred.**
Rejected as the primary mechanism: it makes tidying up a negotiation.
Harvesting first and then removing achieves the same end without one.

**Putting the root in `openspec/config.yaml`.** Rejected: it would send
one person's disk layout to every checkout of the project.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ Alternatives / Consequences.
| [0024](0024-parallel-readiness-is-derived.md) | Parallel readiness is derived, never declared | Accepted |
| [0025](0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md) | The pipeline picture is derived, and drawn without a graph library | Accepted |
| [0026](0026-other-working-directories-are-observed-never-touched.md) | Other working directories are observed, never touched | Accepted |
| [0027](0027-a-working-directory-is-disposable.md) | A working directory is disposable, and nothing of value stays in it | Accepted |

New architecture-impacting changes must add an ADR and reference it from the related OpenSpec change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
schema: spec-driven
created: 2026-09-12
follows:
- changes-run-side-by-side
- a-lease-says-who
123 changes: 123 additions & 0 deletions openspec/changes/a-working-directory-is-disposable/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Design

See `docs/adr/0027-a-working-directory-is-disposable.md` for the
decisions: one root outside the repository, configured per machine and
not per repository, not the system temporary directory, and removal that
harvests before it deletes and names what it does not take.

## Decision: the root, and where its setting lives

`<root>/<repository>/<change>`. The repository segment is what lets one
root serve every project without two changes of the same name in
different repositories colliding on a path.

The root is read, in order: an environment variable, then a user-level
file, then a default. The default keeps the parent of the repository —
`<parent>/.worktrees` — so a person who sets nothing gets one hidden
directory where they previously got one per repository, at a path no
longer than today's.

It is deliberately not read from `openspec/config.yaml`. That file
travels to every checkout, and a person's disk layout is not a property
of the project; a setting placed there arrives on machines whose owner
never chose it.

## Decision: why not the temporary directory

The proposal was that these directories are temporary, so the system's
temporary directory is where they belong, and once their artifacts have
been transferred the directory itself is worth nothing.

The last clause is the problem, because of its "once". The system
temporary directory is swept by Storage Sense, by disk cleanup, by
anything that removes files past an age — and none of those can know
whether the transfer happened. Deletion would be performed by the one
mechanism unable to check the precondition for deleting safely.

It also bypasses a refusal built on purpose. `worktreeRemove` passes no
`--force`, so a directory holding uncommitted tracked work is refused; a
sweep refuses nothing.

And it is long. `%TEMP%` is some thirty characters deeper than a short
root before a monorepo's nested `node_modules` starts, against 260.

Disposable is right. Disposed of by the tool, after the transfer, is
what has to stay true.

## Decision: harvest, then remove

Removal copies the directory's `audit.jsonl` into the repository's own
before deleting anything.

Merging needs no rewriting: an entry already records its `cwd` and
`changeDir`, so a combined log says where each run happened, and `runId`
makes the merge idempotent — harvesting twice adds nothing the second
time.

Writes are not changed. Every run keeps writing to its own directory,
which is what keeps two runs off one file; the alternative — every run
appending to the primary checkout's log — reintroduces the shared
mutable file that per-directory isolation removed, and would need a lock
in the hot path to replace a copy at a rare moment.

## Decision: the log is the artifact

Asked what else should travel — timestamps, failures — the answer for
both is that they are already inside `audit.jsonl`. It carries each
run's identity, agent, agent version, timings, usage, outcome and
summary; `runTimestampsByChange`, `buildWorkspaceRunStats`,
`buildVerifyQuality` and the recommendations are all derived from it and
from nothing else.

What is genuinely separate, and what becomes of it:

- **`workbench-runs.json`** — recovery state for runs in flight, whose
value expires when they end. Named at removal, not taken.
- **`checkpoints/`** — the before-and-after a rollback would use. Large,
and its purpose ends when the change archives. Named, not taken.
- **`workspace.lease.json`** — ephemeral by construction. Not named;
there is nothing to say.
- **Browser-suite failure artifacts** — traces and videos, produced only
when something failed, which is when somebody wants them. Taken when
present.

## Decision: what loss even means here

A working directory's branch and commits live in the shared object
store, not in the directory. Removing it loses neither.

Exactly two things can be lost: uncommitted work, which removal already
refuses, and ignored files, which this change is about. Saying so bounds
the problem and stops it growing into a general backup feature.

## Non-Goals

Tidying directories this tool did not create. Three entries in `C:\Prog`
have no `.git` at all; what is in them is not known here, and deleting
or moving them would be acting on a guess.

Changing how a run writes its log.

Backing up a working directory. The two recoverable things are named
above; everything else in one is either in git or reproducible.

Forbidding a location. A person who wants their directories somewhere
unusual sets the root and gets it.

## Risks / Trade-offs

A combined log holds entries from directories that no longer exist. That
is the intent, and every entry says where it ran — but a reader who
assumes a path still exists will be wrong.

Harvesting on removal does nothing for a directory deleted by hand, or
by a sweep, or by `git worktree remove` run directly. This makes the
tool's own path safe; it cannot make every path safe.

The default root changes where new directories appear. Someone with
scripts pointing at `<repo>.worktrees/<change>` will find the next one
elsewhere, which is why existing directories are reported rather than
moved out from under them.

Two repositories with the same directory name share a root segment.
Rare, and the failure is visible immediately rather than silent.
68 changes: 68 additions & 0 deletions openspec/changes/a-working-directory-is-disposable/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# A working directory is disposable

## Why

`C:\Prog` used to hold repositories. It now holds seventeen entries, of
which eleven are repositories and the rest are working directories,
leftover copies, and a second clone of one project.

One of those entries comes from this tool: `defaultWorktreePath` puts a
change's directory beside the repository, as `<repo>.worktrees/<change>`
— one such container per repository. The others have no `.git` at all
and were made by something else. A new default will not tidy those, and
this change does not pretend otherwise.

While looking, two things turned up that make the location question the
smaller half of the problem.

A working directory's `.openspec-ui/` is gitignored, so `audit.jsonl` —
the run history every recommendation, timeline, cost figure and quality
rating is derived from — cannot leave by commit. And `git worktree
remove` without `--force` refuses uncommitted **tracked** work but does
not see ignored files. Measured in a throwaway repository: the ignored
log leaves the directory clean, removal exits `0`, the log is gone.

So the tool destroys its own evidence at the moment that evidence first
becomes interesting — when the work is finished.

That makes "where do these live" and "what has to leave one before it
goes" a single question. Answering only the first would be choosing
where to lose things.

## What Changes

- Working directories are created under **one root**, at
`<root>/<repository>/<change>`: one directory in a person's workspace
folder for every repository, instead of one beside each.
- The root is read from the environment and a **user-level** file, never
from `openspec/config.yaml` — one person's disk layout is not a fact
about the project.
- It stays **outside** the repository, for the reason
`change-worktrees.ts` already records: a second copy inside is
something every recursive tool walks into.
- **Not the system temporary directory**, though it was proposed and the
reasoning was sound as far as "these are disposable". It is swept by
things that cannot know whether the transfer happened, and a sweep
bypasses the refusal that protects uncommitted work. See ADR 0027.
- **Removal harvests first.** The directory's run history is merged into
the repository's own before the directory goes. Entries already carry
their `cwd` and `changeDir`, so a combined log is unambiguous and
de-duplicates by `runId`.
- **What is not harvested is named**, at the point of removal. A
destroyed thing that was announced is a decision; one that was not is
a discovery, made later, by whoever needed it.
- Existing directories are **reported and movable**, never moved
silently. Directories the tool did not create are reported and left
alone.

## Impact

- `packages/core` — where a directory goes, and what leaves it before it
is removed.
- `packages/cli` — `worktree add` creates under the root; `worktree
remove` harvests and reports; a command to relocate what already
exists.
- No change to how a run writes its log. Writes stay per-directory,
which is what keeps two runs off one file.

See `docs/adr/0027-a-working-directory-is-disposable.md`.
Loading
Loading