diff --git a/docs/adr/0027-a-working-directory-is-disposable.md b/docs/adr/0027-a-working-directory-is-disposable.md new file mode 100644 index 0000000..93956ad --- /dev/null +++ b/docs/adr/0027-a-working-directory-is-disposable.md @@ -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 — `.worktrees/` — 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 `//`: 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index b2f3183..729fcb5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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. diff --git a/openspec/changes/a-working-directory-is-disposable/.openspec.yaml b/openspec/changes/a-working-directory-is-disposable/.openspec.yaml new file mode 100644 index 0000000..9dafc9d --- /dev/null +++ b/openspec/changes/a-working-directory-is-disposable/.openspec.yaml @@ -0,0 +1,5 @@ +schema: spec-driven +created: 2026-09-12 +follows: + - changes-run-side-by-side + - a-lease-says-who diff --git a/openspec/changes/a-working-directory-is-disposable/design.md b/openspec/changes/a-working-directory-is-disposable/design.md new file mode 100644 index 0000000..301827a --- /dev/null +++ b/openspec/changes/a-working-directory-is-disposable/design.md @@ -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 + +`//`. 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 — +`/.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 `.worktrees/` 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. diff --git a/openspec/changes/a-working-directory-is-disposable/proposal.md b/openspec/changes/a-working-directory-is-disposable/proposal.md new file mode 100644 index 0000000..7d0f2bd --- /dev/null +++ b/openspec/changes/a-working-directory-is-disposable/proposal.md @@ -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 `.worktrees/` +— 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 + `//`: 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`. diff --git a/openspec/changes/a-working-directory-is-disposable/specs/ci-cli/spec.md b/openspec/changes/a-working-directory-is-disposable/specs/ci-cli/spec.md new file mode 100644 index 0000000..d67c59e --- /dev/null +++ b/openspec/changes/a-working-directory-is-disposable/specs/ci-cli/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Working directories are created under one root + +A change's working directory SHALL be created under a single configured +root, in a place that identifies both the repository and the change, so +that one root serves every repository. + +The root SHALL be outside the repository. + +Where nothing is configured, a default SHALL be used, so that creating a +working directory never requires configuration first. + +#### Scenario: A working directory for a change + +- **WHEN** a working directory is created for a change +- **THEN** it is placed under the root, under that repository + +#### Scenario: Two repositories, one root + +- **WHEN** changes of the same name exist in two repositories +- **THEN** each gets its own working directory and neither displaces the + other + +### Requirement: Where working directories live is a setting of the machine + +The root SHALL be read from the environment and from a setting belonging +to the person, and SHALL NOT be read from the repository's own +configuration. + +#### Scenario: A repository opened on another machine + +- **WHEN** the same repository is opened by somebody else +- **THEN** their working directories go where they configured, not where + anybody else did + +### Requirement: Removing a working directory keeps its run history + +Before a working directory is removed, the run history recorded in it +SHALL be merged into the repository's own. + +Merging SHALL be repeatable without duplicating what it already took. + +#### Scenario: Removing a directory that recorded runs + +- **WHEN** a working directory holding run history is removed +- **THEN** that history is readable in the repository afterwards + +#### Scenario: Removing a directory that recorded nothing + +- **WHEN** a working directory holding no run history is removed +- **THEN** it is removed and nothing is reported as taken + +### Requirement: Removal says what it discards + +Where a working directory holds anything outside version control that is +not taken, removal SHALL name it. + +#### Scenario: A directory holding rollback data + +- **WHEN** a working directory holding checkpoints is removed +- **THEN** removal names them as discarded before removing the directory + +### Requirement: Existing working directories are reported, never moved unasked + +Working directories that already exist elsewhere SHALL be reported with +where they are. + +They SHALL be relocatable on request, and SHALL NOT be moved as a side +effect of anything else. + +Directories that this tool did not create SHALL NOT be moved or removed. + +#### Scenario: A directory created under an older default + +- **WHEN** a working directory exists outside the root +- **THEN** it is reported, and moving it requires asking + +#### Scenario: A directory nothing here created + +- **WHEN** a directory beside the repository is not a working directory + of it +- **THEN** nothing here moves or removes it diff --git a/openspec/changes/a-working-directory-is-disposable/tasks.md b/openspec/changes/a-working-directory-is-disposable/tasks.md new file mode 100644 index 0000000..bb9c3c2 --- /dev/null +++ b/openspec/changes/a-working-directory-is-disposable/tasks.md @@ -0,0 +1,96 @@ +`C:\Prog` used to hold repositories and now holds seventeen entries, of +which eleven are. One of the rest comes from this tool's default; the +others have no `.git` at all. And a working directory's run history is +gitignored, so removing the directory destroys it — measured, not +assumed. + +**Coordination.** This delivers a `ci-cli` delta. Two proposals on +another branch — `a-doctor-says-what-would-stop-a-run` and +`a-hint-says-what-can-run-together` — deliver one too, and would meet +this in `openspec/specs/ci-cli/spec.md` at archive. Proposals do not +collide; implementations do. Do not implement this alongside either of +those. + +## 1. Where a working directory goes + +- [ ] 1.1 `//`. The repository segment is what + lets one root serve every project without two changes of one name + colliding on a path. +- [ ] 1.2 The root is read from an environment variable, then a + user-level file, then a default. Never from `openspec/config.yaml`: + that file travels to every checkout, and a person's disk layout is not + a property of the project. +- [ ] 1.3 The default keeps the repository's parent — `/.worktrees` + — so somebody who sets nothing gets one hidden directory where they + used to get one per repository, at a path no longer than today's. +- [ ] 1.4 Still outside the repository. `change-worktrees.ts` already + records why: a second copy inside is what every recursive tool in the + repository walks into. +- [ ] 1.5 Not the system temporary directory, and the reason is written + down where somebody will propose it again (ADR 0027): it is swept by + things that cannot know whether the transfer happened, and a sweep + bypasses the refusal that protects uncommitted work. + +## 2. What leaves before it goes + +- [ ] 2.1 `worktree remove` merges the directory's `audit.jsonl` into + the repository's own before deleting anything. +- [ ] 2.2 Merging needs no rewriting: an entry already carries its `cwd` + and `changeDir`. De-duplicated by `runId`, so harvesting twice adds + nothing the second time. +- [ ] 2.3 Browser-suite failure artifacts are taken where present. They + exist only when something failed, which is when somebody wants them. +- [ ] 2.4 Writes are NOT changed. Every run keeps writing to its own + directory — that is what keeps two runs off one file, and the + alternative would need a lock in the hot path to replace a copy at a + rare moment. +- [ ] 2.5 What is not taken is named at removal: the run journal, whose + value expires when its runs end, and the checkpoints, whose purpose + ends when the change archives. A destroyed thing that was announced is + a decision; one that was not is a discovery. +- [ ] 2.6 The lease is not named. It is ephemeral by construction and + there is nothing to say about it. + +## 3. What already exists + +- [ ] 3.1 Working directories outside the root are reported, with where + they are. +- [ ] 3.2 A command relocates one on request, by `git worktree move`. + Nothing moves as a side effect of anything else — somebody may have + scripts pointing at the old path. +- [ ] 3.3 Directories this tool did not create are never moved or + removed. Three entries beside this repository have no `.git`; what is + in them is not known here, and acting on them would be acting on a + guess. + +## 4. Tests + +- [ ] 4.1 A working directory is created under the root, under its + repository's segment. +- [ ] 4.2 The environment wins over the user-level file, which wins over + the default. +- [ ] 4.3 The repository's own configuration is not consulted for the + root — asserted, because the natural place to put a setting is the + file that would be wrong. +- [ ] 4.4 Removal merges a directory's history into the repository's, + and merging twice leaves one copy of each entry. +- [ ] 4.5 Removal of a directory that recorded nothing takes nothing and + says nothing was taken. +- [ ] 4.6 Removal names checkpoints as discarded before deleting. +- [ ] 4.7 Removal still refuses a directory holding uncommitted tracked + work — the refusal this change relies on must not be lost while its + neighbour is rewritten. +- [ ] 4.8 A directory outside the root is reported and not moved. + +## 5. Verification + +- [ ] 5.1 This change validates strictly. `check(validate-change)` +- [ ] 5.2 `npm run verify` unpiped, after the last edit, with everything + staged. Record the run and the per-package test counts. +- [ ] 5.3 A pending changeset exists. `check(changeset-present)` +- [ ] 5.4 **Delegated to `claude-cli`**: create a working directory, run + a chain in it so it records history, remove it, and read that history + back from the repository. Then remove one that recorded nothing. + Evidence: both logs before and after, and what removal printed. The + unit tests drive this with logs a test wrote; only a real run shows + that what a chain records is what removal carries out.