From b82f1b453caba374a33e67f54954fd9c29c49a3f Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 12 Sep 2026 11:22:12 +0300 Subject: [PATCH] A working directory is disposable ADR 0027 and the change documents for where working directories live and what has to leave one before it goes. The folder that used to hold repositories 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 new default will not tidy those. While looking, the larger half of the problem turned up. A working directory's .openspec-ui is gitignored, so audit.jsonl 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. The tool destroys its own evidence at the moment that evidence first becomes interesting. So location and harvest are one question, and answering only the first would be choosing where to lose things. Working directories go under one root, at root/repository/change, read from the environment and a user-level file and never from the repository's own configuration: that file travels to every checkout, and a person's disk layout is not a property of the project. Not the system temporary directory, though it was proposed and the reasoning was sound as far as "these are disposable". That sentence has an "once" in it: the temporary directory is swept by things that cannot know whether the transfer happened, and a sweep bypasses the refusal that protects uncommitted work. Disposable is right; disposed of by the tool, after the transfer, is the part that has to stay true. Removal merges the directory's run history into the repository's first. Entries already carry their cwd and changeDir, so a combined log is unambiguous and de-duplicates by runId. Writes are not changed: every run keeps writing to its own directory, which is what keeps two runs off one file. Asked what else should travel, timestamps and failures are both already inside audit.jsonl. What is genuinely separate is named at removal rather than deleted quietly: the run journal, whose value expires when its runs end, and the checkpoints, whose purpose ends when the change archives. Commits are not at risk. A branch lives in the shared object store, so exactly two things can be lost: uncommitted work, which removal already refuses, and ignored files, which this change is about. Co-Authored-By: Claude Opus 5 (1M context) --- .../0027-a-working-directory-is-disposable.md | 155 ++++++++++++++++++ docs/adr/README.md | 1 + .../.openspec.yaml | 5 + .../design.md | 123 ++++++++++++++ .../proposal.md | 68 ++++++++ .../specs/ci-cli/spec.md | 83 ++++++++++ .../tasks.md | 96 +++++++++++ 7 files changed, 531 insertions(+) create mode 100644 docs/adr/0027-a-working-directory-is-disposable.md create mode 100644 openspec/changes/a-working-directory-is-disposable/.openspec.yaml create mode 100644 openspec/changes/a-working-directory-is-disposable/design.md create mode 100644 openspec/changes/a-working-directory-is-disposable/proposal.md create mode 100644 openspec/changes/a-working-directory-is-disposable/specs/ci-cli/spec.md create mode 100644 openspec/changes/a-working-directory-is-disposable/tasks.md 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.