worktree start picks its mode from where it runs; finish gains --keep and a dirty-primary refusal #844

Closed
opened 2026-09-30 14:24:55 +00:00 by jared · 3 comments
Owner

Problem

The implement skill always runs worktree start ticket-<n> and always finishes that worktree. worktree start refuses inside every linked worktree (start_command.rb:26, refuse_nested!). So a session that starts in its own ticket-<n> worktree (a failed run picked up) or in a persistent worktree stops at Setup. Work must never change main directly, but persistent worktrees meet that rule.

This ticket is the os plugin part: worktree start picks a mode from where it runs, and worktree finish gains --keep and a dirty-primary refusal. The os-sdlc part (open places the worktree, payload, skill text) is a separate ticket blocked by this one. ADR-0253 records the design.

Reproduce

From inside a linked worktree: worktree start ticket-844 exits 1 with "cwd is inside a worktree — refusing to nest; run start from the main checkout".

Approach

StartCommand#run replaces the blanket nesting refusal with mode rules. Reused worktrees are checked for ADR-0143 links and brought up to the base branch by merge (fast-forward when possible), never rebase. Base is the primary checkout's current branch, as FinishCommand#base_branch defines it. FinishCommand gains a dirty-primary refusal (exit 6, free after #842) and --keep. Keep one StartCommand with private mode methods and one result object; do not add a class per mode.

Required work:

  1. worktree start NAME from the primary checkout with no NAME worktree creates it as today and prints mode: ticket-new and finish: worktree finish NAME — check: grep -rn "ticket-new" plugins/os/lib/worktree
  2. From the primary checkout with NAME already registered in git worktree list --porcelain, start reuses it: mode: ticket-continue, no git worktree add.
  3. From the primary checkout with branch NAME present but no worktree registered, start exits 1 and names the branch.
  4. Inside the linked worktree whose branch is NAME, start prints mode: ticket-continue and that worktree's path.
  5. Inside a linked worktree whose branch starts with ticket- and is not NAME, start exits 1 and the message names both branches.
  6. Inside any other linked worktree, start prints mode: persistent, branch: the worktree's branch, and finish: worktree finish <branch> --keep.
  7. For ticket-continue and persistent, start refuses (exit 1) unless .cc-os and .sdlc/pipeline.db in the worktree are symlinks to the primary checkout's copies; the message names the entry. It never repairs them.
  8. For ticket-continue and persistent, start runs git merge --ff-only <base> in the worktree, else git merge --no-edit <base>. It never rebases. On conflict it exits 1, prints Merge conflicts in: <files>, and leaves the merge in progress. With MERGE_HEAD already present it exits 1 with "merge in progress" and does not merge again — check: ! grep -rn "rebase" plugins/os/lib/worktree
  9. start output adds mode: and finish: lines to the existing worktree:, branch:, path:, linked:, next: lines.
  10. finish exits 6 before any merge when the primary checkout has uncommitted changes to tracked files. Untracked files do not trigger it. This applies with and without --keep.
  11. finish BRANCH --keep runs the same refusals, merge, ancestry check, and AfterMerge, then runs git merge --ff-only <base> in the worktree, and keeps the worktree and branch. It prints "merged BRANCH into ; worktree and branch kept" — check: grep -n "method_option :keep" plugins/os/lib/worktree/cli.rb
  12. In ticket-continue and persistent modes, path: is the worktree's registered path from git worktree list --porcelain, not Layout#worktree_path; a persistent worktree can live outside .claude/worktrees/.
  13. next: prints EnterWorktree(path: "<path>") only when the cwd is not already that worktree; otherwise it prints next: none (cwd is the worktree).
  14. The dirty-primary check reads git status --porcelain --untracked-files=no — check: grep -rn "untracked-files=no" plugins/os/lib/worktree

Documentation, done by the orchestrator, not by a pipeline agent:

  • ADR-0253 (amends ADR-0151, ADR-0157) records the design.
  • Rewrite plugins/os/skills/worktree/SKILL.md start and finish sections for the modes, --keep, exit 6, and the conflict path (/mattpocock-skills:resolving-merge-conflicts).
  • Update plugins/os/invariants.md items 17-20, plugins/os/CLAUDE.md lines 26-27 ("nesting guard"), and docs/implementation-status/os.md (line 49 "refuses to nest", the exit list on line 51); add one History row.
  • Run bin/refresh-plugins after the change.

Rejected: rebase reused branches (rewrites shared history; finish exit 2 already prescribes a merge); repair unlinked worktrees (could replace a non-empty directory); separate sync/status/integrate subcommands (one command and one flag suffice).

Acceptance criteria

  1. Given the primary checkout and no ticket-9 worktree, when worktree start ticket-9 runs, then the worktree is created as today and stdout has mode: ticket-new and finish: worktree finish ticket-9.
  2. Given the primary checkout and a registered ticket-9 worktree, when worktree start ticket-9 runs, then it exits 0, creates nothing, and stdout has mode: ticket-continue and that worktree's path.
  3. Given the primary checkout and a ticket-9 branch with no worktree, when worktree start ticket-9 runs, then it exits 1 and stderr names ticket-9.
  4. Given cwd inside linked worktree ticket-9, when worktree start ticket-9 runs, then it exits 0, creates nothing, and stdout has mode: ticket-continue.
  5. Given cwd inside linked worktree ticket-8, when worktree start ticket-9 runs, then it exits 1 and stderr names ticket-8 and ticket-9.
  6. Given cwd inside a linked worktree on branch p, created with git worktree add outside .claude/worktrees/, with base one commit ahead and no commits of its own, when worktree start ticket-9 runs, then p fast-forwards to base, and stdout has mode: persistent, branch: p, path: that worktree's registered path, and finish: worktree finish p --keep.
  7. Given cwd inside linked worktree on branch p with its own commit and base ahead, when worktree start ticket-9 runs, then base is merged into p with a merge commit and no commit on p is rewritten.
  8. Given the merge of base into p conflicts, when worktree start ticket-9 runs, then it exits 1, stderr lists the conflicted files, and MERGE_HEAD exists.
  9. Given MERGE_HEAD exists in worktree p, when worktree start ticket-9 runs, then it exits 1 with "merge in progress" and HEAD does not change.
  10. Given cwd inside linked worktree p whose .cc-os is a directory, not a symlink, when worktree start ticket-9 runs, then it exits 1, stderr names .cc-os, and the directory is unchanged.
  11. Given the primary checkout has an uncommitted change to a tracked file, when worktree finish ticket-9 runs, then it exits 6 before any merge.
  12. Given the primary checkout has only untracked files, when worktree finish ticket-9 runs, then it merges and removes as today.
  13. Given worktree p has a commit not on base, when worktree finish p --keep runs, then base contains the commit, p equals base, and the worktree directory and branch p still exist.
  14. Given cwd inside linked worktree ticket-9, when worktree start ticket-9 runs, then stdout has next: none (cwd is the worktree).

Retired tests

plugins/os/tests/worktree/start_command_refuses_nested_test.rb

Owner sketch

module Worktree
  class StartCommand
    def run
      return create_or_reuse unless @repo_root.inside_worktree?

      refuse_unlinked!
      return reuse(mode: 'ticket-continue') if here.branch == @name
      raise Error, "session is in #{here.branch}; refusing to start #{@name}" if here.branch.start_with?('ticket-')

      reuse(mode: 'persistent')
    end
  end

  class FinishCommand
    def run
      return refuse_dirty_primary if primary_dirty?
      # ... existing refusals, merge, verify_ancestry!, @after_merge.call
      return keep_aligned if @keep
      # ... remove_worktree
    end
  end
end

Origin

  • Trigger: user proposal during the #836 hand-finish, 2026-09-30
  • Improvised this session: none
  • Chain: implement skill always creates and finishes a ticket worktree ← Setup and Finish steps in plugins/os-sdlc/skills/implement/SKILL.md ← DESIGN: ADR-0157 (worktree CLI sole creator)
  • Root candidate: this ticket
  • Where: plugins/os-sdlc/skills/implement/SKILL.md (Setup, Finish); plugins/os/bin/worktree; plugins/os/lib/worktree/
  • Session: 3ce2d004-fe35-42ec-ba9a-b9bc2d1e9097
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/3ce2d004-fe35-42ec-ba9a-b9bc2d1e9097.jsonl

Related: #842, which removes --implementation and the before-remove hook from worktree finish.

Blocked by: #842 (both change finish_command.rb and exit numbering).

VERDICT: CREATE — user-requested hard floor; ADR-0253 records the decision (ticket-skeptic, 2026-09-30).

## Problem The implement skill always runs `worktree start ticket-<n>` and always finishes that worktree. `worktree start` refuses inside every linked worktree (`start_command.rb:26`, `refuse_nested!`). So a session that starts in its own `ticket-<n>` worktree (a failed run picked up) or in a persistent worktree stops at Setup. Work must never change main directly, but persistent worktrees meet that rule. This ticket is the `os` plugin part: `worktree start` picks a mode from where it runs, and `worktree finish` gains `--keep` and a dirty-primary refusal. The os-sdlc part (open places the worktree, payload, skill text) is a separate ticket blocked by this one. ADR-0253 records the design. ## Reproduce From inside a linked worktree: `worktree start ticket-844` exits 1 with "cwd is inside a worktree — refusing to nest; run start from the main checkout". ## Approach `StartCommand#run` replaces the blanket nesting refusal with mode rules. Reused worktrees are checked for ADR-0143 links and brought up to the base branch by merge (fast-forward when possible), never rebase. Base is the primary checkout's current branch, as `FinishCommand#base_branch` defines it. `FinishCommand` gains a dirty-primary refusal (exit 6, free after #842) and `--keep`. Keep one `StartCommand` with private mode methods and one result object; do not add a class per mode. Required work: 1. `worktree start NAME` from the primary checkout with no NAME worktree creates it as today and prints `mode: ticket-new` and `finish: worktree finish NAME` — check: `grep -rn "ticket-new" plugins/os/lib/worktree` 2. From the primary checkout with NAME already registered in `git worktree list --porcelain`, `start` reuses it: `mode: ticket-continue`, no `git worktree add`. 3. From the primary checkout with branch NAME present but no worktree registered, `start` exits 1 and names the branch. 4. Inside the linked worktree whose branch is NAME, `start` prints `mode: ticket-continue` and that worktree's path. 5. Inside a linked worktree whose branch starts with `ticket-` and is not NAME, `start` exits 1 and the message names both branches. 6. Inside any other linked worktree, `start` prints `mode: persistent`, `branch:` the worktree's branch, and `finish: worktree finish <branch> --keep`. 7. For ticket-continue and persistent, `start` refuses (exit 1) unless `.cc-os` and `.sdlc/pipeline.db` in the worktree are symlinks to the primary checkout's copies; the message names the entry. It never repairs them. 8. For ticket-continue and persistent, `start` runs `git merge --ff-only <base>` in the worktree, else `git merge --no-edit <base>`. It never rebases. On conflict it exits 1, prints `Merge conflicts in: <files>`, and leaves the merge in progress. With `MERGE_HEAD` already present it exits 1 with "merge in progress" and does not merge again — check: `! grep -rn "rebase" plugins/os/lib/worktree` 9. `start` output adds `mode:` and `finish:` lines to the existing `worktree:`, `branch:`, `path:`, `linked:`, `next:` lines. 10. `finish` exits 6 before any merge when the primary checkout has uncommitted changes to tracked files. Untracked files do not trigger it. This applies with and without `--keep`. 11. `finish BRANCH --keep` runs the same refusals, merge, ancestry check, and AfterMerge, then runs `git merge --ff-only <base>` in the worktree, and keeps the worktree and branch. It prints "merged BRANCH into <base>; worktree and branch kept" — check: `grep -n "method_option :keep" plugins/os/lib/worktree/cli.rb` 12. In ticket-continue and persistent modes, `path:` is the worktree's registered path from `git worktree list --porcelain`, not `Layout#worktree_path`; a persistent worktree can live outside `.claude/worktrees/`. 13. `next:` prints `EnterWorktree(path: "<path>")` only when the cwd is not already that worktree; otherwise it prints `next: none (cwd is the worktree)`. 14. The dirty-primary check reads `git status --porcelain --untracked-files=no` — check: `grep -rn "untracked-files=no" plugins/os/lib/worktree` Documentation, done by the orchestrator, not by a pipeline agent: - ADR-0253 (amends ADR-0151, ADR-0157) records the design. - Rewrite `plugins/os/skills/worktree/SKILL.md` start and finish sections for the modes, `--keep`, exit 6, and the conflict path (`/mattpocock-skills:resolving-merge-conflicts`). - Update `plugins/os/invariants.md` items 17-20, `plugins/os/CLAUDE.md` lines 26-27 ("nesting guard"), and `docs/implementation-status/os.md` (line 49 "refuses to nest", the exit list on line 51); add one History row. - Run `bin/refresh-plugins` after the change. Rejected: rebase reused branches (rewrites shared history; finish exit 2 already prescribes a merge); repair unlinked worktrees (could replace a non-empty directory); separate `sync`/`status`/`integrate` subcommands (one command and one flag suffice). ## Acceptance criteria 1. Given the primary checkout and no `ticket-9` worktree, when `worktree start ticket-9` runs, then the worktree is created as today and stdout has `mode: ticket-new` and `finish: worktree finish ticket-9`. 2. Given the primary checkout and a registered `ticket-9` worktree, when `worktree start ticket-9` runs, then it exits 0, creates nothing, and stdout has `mode: ticket-continue` and that worktree's path. 3. Given the primary checkout and a `ticket-9` branch with no worktree, when `worktree start ticket-9` runs, then it exits 1 and stderr names `ticket-9`. 4. Given cwd inside linked worktree `ticket-9`, when `worktree start ticket-9` runs, then it exits 0, creates nothing, and stdout has `mode: ticket-continue`. 5. Given cwd inside linked worktree `ticket-8`, when `worktree start ticket-9` runs, then it exits 1 and stderr names `ticket-8` and `ticket-9`. 6. Given cwd inside a linked worktree on branch `p`, created with `git worktree add` outside `.claude/worktrees/`, with base one commit ahead and no commits of its own, when `worktree start ticket-9` runs, then `p` fast-forwards to base, and stdout has `mode: persistent`, `branch: p`, `path:` that worktree's registered path, and `finish: worktree finish p --keep`. 7. Given cwd inside linked worktree on branch `p` with its own commit and base ahead, when `worktree start ticket-9` runs, then base is merged into `p` with a merge commit and no commit on `p` is rewritten. 8. Given the merge of base into `p` conflicts, when `worktree start ticket-9` runs, then it exits 1, stderr lists the conflicted files, and `MERGE_HEAD` exists. 9. Given `MERGE_HEAD` exists in worktree `p`, when `worktree start ticket-9` runs, then it exits 1 with "merge in progress" and HEAD does not change. 10. Given cwd inside linked worktree `p` whose `.cc-os` is a directory, not a symlink, when `worktree start ticket-9` runs, then it exits 1, stderr names `.cc-os`, and the directory is unchanged. 11. Given the primary checkout has an uncommitted change to a tracked file, when `worktree finish ticket-9` runs, then it exits 6 before any merge. 12. Given the primary checkout has only untracked files, when `worktree finish ticket-9` runs, then it merges and removes as today. 13. Given worktree `p` has a commit not on base, when `worktree finish p --keep` runs, then base contains the commit, `p` equals base, and the worktree directory and branch `p` still exist. 14. Given cwd inside linked worktree `ticket-9`, when `worktree start ticket-9` runs, then stdout has `next: none (cwd is the worktree)`. ## Retired tests plugins/os/tests/worktree/start_command_refuses_nested_test.rb ## Owner sketch ```ruby module Worktree class StartCommand def run return create_or_reuse unless @repo_root.inside_worktree? refuse_unlinked! return reuse(mode: 'ticket-continue') if here.branch == @name raise Error, "session is in #{here.branch}; refusing to start #{@name}" if here.branch.start_with?('ticket-') reuse(mode: 'persistent') end end class FinishCommand def run return refuse_dirty_primary if primary_dirty? # ... existing refusals, merge, verify_ancestry!, @after_merge.call return keep_aligned if @keep # ... remove_worktree end end end ``` ## Origin - Trigger: user proposal during the #836 hand-finish, 2026-09-30 - Improvised this session: none - Chain: implement skill always creates and finishes a ticket worktree ← Setup and Finish steps in `plugins/os-sdlc/skills/implement/SKILL.md` ← DESIGN: ADR-0157 (worktree CLI sole creator) - Root candidate: this ticket - Where: plugins/os-sdlc/skills/implement/SKILL.md (Setup, Finish); plugins/os/bin/worktree; plugins/os/lib/worktree/ - Session: 3ce2d004-fe35-42ec-ba9a-b9bc2d1e9097 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/3ce2d004-fe35-42ec-ba9a-b9bc2d1e9097.jsonl Related: #842, which removes `--implementation` and the before-remove hook from `worktree finish`. Blocked by: #842 (both change `finish_command.rb` and exit numbering). VERDICT: CREATE — user-requested hard floor; ADR-0253 records the decision (ticket-skeptic, 2026-09-30).
Author
Owner

User rulings 2026-09-30:

  1. Record a new ADR for merging a persistent worktree into main.
  2. Conflict resolution on the persistent path uses /mattpocock-skills:resolving-merge-conflicts.
  3. Work #842 and #844 in the same session. #842 first, because both change the Finish step.
User rulings 2026-09-30: 1. Record a new ADR for merging a persistent worktree into main. 2. Conflict resolution on the persistent path uses /mattpocock-skills:resolving-merge-conflicts. 3. Work #842 and #844 in the same session. #842 first, because both change the Finish step.
jared changed title from Pick the implement worktree mode from the start state: ticket, persistent, or main to worktree start picks its mode from where it runs; finish gains --keep and a dirty-primary refusal 2026-09-30 16:47:40 +00:00
Author
Owner

Work started on branch ticket-844 (worktree .claude/worktrees/ticket-844) via /os-sdlc:implement. #842 merged at 6cd75d59.

Work started on branch ticket-844 (worktree .claude/worktrees/ticket-844) via /os-sdlc:implement. #842 merged at 6cd75d59.
Author
Owner

Resolution

Done: worktree start picks its mode from where it runs: ticket-new, ticket-continue (from main checkout or inside), refuse other ticket-*, persistent; reused worktrees must be linked (never repaired) and are brought up to base by ff-only else merge, never rebase; output adds mode:/finish:, next: is none when cwd is the worktree. finish refuses a dirty main checkout (exit 6); finish --keep merges, fast-forwards the branch, keeps worktree and branch. Registry and Checkout objects own lookup and worktree-side git. os:worktree skill, invariants 19-21, os CLAUDE.md, and status leaf updated.

Evidence: Runner commit 6b5c6a72 (implementation 9, implementation_complete); docs commit 9ac00e4a. ruby plugins/os/tests/all.rb: 35 runs, 0 failures, stable over 5 parallel runs after the WorktreeScenario capture fix. Required-work checks 1/8/11/14 verified by hand. ADR-0253.

Follow-ups: Two follow-ups captured separately via /os-backlog:capture: (1) worktree guard blocks agents without Write from submitting handoffs containing the word git; (2) finish --keep exits 1 with no message when the worktree fast-forward fails. #846 (os-sdlc part) is now unblocked.

## Resolution **Done:** worktree start picks its mode from where it runs: ticket-new, ticket-continue (from main checkout or inside), refuse other ticket-*, persistent; reused worktrees must be linked (never repaired) and are brought up to base by ff-only else merge, never rebase; output adds mode:/finish:, next: is none when cwd is the worktree. finish refuses a dirty main checkout (exit 6); finish --keep merges, fast-forwards the branch, keeps worktree and branch. Registry and Checkout objects own lookup and worktree-side git. os:worktree skill, invariants 19-21, os CLAUDE.md, and status leaf updated. **Evidence:** Runner commit 6b5c6a72 (implementation 9, implementation_complete); docs commit 9ac00e4a. ruby plugins/os/tests/all.rb: 35 runs, 0 failures, stable over 5 parallel runs after the WorktreeScenario capture fix. Required-work checks 1/8/11/14 verified by hand. ADR-0253. **Follow-ups:** Two follow-ups captured separately via /os-backlog:capture: (1) worktree guard blocks agents without Write from submitting handoffs containing the word git; (2) finish --keep exits 1 with no message when the worktree fast-forward fails. #846 (os-sdlc part) is now unblocked.
jared closed this issue 2026-09-30 17:20:47 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#844
No description provided.