diff --git a/.claude/skills/assign-to-workforce/SKILL.md b/.claude/skills/assign-to-workforce/SKILL.md index c8db88f..7376316 100644 --- a/.claude/skills/assign-to-workforce/SKILL.md +++ b/.claude/skills/assign-to-workforce/SKILL.md @@ -148,12 +148,31 @@ stay consistent if either changes.) Once the human approves, the main agent fans out each wave in order: -1. **Create an isolated git worktree** for each task in the current wave: +1. **Create an isolated git worktree** for each task in the current wave, + **inside this repo's own worktree root** — `.worktrees.`, a + sibling of the repo directory: ```bash - git worktree add ../worktrees/agent- -b agent/ + repo_root=$(git rev-parse --show-toplevel) + wt_root="$(dirname "$repo_root")/.worktrees.$(basename "$repo_root")" + git worktree add "$wt_root/agent-" -b agent/ ``` + Never use a bare `../worktrees/` or an in-repo path. The + `.worktrees.` root is mandatory for three reasons: + + - **Nobody else will delete it.** A shared `../worktrees/` in a multi-repo + parent directory looks like anyone's scratch space; a directory named + after *your* repo is visibly owned, so another agent or human cleaning up + their own worktrees won't sweep away a live fan-out mid-wave. + - **No cross-repo collision.** Task ids restart at `t1` in every repo and + every plan, so `../worktrees/agent-t1` from two concurrent repos is the + same path. Namespacing by repo name keeps concurrent fan-outs disjoint. + - **The repo working tree stays clean.** An in-repo path (`.worktrees/`, + `.claude/worktrees/`) puts N checkouts inside the tree you are about to + commit and PR — `git add -A` sweeps them in and `git clean -fdx` destroys + them. Outside the repo, neither can touch them. + 2. **Spawn a task agent** inside that worktree (using the approved model from the split plan), with: - The task id, summary, working instruction, acceptance criteria, and @@ -190,9 +209,13 @@ For each completed task worktree, the main agent: 4. **Removes the worktree** once the merge is accepted: ```bash - git worktree remove ../worktrees/agent- + git worktree remove "$wt_root/agent-" ``` + Remove only the worktrees this run created — never `rm -rf` the + `.worktrees.` root itself, and never touch another repo's + worktree root. A concurrent fan-out may be live inside it. + The human does **not** review individual task merges. Per-task acceptance is the main agent's responsibility — the TDD gate (tests pass before AND after merge) plus the task's acceptance criteria. This mirrors the non-authoritative @@ -242,6 +265,10 @@ These protect the human-gate contract and the TDD guarantee. contention is managed by isolation, not by trust in the dependency graph. The dependency graph guarantees *logical* independence within a wave, not *file* disjointness. Conflicts surface at merge time. +- **All worktrees live under `.worktrees.`.** Every worktree this + skill creates goes in that one repo-owned root beside the repo directory — + never a shared `../worktrees/`, never inside the repo. Clean up only the + worktrees you created; leave the root and anyone else's worktrees alone. - **Tests before AND after merge — no exceptions.** The TDD gate must pass on both sides. A merge that makes tests pass only after (not before) means the baseline was already broken — fix the baseline first. @@ -285,22 +312,27 @@ a split-plan # --- HUMAN: review the table, edit agent/model assignments if needed, # then say "approved" to proceed --- -# 3. Fan out wave 1 (t1, t2, t3 are independent — run in parallel) -git worktree add ../worktrees/agent-t1 -b agent/t1 -git worktree add ../worktrees/agent-t2 -b agent/t2 -git worktree add ../worktrees/agent-t3 -b agent/t3 +# 3. Fan out wave 1 (t1, t2, t3 are independent — run in parallel). +# All worktrees live under this repo's own root, beside the repo dir: +# e.g. /devague -> /.worktrees.devague/ +repo_root=$(git rev-parse --show-toplevel) +wt_root="$(dirname "$repo_root")/.worktrees.$(basename "$repo_root")" + +git worktree add "$wt_root/agent-t1" -b agent/t1 +git worktree add "$wt_root/agent-t2" -b agent/t2 +git worktree add "$wt_root/agent-t3" -b agent/t3 # ... spawn task agents in each worktree, await completion ... # 4. TDD-gated merge for each wave-1 task (no human per task) git merge --no-ff agent/t1 # tests pass before + after -git worktree remove ../worktrees/agent-t1 +git worktree remove "$wt_root/agent-t1" git merge --no-ff agent/t2 -git worktree remove ../worktrees/agent-t2 +git worktree remove "$wt_root/agent-t2" git merge --no-ff agent/t3 -git worktree remove ../worktrees/agent-t3 +git worktree remove "$wt_root/agent-t3" # 5. Advance to wave 2 (t4 depends on t1–t3 being merged) -git worktree add ../worktrees/agent-t4 -b agent/t4 +git worktree add "$wt_root/agent-t4" -b agent/t4 # ... spawn, await, merge with TDD gate, remove worktree ... # 6. Open the final PR (human gate 3) diff --git a/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh b/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh index c6637ba..9527679 100755 --- a/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +++ b/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh @@ -224,7 +224,9 @@ print("Go/no-go: review the table above, edit the Model column as needed, then") print("confirm: \"Approved — assign to workforce\" or \"Edit first\".") print() print("Once approved, fan out wave by wave:") -print(" 1. Create one git worktree per task in the wave.") +print(" 1. Create one git worktree per task in the wave, under this repo's own") +print(" root beside the repo dir: /.worktrees./agent-") +print(" (never a shared ../worktrees/, never inside the repo).") print(" 2. Spawn a task agent per worktree — its brief quotes `devague plan") print(" waves --json`'s summary, instruction, and acceptance criteria for") print(" that task id verbatim (no paraphrasing).") diff --git a/CHANGELOG.md b/CHANGELOG.md index f5dbfd0..99a47ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.20.1] - 2026-07-20 + +### Changed + +- **Fan-out worktrees now live in one repo-owned root: + `/.worktrees./agent-`.** The + `assign-to-workforce` skill, `CLAUDE.md`, `docs/skills.md`, the `split-plan` + fan-out instructions, and `devague learn` all teach the same mandatory path + and resolve it at runtime instead of hardcoding it. Two paths that were + previously in use are now explicitly forbidden: a shared `../worktrees/` (in + a multi-repo parent it belongs to nobody, so it reads as deletable scratch + space, and task ids restarting at `t1` in every repo and every plan make + concurrent fan-outs collide on the same directory) and any in-repo path such + as `.claude/worktrees/` (where `git add -A` sweeps agent checkouts into the + PR and `git clean -fdx` destroys live work). Cleanup removes only the + worktrees a run created — never the root itself, which a concurrent fan-out + may be using. `docs/assign-to-workforce-worked-example.md` keeps its + historical commands and gains a note pointing at the current convention. + ## [0.20.0] - 2026-07-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 05ab3ea..8bd3a2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,6 +227,34 @@ Same-file overlaps between tasks (which the dependency graph does not guarantee to exclude) surface as merge conflicts at reconcile time, never as live races. The main/operating agent reconciles each merge. +### Where worktrees live: `.worktrees.` (mandatory) + +**Every worktree a fan-out creates goes under one repo-owned root beside the +repo directory** — `/.worktrees./agent-`. +For this repo that is `../.worktrees.devague/`. Resolve it, never hardcode it: + +```bash +repo_root=$(git rev-parse --show-toplevel) +wt_root="$(dirname "$repo_root")/.worktrees.$(basename "$repo_root")" +git worktree add "$wt_root/agent-" -b agent/ +``` + +Two paths are **forbidden**, and each was in use here before this convention: + +- **A shared `../worktrees/`** — in a multi-repo parent like `~/git/`, that + directory belongs to nobody, so it reads as scratch space another agent or + human may delete while your wave is live. Worse, task ids restart at `t1` in + every repo and every plan, so `../worktrees/agent-t1` from two concurrent + fan-outs is literally the same directory. The repo-named root makes + ownership visible and collisions impossible. +- **Anything inside the repo** (`.worktrees/`, `.claude/worktrees/`) — N full + checkouts inside the tree you are about to commit means `git add -A` sweeps + them into the PR and `git clean -fdx` destroys live agent work. + +Clean up with `git worktree remove "$wt_root/agent-"` per merged task. +Never `rm -rf` the root itself and never touch another repo's root — a +concurrent fan-out may be running inside it. + ### Main-agent TDD merge gate (no human per task) The main agent gates each subagent's worktree merge with test-driven development: diff --git a/devague/cli/_commands/learn.py b/devague/cli/_commands/learn.py index 7b41164..e17b778 100644 --- a/devague/cli/_commands/learn.py +++ b/devague/cli/_commands/learn.py @@ -81,6 +81,18 @@ "This keeps file-contention safe: overlapping same-file changes " "surface as merge conflicts at reconcile time, not live races." ), + "worktree_location": ( + "Put every worktree under one repo-owned root beside the repo " + "directory: /.worktrees./agent-. " + "Resolve it, don't hardcode it — both steps, since an undefined " + 'repo_root silently yields the in-repo path "./.worktrees.": ' + 'repo_root=$(git rev-parse --show-toplevel) then wt_root="$(dirname ' + '"$repo_root")/.worktrees.$(basename "$repo_root")". A shared ' + "../worktrees/ collides across repos and plans (task ids restart at " + "t1) and looks deletable to anyone else cleaning up; an in-repo path " + "gets swept into your PR by git add -A and destroyed by git clean " + "-fdx. Remove only the worktrees you created — never the root itself." + ), "main_agent_merge_gate": ( "The main agent gates each subagent worktree merge with TDD: tests " "pass before AND after merge. No human per-task merge decision." @@ -204,6 +216,7 @@ f" Prerequisites: {ASSIGN_TO_WORKFORCE_GUIDANCE['prerequisites']}\n" f" Human gates (3): {ASSIGN_TO_WORKFORCE_GUIDANCE['human_gates']}\n" f" Safety: {ASSIGN_TO_WORKFORCE_GUIDANCE['worktree_isolation']}\n" + f" Worktree location: {ASSIGN_TO_WORKFORCE_GUIDANCE['worktree_location']}\n" f" Main agent: {ASSIGN_TO_WORKFORCE_GUIDANCE['main_agent_merge_gate']}\n" f" TDD: {ASSIGN_TO_WORKFORCE_GUIDANCE['tdd_acceptance_criteria']}\n" f" Scope: {ASSIGN_TO_WORKFORCE_GUIDANCE['not_orchestration']}\n" @@ -339,8 +352,10 @@ "leg": "plan -> parallel implementation", "role": ( "Reads 'devague plan waves' and fans out one agent per task per wave in " - "isolated git worktrees, with main-agent TDD-gated merges. Three human " - "gates; devague never orchestrates (#20)." + "isolated git worktrees, all under the repo-owned root " + "'/.worktrees./' (never a shared " + "'../worktrees/', never in-repo), with main-agent TDD-gated merges. " + "Three human gates; devague never orchestrates (#20)." ), "method_only": False, }, diff --git a/docs/assign-to-workforce-worked-example.md b/docs/assign-to-workforce-worked-example.md index 613cc55..e63744c 100644 --- a/docs/assign-to-workforce-worked-example.md +++ b/docs/assign-to-workforce-worked-example.md @@ -97,6 +97,16 @@ git worktree add .claude/worktrees/agent-t4 -b agent/t4 git worktree add .claude/worktrees/agent-t5 -b agent/t5 ``` +> **Note — the worktree path has since changed.** This run used an in-repo +> `.claude/worktrees/` path, recorded here as it actually happened. The current +> convention (see the `assign-to-workforce` skill and `CLAUDE.md`) puts every +> worktree under one repo-owned root *beside* the repo directory — +> `/.worktrees./agent-` — so that no other +> agent mistakes it for shared scratch, task ids that restart at `t1` can't +> collide across repos, and `git add -A` / `git clean -fdx` can't sweep live +> agent checkouts into a PR or delete them. Follow the skill, not these +> historical commands. + Each subagent received its task id, summary, and acceptance criteria as its brief. Each was instructed to work **test-first**: write the failing test(s) matching the acceptance criteria before implementing. diff --git a/docs/skills.md b/docs/skills.md index 20c918c..1ed2aa0 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -246,7 +246,12 @@ guidance carried verbatim to the workforce. Reads `devague plan waves` (deterministic, read-only scheduling metadata) and fans out independent tasks to **one agent per task per wave in isolated git worktrees**, with **main-agent TDD-gated merges** (the task's tests pass before -*and* after the merge). Exactly three human gates; the final PR uses the `cicd` +*and* after the merge). Every worktree lives under one repo-owned root beside +the repo directory — `/.worktrees./agent-` +— never a shared `../worktrees/` (which collides across repos and plans, since +task ids restart at `t1`, and reads as deletable scratch space to anyone else) +and never inside the repo (where `git add -A` sweeps checkouts into the PR and +`git clean -fdx` destroys live agent work). Exactly three human gates; the final PR uses the `cicd` skill (`agex pr open`). Each subagent's brief quotes its task's fields verbatim from `plan show --json` / the exported plan-md — including the per-task `instruction` — never an operator paraphrase. The `split-plan` subcommand diff --git a/pyproject.toml b/pyproject.toml index 045725b..e622e1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devague" -version = "0.20.0" +version = "0.20.1" description = "devague — turns a vague feature idea into a buildable spec, then a buildable plan." readme = "README.md" diff --git a/tests/test_cli_learn.py b/tests/test_cli_learn.py index 9c77c41..a642287 100644 --- a/tests/test_cli_learn.py +++ b/tests/test_cli_learn.py @@ -29,6 +29,43 @@ def test_learn_documents_assign_to_workforce_invocation( assert "worktree" in out +def test_learn_names_repo_owned_worktree_root( + capsys: pytest.CaptureFixture[str], +) -> None: + """`devague learn` teaches where fan-out worktrees live: one repo-owned root + beside the repo directory (`.worktrees.`), never a shared + `../worktrees/` (which collides across repos and reads as deletable scratch) + and never inside the repo (where `git add -A` / `git clean -fdx` reach it). + """ + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out + assert ".worktrees." in out + # The rationale must survive, not just the path: this is why the root is + # repo-owned rather than shared. + lowered = out.lower() + assert "../worktrees/" in lowered + assert "git add -a" in lowered or "in-repo" in lowered + + +def test_learn_worktree_snippet_is_self_contained( + capsys: pytest.CaptureFixture[str], +) -> None: + """The taught shell snippet must define `repo_root` before using it (#89). + + An operator copies this verbatim. With `repo_root` undefined the snippet + does not fail loudly — `$(dirname "")` is `.` and `$(basename "")` is + empty, so `wt_root` silently becomes `./.worktrees.`: a relative, in-repo + path with no repo name, i.e. exactly the layout this guidance forbids. + """ + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out + assert "wt_root=" in out, "guidance should teach the wt_root snippet" + # Wherever wt_root is derived from repo_root, repo_root must be defined. + assert "repo_root=$(git rev-parse --show-toplevel)" in out + + def test_learn_json_includes_assign_to_workforce_section( capsys: pytest.CaptureFixture[str], ) -> None: diff --git a/uv.lock b/uv.lock index 50deab6..e1fe3ce 100644 --- a/uv.lock +++ b/uv.lock @@ -183,7 +183,7 @@ wheels = [ [[package]] name = "devague" -version = "0.20.0" +version = "0.20.1" source = { editable = "." } [package.dev-dependencies]