From 4613ef22b76ac5d91c0ba6e266f9005631cc8c9f Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 14 Jun 2026 14:24:26 -0700 Subject: [PATCH 1/2] fix(plugin): route /wt-switch-create cross-repo tasks to a rooted session A session re-roots only within its own repo: EnterWorktree validates the target against the current repo (the `LI6` path validator and its `requireManagedLocation` gating, verified against the 2.1.177 binary). So a worktree created in another repo could never be entered, leaving the session in the degraded absolute-paths mode with every `cd` reset. Step 2 now settles the repo before creating anything. A cross-repo task is routed to a session *started* in the target repo (the only thing that re-roots), or worked in place via `/add-dir`, rather than creating an unenterable worktree; creation drops `-C` and is same-repo only. The re-root fallback is rescoped to the unexpected same-repo rejection (already-in-worktree session, or pinned agent). rationale.md documents the verified mechanism: the two path-entry checks and the session-state flag that selects them, repo-derived-from-cwd, and the separate cd working-directory axis (`/add-dir` enlarges it). docs/content/claude-code.md drops the stale "a path-shaped token names a different repository" claim; reference mirror synced. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/claude-code.md | 2 +- skills/worktrunk/reference/claude-code.md | 2 +- skills/wt-switch-create/SKILL.md | 57 +++++--- skills/wt-switch-create/rationale.md | 157 ++++++++++++++-------- 4 files changed, 144 insertions(+), 74 deletions(-) diff --git a/docs/content/claude-code.md b/docs/content/claude-code.md index 3439a1a5a1..cff0fb16af 100644 --- a/docs/content/claude-code.md +++ b/docs/content/claude-code.md @@ -101,7 +101,7 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d ## `/wt-switch-create` command (Claude Code only) -`/wt-switch-create [] [] [-- ]` starts work in a fresh worktree without leaving the session. It creates (or re-enters) a worktrunk worktree with `wt switch`, switches the session's working directory into it, then runs the task there. Every argument is optional: a missing branch name is picked from the task, a path-shaped token names a different repository, and the task is whatever follows `--` (or the trailing words, when there is no `--`). The worktree is a normal worktrunk worktree — it persists after the session and is merged or removed with `wt merge` / `wt remove` like any other. +`/wt-switch-create [] [] [-- ]` starts work in a fresh worktree without leaving the session. It creates (or re-enters) a worktrunk worktree with `wt switch`, switches the session's working directory into it, then runs the task there. The branch name is optional (picked from the task when omitted), as is the task (whatever follows `--`, or the trailing words when there is no `--`). A path-shaped token names a different repo the task belongs in; since a session re-roots only within its own repo, the skill hands such a task to a session rooted in that repo rather than switching this one in. The worktree is a normal worktrunk worktree — it persists after the session and is merged or removed with `wt merge` / `wt remove` like any other. ## Statusline (Claude Code only) diff --git a/skills/worktrunk/reference/claude-code.md b/skills/worktrunk/reference/claude-code.md index f13b9f7c79..e6c8bc8c21 100644 --- a/skills/worktrunk/reference/claude-code.md +++ b/skills/worktrunk/reference/claude-code.md @@ -103,7 +103,7 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d ## `/wt-switch-create` command (Claude Code only) -`/wt-switch-create [] [] [-- ]` starts work in a fresh worktree without leaving the session. It creates (or re-enters) a worktrunk worktree with `wt switch`, switches the session's working directory into it, then runs the task there. Every argument is optional: a missing branch name is picked from the task, a path-shaped token names a different repository, and the task is whatever follows `--` (or the trailing words, when there is no `--`). The worktree is a normal worktrunk worktree — it persists after the session and is merged or removed with `wt merge` / `wt remove` like any other. +`/wt-switch-create [] [] [-- ]` starts work in a fresh worktree without leaving the session. It creates (or re-enters) a worktrunk worktree with `wt switch`, switches the session's working directory into it, then runs the task there. The branch name is optional (picked from the task when omitted), as is the task (whatever follows `--`, or the trailing words when there is no `--`). A path-shaped token names a different repo the task belongs in; since a session re-roots only within its own repo, the skill hands such a task to a session rooted in that repo rather than switching this one in. The worktree is a normal worktrunk worktree — it persists after the session and is merged or removed with `wt merge` / `wt remove` like any other. ## Statusline (Claude Code only) diff --git a/skills/wt-switch-create/SKILL.md b/skills/wt-switch-create/SKILL.md index 43212138d0..349489588c 100644 --- a/skills/wt-switch-create/SKILL.md +++ b/skills/wt-switch-create/SKILL.md @@ -1,6 +1,6 @@ --- name: wt-switch-create -description: Create a new worktrunk worktree (optionally in another repo) and switch this session's working directory into it. The branch name is optional — one is picked from the task when omitted. Use when launching a session that should work in its own worktree (e.g. `/wt-switch-create -- `, `/wt-switch-create my-branch -- `, or `/wt-switch-create my-branch ~/workspace/other-repo -- `), or mid-session to move work into a fresh branch. +description: Create a new worktrunk worktree in this repo and switch this session's working directory into it. The branch name is optional — one is picked from the task when omitted. Use when launching a session that should work in its own worktree (e.g. `/wt-switch-create -- ` or `/wt-switch-create my-branch -- `), or mid-session to move work into a fresh branch. A task in a different repo can't re-root this session — the skill points it to a session rooted in that repo. argument-hint: "[] [] [-- ]" license: MIT OR Apache-2.0 compatibility: Requires the `wt` CLI (https://worktrunk.dev) @@ -10,8 +10,9 @@ Arguments: `$ARGUMENTS`. Grammar: `[] [] [-- ]`. - **branch** — optional; the branch name for the new worktree. When omitted, pick one (step 1 below). -- **repo** — optional path; create the worktree in this repo instead of the - session's current one. +- **repo** — optional path naming a *different* repo the task belongs in. This + session can't re-root into another repo, so the skill hands the task to a + session rooted there rather than creating a worktree here (step 2). - **task** — optional; what to do inside the new worktree. No task means enter the worktree and wait. @@ -33,7 +34,7 @@ branch-shaped lead — all task). ## What to do -Steps 1–3 come before any other work. +Steps 1–4 come before any other work. @@ -43,11 +44,29 @@ rationale.md (same directory) — read it before re-adding guards or routes. --> names, or, mid-session, from the work being moved; with nothing to derive from, ask. -2. **Create the worktree** with a `Bash` call (omit `-C ` when no repo - was given): +2. **Confirm the task belongs in this repo.** This command re-roots *this* + session, and a session can only re-root within its own repo: the harness + rejects any worktree outside it. So a worktree created in another repo can + never become this session's cwd, and the task would limp along through + absolute paths with every `cd` reset back. + + When the task belongs in another repo (a repo path was given, or the work is + plainly about a different repo), don't create a worktree here — this session + can't re-root into another repo. Match the response to the work: + + - **Substantial work** wants a session *rooted* in that repo, which only a + session *started* there can be: launch one whose working directory is the + target repo and let it run this skill (now same-repo) as its first step; + name the repo, then stop. + - **Work in this conversation**: have the user run `/add-dir ` (a + user-typed command — the agent can't run it), which makes `cd` into that + repo persist so commands run there cleanly without re-rooting. A quick read + needs even less: `git -C ` / `gh` from here, no worktree. + +3. **Create the worktree** with a `Bash` call: ``` - wt -C switch --create --no-cd --format=json + wt switch --create --no-cd --format=json ``` Stdout is JSON whose `path` field is the worktree's absolute path (status @@ -65,17 +84,17 @@ rationale.md (same directory) — read it before re-adding guards or routes. --> carry it across: `git stash push -u` before creating the worktree, then `git -C stash pop` after (the stash is shared across worktrees). -3. **Re-root the session** with `EnterWorktree({path: ""})`. +4. **Re-root the session** with `EnterWorktree({path: ""})`. - If it is rejected (worktree in a different repo, session already in a - worktree, pinned cwd — the rejections are graceful and create nothing), - leave the session rooted where it is and work in the worktree through - absolute paths instead; name the worktree path when reporting back. Don't - try to `cd` there: the harness resets `cd` that leaves the session's - working directories, and `EnterWorktree` is the supported way to move a - session. + A same-repo worktree is accepted on first entry, sibling layout included. A + rejection is the unexpected case: the session already entered a worktree via + `EnterWorktree`, or it's a pinned agent restricted to `.claude/worktrees/`. + The rejection is graceful and creates nothing; then work in the worktree + through absolute paths and name its path when reporting back. Don't try to + `cd` there: the harness resets `cd` that leaves the session's working + directories, and `EnterWorktree` is the supported way to move a session. -4. **Do the task** in the worktree. If there was no task text, confirm the +5. **Do the task** in the worktree. If there was no task text, confirm the worktree is ready and wait for the next instruction. ## Cleanup @@ -89,6 +108,6 @@ entered by `path`, so removal is always `wt remove `. ## Scope -This command authorizes creating/entering ONE worktree — in the named repo, if -one was given — and doing the requested task. Commits, pushes, and merges still -each require explicit user permission. +This command authorizes creating/entering ONE worktree in this repo and doing +the requested task. Commits, pushes, and merges still each require explicit user +permission. diff --git a/skills/wt-switch-create/rationale.md b/skills/wt-switch-create/rationale.md index 54877f3f6b..d456eb6d36 100644 --- a/skills/wt-switch-create/rationale.md +++ b/skills/wt-switch-create/rationale.md @@ -1,24 +1,31 @@ # wt-switch-create design rationale -Why the skill is one route (`wt` in Bash → `EnterWorktree({path})`) plus one -error-driven fallback, and not the guard-heavy multi-route flow it replaced. -Every claim here was verified against primary sources on 2026-06-11: -Claude Code 2.1.173 (binary inspection + live tool calls + official docs at -code.claude.com/docs) and wt v0.57.0-16-g371d28662 (live runs in a scratch -repo). Re-verify against current versions before relying on a specific -behavior; the *shape* of the argument should outlive the details. +Why the skill is one same-repo route (`wt` in Bash → `EnterWorktree({path})`) +with a single up-front split for cross-repo tasks and an error-driven fallback, +and not the guard-heavy multi-route flow it replaced. +Every claim here was verified against primary sources (2026-06-11 to +2026-06-14): Claude Code 2.1.173, the path-entry logic re-confirmed against the +2.1.177 binary (`EnterWorktree`'s `LI6` validator and its call sites) plus live +tool calls and official docs at code.claude.com/docs; and wt +v0.57.0-16-g371d28662 (live runs in a scratch repo). Re-verify against current +versions before relying on a specific behavior; the *shape* of the argument +should outlive the details. ## The design -1. Create the worktree with `wt -C switch --create --no-cd - --format=json` in Bash. `wt` already solves repo targeting (`-C`), - existing-branch handling (rerun without `--create`), and machine-readable - output (`.path` on stdout, status on stderr). -2. Re-root the session with `EnterWorktree({path})` — the only supported way +1. Settle the repo before creating anything. A session re-roots only within + its own repo, so a task in another repo can't be entered from here; hand it + to a session rooted in that repo. The rest assumes a same-repo task. +2. Create the worktree with `wt switch --create --no-cd --format=json` + in Bash. `wt` already solves existing-branch handling (rerun without + `--create`) and machine-readable output (`.path` on stdout, status on + stderr). +3. Re-root the session with `EnterWorktree({path})` — the only supported way to move a Claude Code session's root. -3. If (2) is rejected, work in the worktree via absolute paths. Every - rejection is a graceful, side-effect-free error, so one attempt plus a - fallback covers every environment without predicting which one we're in. +4. If (3) is rejected (a same-repo edge case: already in a worktree session, or + a pinned agent), work in the worktree via absolute paths. The rejection is + graceful and side-effect-free, so attempt-then-fallback covers it without + prediction. ## wt CLI and git behavior (all live-tested) @@ -32,26 +39,40 @@ behavior; the *shape* of the argument should outlive the details. - Every `--format=json` variant carries `path` (absolute). Only the JSON goes to stdout; all human-readable status, including hook output, goes to stderr — which is what makes `.path` extraction safe. -- `wt -C ` works from an unrelated cwd, so the skill never needs `cd` - to create a worktree in another repo. - `wt remove`: dirty worktree → refuses (exit 1, hints `--force`); clean but unmerged commits → removes the worktree, keeps the branch, hints `wt remove -D`; clean and merged → removes worktree and branch. - The git stash is per-repo, shared across worktrees: `git stash push -u` in one worktree pops cleanly in another via `git -C stash pop`, - untracked files included — the mid-session carry-across in step 2. + untracked files included — the mid-session carry-across in step 3. ## Claude Code behavior ### `EnterWorktree({path})` accepts worktrunk's sibling layout (load-bearing) -On first entry from a session not already in a worktree, the path may be ANY -worktree registered in `git worktree list` of the current repo — there is no -`.claude/worktrees/` restriction. Binary: the managed-location check is -conditional (`requireManagedLocation: `); the -restriction applies only when switching from inside a worktree session or -from a pinned agent. Live-tested: a background session entered -`projects.ew-path-test` (sibling layout) by path. +The path validator (`LI6` in the 2.1.177 binary) derives the repo from the cwd +(`T = h$(u_())`; no repo argument), and applies a `requireManagedLocation` flag +the *caller* sets from session state: `requireManagedLocation: q != null`, where +`q` is the active worktree session (`z$()`). Pinned agents force it `true`. That +one flag picks which check runs, which is the whole reason the two cross-repo +rejections read differently: + +- **`false`** (plain session, no active worktree session): the target must be in + `git worktree list` of the cwd's repo, else *"is not a registered worktree of + "*. Sibling layout passes — a fresh session enters its sibling worktree + by path. +- **`true`** (already in a worktree session, or a pinned agent): the target must + be under `/.claude/worktrees/` (`_F_ = join(repo, ".claude", + "worktrees")`), else *"not under …/.claude/worktrees … managed by Claude + Code"*. Sibling layout fails — a worktree session can't re-enter a worktrunk + sibling worktree. + +worktrunk's `WorktreeCreate` hook writes the sibling layout but never meets this +check: the hook runs only on create (`{name}`), the validator only on entry +(`{path}`). Both branches reject cross-repo regardless — no cwd has a worktree +of another repo in its `git worktree list`, and `.claude/worktrees/` is +per-repo. All four cells (plain / worktree-session × sibling / cross-repo), plus +the no-repo refusal, reproduced live against 2.1.177. ### Every rejection is graceful and side-effect-free @@ -74,18 +95,29 @@ Binary-confirmed (not live-run): pinned agents (subagent `isolation: their `path` entry is restricted to `.claude/worktrees/`, so sibling-layout worktrees are rejected there too — same fallback applies. -### `cd` is not a re-rooting mechanism - -The harness resets a `cd` that lands outside the session's working -directories, appending `Shell cwd was reset to ` to the result — -reproduced twice. Within the working directories `cd` persists and -`EnterWorktree` *does* read the moved cwd (live: after `cd /tmp`, -`EnterWorktree({path})` failed with "the current directory is not in a git -repository"). So a target repo outside the working directories can never be -reached by `cd`, which is exactly the case where a repo argument matters. -The earlier revision's "some sessions pin the cwd" was a misdiagnosis of -this reset; subagent threads additionally reset cwd between every Bash call -(binary: "Agent threads always have their cwd reset between bash calls"). +### `cd` is a separate axis from re-root, gated by working-directory membership + +Re-root (`EnterWorktree`) is scoped by repo, above. `cd` persistence is a +different gate: the session carries its primary cwd plus an +`additionalWorkingDirectories` set, and the harness resets any `cd` that lands +outside it, appending `Shell cwd was reset to ` to the result. +Reproduced both ways against 2.1.177: `cd /tmp` persisted (a working directory), +while `cd` into a sibling worktree or another repo reset. + +So `cd` succeeds anywhere inside the working-directory set and never re-roots; +it only resets when stepping outside. `/add-dir ` (or the +`additionalDirectories` setting) enlarges the set, which makes `cd` into another +repo's worktree persist — the fix for *working* cross-repo in place, distinct +from re-rooting (the session's repo identity is unchanged either way). `/add-dir` +is a user-typed command, not an agent tool, so the agent can't enlarge the set +itself. + +`EnterWorktree` reads the moved cwd: after `cd /tmp`, `EnterWorktree({path})` +failed with "the current directory is not in a git repository" (the no-repo +branch above). The earlier revision's "some sessions pin the cwd" was a +misdiagnosis of this reset; subagent threads additionally reset cwd between every +Bash call (binary: "Agent threads always have their cwd reset between bash +calls"). ### Why not `EnterWorktree({name})` + the WorktreeCreate hook @@ -98,10 +130,7 @@ worktrees: git fallback (binary: "Other exit codes - worktree creation failed"; docs: "the hook replaces the default git behavior"). That forced a second route for existing branches; `path` entry needs no second route. -2. **No repo targeting.** `EnterWorktree({name})` creates the worktree - wherever the session is rooted; combined with the `cd` reset above, the - name route can never reach another repo. -3. **Wrong exit-time semantics for durable worktrees.** Worktrees created by +2. **Wrong exit-time semantics for durable worktrees.** Worktrees created by `name` are tracked for session-exit cleanup: when the session has no user-set title, a clean worktree (no changed files, no new commits) is *silently auto-removed* at exit (binary: auto-remove iff zero changes, @@ -109,19 +138,31 @@ worktrees: changes)"). A worktrunk worktree the user asked for should outlive the session (`wt list`, later `wt merge`). Path-entered worktrees get exactly that: left in place, no prompt ("worktree at left in place"). -4. **Hook contract details leak into the skill.** stdout's last non-empty +3. **Hook contract details leak into the skill.** stdout's last non-empty line must be an existing directory, etc. — irrelevant when the skill reads `.path` from `wt --format=json` directly. -### Why no pre-emptive guards +### One guard (cross-repo), and no others + +Guard a case when the check is cheap and deterministic *and* the fallback is +worse, not merely different. Try-then-fallback when the state is opaque *and* +the fallback is fine. The removed guards and the kept one split exactly on +this. The old flow opened with "if already inside a worktree, reuse it", a `cd`-then-`pwd` check, and a pinned-cwd branch — each a workaround for a -hypothesis about the harness, one of which (pinned cwd) was simply wrong. -Since every `EnterWorktree` rejection above is graceful and creates nothing, -attempting it and falling back on error covers all of those environments -with zero prediction. Step 2 creates the worktree before step 3 attempts -entry, so even a rejected entry leaves a usable worktree for the fallback. +hypothesis about opaque harness state, one of which (pinned cwd) was simply +wrong, and each landing in the same place a failed `EnterWorktree` lands +anyway. Both tests fail: the state is unknowable without trying, and the +fallback is fine. So they go, and step 4 just attempts entry and falls back. + +The cross-repo split (step 2) passes both tests. The check is a path +comparison the agent already has the inputs for, with no harness state to +predict. And the fallback is genuinely worse: a substantial task run through +absolute paths breaks the skill's one promise and pays per-command friction, +and the right answer isn't a fallback at all but a different tool (a session +rooted in the target repo) that only an up-front decision can reach. Trying +first can't discover that — it just creates a worktree no one can enter. ## The hooks.json pipefail wrapper (agent-isolation path, not this skill) @@ -140,9 +181,19 @@ exits 1 with empty stdout. ## Known limits (deliberate) -- Cross-repo and pinned/already-in-worktree sessions cannot be re-rooted at - all (Claude Code restriction, not a skill gap); the fallback does the task - in the right worktree via absolute paths, which loses only the implicit - default cwd — not any capability. +- A session can't re-root across repos (Claude Code restriction, not a skill + gap). The skill routes this before creating anything (step 2): substantial + work to a session *started* in that repo (the only re-root), and work wanted + in this conversation to `/add-dir ` so `cd` there persists. Both beat + the silent absolute-paths mode — every `cd` reset, an absolute prefix per + command, no worktree cwd — which is what's left when neither lever is used. + A non-interactive session has only that mode (re-root is impossible and + `/add-dir` is user-typed), so a cross-repo task is best provisioned at launch: + start the session in the target repo. +- Pinned and already-in-worktree sessions can't re-root into a sibling-layout + worktree either; that surfaces as the step-4 rejection. There's no cross-repo + handoff to make here: the worktree is already in the session's own repo, so + absolute paths in place lose only the cwd convenience, not the right repo. (A + pinned agent couldn't spawn a fresh rooted session anyway.) - `wt switch --create` is not idempotent. If that ever changes upstream - (enter-if-exists), step 2's existing-branch retry collapses to nothing. + (enter-if-exists), step 3's existing-branch retry collapses to nothing. From 8bfc5bf881e268e43cf473cb95b7e8d0194c768e Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 17 Jun 2026 23:13:32 -0700 Subject: [PATCH 2/2] refactor(plugin): rework /wt-switch-create cross-repo around reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "hand off to a session rooted in the target repo" approach with create-then-reach. `EnterWorktree` re-roots within the repo the cwd is in, and a session reaches another repo's worktree whenever that repo is in `additionalDirectories` (via `cd`) — so cross-repo work, and even cross-repo re-root after a `cd`, are possible without a handoff. Verified live and against the 2.1.177 binary. Step 3 now tries `EnterWorktree`, then `cd`-tests reachability, then escalates: ask the user to add the repo (or a parent like `~/workspace`) to `additionalDirectories`, or `/add-dir`. Creation keeps `wt -C` (cross-repo creation was never the problem), and the procedure is three steps instead of five. rationale.md now carries the harness-mechanics reference inline (M1 `cd` / M2 `EnterWorktree`, how they compose, the master gate) — one supporting doc, not two. docs/content/claude-code.md is trimmed back to two sentences; the mechanism detail belongs in the skill, not the website page. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/claude-code.md | 2 +- skills/worktrunk/reference/claude-code.md | 2 +- skills/wt-switch-create/SKILL.md | 101 ++++----- skills/wt-switch-create/rationale.md | 265 +++++++++++----------- 4 files changed, 176 insertions(+), 194 deletions(-) diff --git a/docs/content/claude-code.md b/docs/content/claude-code.md index cff0fb16af..905305df58 100644 --- a/docs/content/claude-code.md +++ b/docs/content/claude-code.md @@ -101,7 +101,7 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d ## `/wt-switch-create` command (Claude Code only) -`/wt-switch-create [] [] [-- ]` starts work in a fresh worktree without leaving the session. It creates (or re-enters) a worktrunk worktree with `wt switch`, switches the session's working directory into it, then runs the task there. The branch name is optional (picked from the task when omitted), as is the task (whatever follows `--`, or the trailing words when there is no `--`). A path-shaped token names a different repo the task belongs in; since a session re-roots only within its own repo, the skill hands such a task to a session rooted in that repo rather than switching this one in. The worktree is a normal worktrunk worktree — it persists after the session and is merged or removed with `wt merge` / `wt remove` like any other. +`/wt-switch-create [] [] [-- ]` starts a task in a fresh worktree without leaving the session: it creates the worktree, switches into it, and runs the task (all arguments optional). The worktree persists like any other; merge or remove it with `wt merge` / `wt remove`. ## Statusline (Claude Code only) diff --git a/skills/worktrunk/reference/claude-code.md b/skills/worktrunk/reference/claude-code.md index e6c8bc8c21..14c6d713fc 100644 --- a/skills/worktrunk/reference/claude-code.md +++ b/skills/worktrunk/reference/claude-code.md @@ -103,7 +103,7 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d ## `/wt-switch-create` command (Claude Code only) -`/wt-switch-create [] [] [-- ]` starts work in a fresh worktree without leaving the session. It creates (or re-enters) a worktrunk worktree with `wt switch`, switches the session's working directory into it, then runs the task there. The branch name is optional (picked from the task when omitted), as is the task (whatever follows `--`, or the trailing words when there is no `--`). A path-shaped token names a different repo the task belongs in; since a session re-roots only within its own repo, the skill hands such a task to a session rooted in that repo rather than switching this one in. The worktree is a normal worktrunk worktree — it persists after the session and is merged or removed with `wt merge` / `wt remove` like any other. +`/wt-switch-create [] [] [-- ]` starts a task in a fresh worktree without leaving the session: it creates the worktree, switches into it, and runs the task (all arguments optional). The worktree persists like any other; merge or remove it with `wt merge` / `wt remove`. ## Statusline (Claude Code only) diff --git a/skills/wt-switch-create/SKILL.md b/skills/wt-switch-create/SKILL.md index 05ca632456..2494afdd0f 100644 --- a/skills/wt-switch-create/SKILL.md +++ b/skills/wt-switch-create/SKILL.md @@ -1,6 +1,6 @@ --- name: wt-switch-create -description: Create a new worktrunk worktree in this repo and switch this session's working directory into it. Use when launching a session that should work in its own worktree. +description: Create a new worktrunk worktree (optionally in another repo) and switch this session's working directory into it. Use when launching a session that should work in its own worktree. argument-hint: "[] [] [-- ]" license: MIT OR Apache-2.0 compatibility: Requires the `wt` CLI (https://worktrunk.dev) @@ -10,9 +10,8 @@ Arguments: `$ARGUMENTS`. Grammar: `[] [] [-- ]`. - **branch** — optional; the branch name for the new worktree. When omitted, pick one (step 1 below). -- **repo** — optional path naming a *different* repo the task belongs in. This - session can't re-root into another repo, so the skill hands the task to a - session rooted there rather than creating a worktree here (step 2). +- **repo** — optional path; create the worktree in this repo instead of the + session's current one. - **task** — optional; what to do inside the new worktree. No task means enter the worktree and wait. @@ -34,68 +33,48 @@ branch-shaped lead — all task). ## What to do -Steps 1–4 come before any other work. +Steps 1–3 come before any other work. - + -1. **Pick the branch name** if none was given: short, from the task ("fix the - parser bug" → `fix-parser-bug`) and consistent with existing worktree - names, or, mid-session, from the work being moved; with nothing to derive - from, ask. +1. **Pick the branch name** if none was given: short, from the task and + consistent with existing worktree names, or, mid-session, from the work + being moved; with nothing to derive from, ask. -2. **Confirm the task belongs in this repo.** This command re-roots *this* - session, and a session can only re-root within its own repo: the harness - rejects any worktree outside it. So a worktree created in another repo can - never become this session's cwd, and the task would limp along through - absolute paths with every `cd` reset back. - - When the task belongs in another repo (a repo path was given, or the work is - plainly about a different repo), don't create a worktree here — this session - can't re-root into another repo. Match the response to the work: - - - **Substantial work** wants a session *rooted* in that repo, which only a - session *started* there can be: launch one whose working directory is the - target repo and let it run this skill (now same-repo) as its first step; - name the repo, then stop. - - **Work in this conversation**: have the user run `/add-dir ` (a - user-typed command — the agent can't run it), which makes `cd` into that - repo persist so commands run there cleanly without re-rooting. A quick read - needs even less: `git -C ` / `gh` from here, no worktree. - -3. **Create the worktree** with a `Bash` call: +2. **Create the worktree** with a `Bash` call (omit `-C ` for this repo): ``` - wt switch --create --no-cd --format=json + wt -C switch --create --no-cd --format=json ``` Stdout is JSON whose `path` field is the worktree's absolute path (status - lines go to stderr). On `Branch already exists`: - - - the user named the branch → rerun the same command without `--create`; - it enters the existing branch, creating its worktree if missing. - - the name was picked in step 1 → the user never chose that branch; pick - another name and rerun. - - Any other failure (not a git repo, invalid name): report it and stop — do - not do the task in the original directory. - - Mid-session, when the work to move is uncommitted in the current worktree, - carry it across: `git stash push -u` before creating the worktree, then - `git -C stash pop` after (the stash is shared across worktrees). - -4. **Re-root the session** with `EnterWorktree({path: ""})`. - - A same-repo worktree is accepted on first entry, sibling layout included. A - rejection is the unexpected case: the session already entered a worktree via - `EnterWorktree`, or it's a pinned agent restricted to `.claude/worktrees/`. - The rejection is graceful and creates nothing; then work in the worktree - through absolute paths and name its path when reporting back. Don't try to - `cd` there: the harness resets `cd` that leaves the session's working - directories, and `EnterWorktree` is the supported way to move a session. - -5. **Do the task** in the worktree. If there was no task text, confirm the - worktree is ready and wait for the next instruction. + lines go to stderr). On `Branch already exists`: if the user named + the branch, rerun without `--create` (it enters the branch, creating its + worktree if missing); if step 1 picked the name, pick another and rerun. Any + other failure (not a git repo, invalid name): report it and stop. + + Mid-session, carry uncommitted work across: `git stash push -u` before + creating the worktree, then `git -C stash pop` after (the stash is + shared across worktrees). + +3. **Enter the worktree, then do the task.** Call + `EnterWorktree({path: ""})`. + + - **Accepted** → the session is re-rooted in the worktree. Do the task (or, + with no task text, confirm it's ready and wait). + - **Rejected** → the worktree is in another repo (`EnterWorktree` re-roots + only within the repo your cwd is in; the rejection is graceful and creates + nothing). You can still `cd` into the worktree and work there if it's + inside an allowed directory — an entry in `permissions.additionalDirectories`, + such as `~/workspace`. So `cd ` and read the result: + - no `Shell cwd was reset` notice → it stuck; the worktree is reachable. + Work there and do the task. + - `Shell cwd was reset` → not reachable. Stop and ask the user to make it + reachable: add the repo, or a parent like `~/workspace`, to + `permissions.additionalDirectories` (durable, every session), or run + `/add-dir ` (this session). Then continue. Don't grind through + absolute paths with `cd` resetting on every command. ## Cleanup @@ -108,6 +87,6 @@ entered by `path`, so removal is always `wt remove `. ## Scope -This command authorizes creating/entering ONE worktree in this repo and doing -the requested task. Commits, pushes, and merges still each require explicit user -permission. +This command authorizes creating/entering ONE worktree (in the named repo, if +one was given) and doing the requested task. Commits, pushes, and merges still +each require explicit user permission. diff --git a/skills/wt-switch-create/rationale.md b/skills/wt-switch-create/rationale.md index d456eb6d36..397102d64d 100644 --- a/skills/wt-switch-create/rationale.md +++ b/skills/wt-switch-create/rationale.md @@ -1,31 +1,40 @@ # wt-switch-create design rationale -Why the skill is one same-repo route (`wt` in Bash → `EnterWorktree({path})`) -with a single up-front split for cross-repo tasks and an error-driven fallback, -and not the guard-heavy multi-route flow it replaced. +Why the skill is create-then-reach: create the worktree with `wt`, enter it with +`EnterWorktree`, and when entry is rejected (the worktree is in another repo), +work in it if it's reachable or escalate to the user to make it reachable. Not +the guard-heavy multi-route flow it replaced, and not the silent absolute-paths +fallback that read as a failure. + Every claim here was verified against primary sources (2026-06-11 to -2026-06-14): Claude Code 2.1.173, the path-entry logic re-confirmed against the -2.1.177 binary (`EnterWorktree`'s `LI6` validator and its call sites) plus live -tool calls and official docs at code.claude.com/docs; and wt -v0.57.0-16-g371d28662 (live runs in a scratch repo). Re-verify against current -versions before relying on a specific behavior; the *shape* of the argument -should outlive the details. +2026-06-17): Claude Code 2.1.173, with the path-entry and working-directory +logic re-confirmed live and against the 2.1.177 binary, plus official docs at +code.claude.com/docs; and wt v0.57.0-16-g371d28662 (live runs in a scratch +repo). Re-verify against current versions before relying on a specific behavior; +the *shape* of the argument should outlive the details. Binary symbol names are +deliberately omitted — they re-minify every build. ## The design -1. Settle the repo before creating anything. A session re-roots only within - its own repo, so a task in another repo can't be entered from here; hand it - to a session rooted in that repo. The rest assumes a same-repo task. -2. Create the worktree with `wt switch --create --no-cd --format=json` - in Bash. `wt` already solves existing-branch handling (rerun without - `--create`) and machine-readable output (`.path` on stdout, status on - stderr). -3. Re-root the session with `EnterWorktree({path})` — the only supported way - to move a Claude Code session's root. -4. If (3) is rejected (a same-repo edge case: already in a worktree session, or - a pinned agent), work in the worktree via absolute paths. The rejection is - graceful and side-effect-free, so attempt-then-fallback covers it without - prediction. +1. Create the worktree with `wt -C switch --create --no-cd + --format=json` in Bash (omit `-C` for this repo). `wt` solves repo targeting + (`-C` works from anywhere), existing-branch handling (rerun without + `--create`), and machine-readable output (`.path` on stdout, status on + stderr). Creating in another repo is fine; only *entering* the result is + constrained. +2. Enter it with `EnterWorktree({path})` — the only way to re-root a Claude Code + session, and it accepts only a worktree of the session's own repo. +3. On rejection (the worktree is in another repo), the session can still work + there iff the path sits inside a directory it's allowed in (an + `additionalDirectories` entry). A single `cd ` discovers which: it + sticks when reachable, resets when not. Reachable → work in place. + Unreachable → escalate, because the agent can't enlarge that set itself: ask + the user to add the repo or a parent (e.g. `~/workspace`) to + `additionalDirectories`, or `/add-dir `. A one-line, set-once handback, + not a silent degrade. + +Two independent harness facts underlie this — re-root is repo-scoped, and `cd` +persistence is working-directory-membership-scoped — detailed below. ## wt CLI and git behavior (all live-tested) @@ -44,85 +53,84 @@ should outlive the details. `wt remove -D`; clean and merged → removes worktree and branch. - The git stash is per-repo, shared across worktrees: `git stash push -u` in one worktree pops cleanly in another via `git -C stash pop`, - untracked files included — the mid-session carry-across in step 3. + untracked files included — the mid-session carry-across in step 2. ## Claude Code behavior -### `EnterWorktree({path})` accepts worktrunk's sibling layout (load-bearing) - -The path validator (`LI6` in the 2.1.177 binary) derives the repo from the cwd -(`T = h$(u_())`; no repo argument), and applies a `requireManagedLocation` flag -the *caller* sets from session state: `requireManagedLocation: q != null`, where -`q` is the active worktree session (`z$()`). Pinned agents force it `true`. That -one flag picks which check runs, which is the whole reason the two cross-repo -rejections read differently: - -- **`false`** (plain session, no active worktree session): the target must be in - `git worktree list` of the cwd's repo, else *"is not a registered worktree of - "*. Sibling layout passes — a fresh session enters its sibling worktree - by path. -- **`true`** (already in a worktree session, or a pinned agent): the target must - be under `/.claude/worktrees/` (`_F_ = join(repo, ".claude", - "worktrees")`), else *"not under …/.claude/worktrees … managed by Claude - Code"*. Sibling layout fails — a worktree session can't re-enter a worktrunk - sibling worktree. - -worktrunk's `WorktreeCreate` hook writes the sibling layout but never meets this -check: the hook runs only on create (`{name}`), the validator only on entry -(`{path}`). Both branches reject cross-repo regardless — no cwd has a worktree -of another repo in its `git worktree list`, and `.claude/worktrees/` is -per-repo. All four cells (plain / worktree-session × sibling / cross-repo), plus -the no-repo refusal, reproduced live against 2.1.177. - -### Every rejection is graceful and side-effect-free - -This is what lets the skill replace predictive guards with try-then-fallback. -Observed live, verbatim: - -- Cross-repo: `Cannot enter worktree: is not a registered worktree of - . Run 'git -C worktree list' to see registered worktrees.` -- Nesting by name: ``Already in a worktree session. Pass `path` to switch - into another existing worktree, or use ExitWorktree to leave this one - before creating a new worktree.`` -- Not in a repo: `Cannot enter an existing worktree: the current directory is - not in a git repository.` -- Removing a path-entered worktree: `This session entered an existing worktree - (); it was not created by EnterWorktree, so this tool will not remove - it. Use action: "keep" to return to …` - -Binary-confirmed (not live-run): pinned agents (subagent `isolation: -"worktree"` or explicit cwd) can't create by `name` at all and require `path`; -their `path` entry is restricted to `.claude/worktrees/`, so sibling-layout -worktrees are rejected there too — same fallback applies. - -### `cd` is a separate axis from re-root, gated by working-directory membership - -Re-root (`EnterWorktree`) is scoped by repo, above. `cd` persistence is a -different gate: the session carries its primary cwd plus an -`additionalWorkingDirectories` set, and the harness resets any `cd` that lands -outside it, appending `Shell cwd was reset to ` to the result. -Reproduced both ways against 2.1.177: `cd /tmp` persisted (a working directory), -while `cd` into a sibling worktree or another repo reset. - -So `cd` succeeds anywhere inside the working-directory set and never re-roots; -it only resets when stepping outside. `/add-dir ` (or the -`additionalDirectories` setting) enlarges the set, which makes `cd` into another -repo's worktree persist — the fix for *working* cross-repo in place, distinct -from re-rooting (the session's repo identity is unchanged either way). `/add-dir` -is a user-typed command, not an agent tool, so the agent can't enlarge the set -itself. - -`EnterWorktree` reads the moved cwd: after `cd /tmp`, `EnterWorktree({path})` -failed with "the current directory is not in a git repository" (the no-repo -branch above). The earlier revision's "some sessions pin the cwd" was a -misdiagnosis of this reset; subagent threads additionally reset cwd between every -Bash call (binary: "Agent threads always have their cwd reset between bash -calls"). +A session works wherever its cwd is. Two mechanisms move it, and they *compose*: +`cd` moves the cwd (and so which repo `EnterWorktree` sees); `EnterWorktree` +re-roots within the repo the cwd is in. All verified live and against the +2.1.177 binary. + +### M1 — `cd` (shell cwd) + +Moves the shell cwd; the statusline and tool-path relativization +(`Write(foo/bar.py)`) follow it. + +- **Gate:** the path must sit inside a configured working directory — the + session's base cwd plus every entry in `permissions.additionalDirectories` + (settings.json), `--add-dir` (launch), or `/add-dir` (mid-session). +- Inside → `cd` persists across Bash calls. Outside → the harness snaps it back + and appends `Shell cwd was reset to ` to the result. +- **Repo-blind:** it checks only the path's location against that set, never + which git repo owns the path. A different repo's worktree under `/tmp` is + reachable when `/tmp` is configured. +- Within a *single* Bash call, `cd X && cmd` always works; the reset happens + only *between* calls. (Subagent threads reset between every call.) + +### M2 — `EnterWorktree({path})` (re-root) + +Formally re-roots: sets the session's worktree home (tracked for exit) and its +cwd. + +- **Gate:** a worktree of **the repo the current cwd resolves to**, by session + state: + - **Plain / first-entry session** → any worktree *registered to that repo* + (`git worktree list`), anywhere on disk. + - **Already in a worktree-session, or a pinned agent** → only under that + repo's `.claude/worktrees/`; rejects even same-repo siblings. + - **cwd outside any git repo** → refuses entirely. +- Rejections are graceful and side-effect-free (nothing is created), verbatim: + - registered-check: `Cannot enter worktree: is not a registered + worktree of . Run 'git -C worktree list' …` + - managed-location: `Cannot enter worktree: is not under + /.claude/worktrees. Switching from this session is limited to + worktrees managed by Claude Code …` + - no repo: `Cannot enter an existing worktree: the current directory is not in + a git repository.` + +The repo is read from the cwd, so `EnterWorktree` never moves you to a different +repo on its own — it re-roots within whatever repo you're already standing in. +To re-root into *another* repo, `cd` into it first, then `EnterWorktree`. +Verified: from a worktrunk session, `cd` into a prql worktree under `/tmp`, then +`EnterWorktree` re-rooted within prql. + +### How they compose + +Whether you can work in — or re-root into — a repo reduces to whether your cwd +can be there, which is `cd`'s gate: + +| Target | cwd reachable? | Result | +|---|---|---| +| Same repo (incl. its sibling worktrees) | always | `EnterWorktree` re-roots directly | +| Another repo under a configured dir (`~/workspace`, `/tmp`) | yes | `cd` in → working there; from a plain session, `EnterWorktree` re-roots within it too | +| Another repo outside every configured dir | no | unreachable — add it (or a parent) to `additionalDirectories`, or `/add-dir` | +| Outside any repo | n/a | no re-root possible | + +`additionalDirectories` is the one master gate: once a repo, or a parent like +`~/workspace`, is in it, the session can `cd` into that repo's worktrees and both +work there and re-root within them. This is load-bearing for the same-repo path +too: a sibling worktree is registered to the repo, so `EnterWorktree` from a +plain session re-roots into the sibling `wt` creates. The agent **cannot** +enlarge that set itself (`/add-dir` is user-typed; the only automatic add is a +narrow symlink-resolving-to-the-same-cwd fixup), so a repo reachable by neither +the cwd nor config is a genuine handback to the user. ### Why not `EnterWorktree({name})` + the WorktreeCreate hook -The previous revision's primary route. Rejected for the skill — the hook -itself stays; it is how `isolation: "worktree"` agents get worktrunk +A worktree could also be created by `EnterWorktree({name})`, which the worktrunk +plugin routes through its `WorktreeCreate` hook. Rejected for the skill — the +hook itself stays; it is how `isolation: "worktree"` agents get worktrunk worktrees: 1. **Hard-fails on existing branches.** The hook runs `wt switch --create`, @@ -130,7 +138,10 @@ worktrees: git fallback (binary: "Other exit codes - worktree creation failed"; docs: "the hook replaces the default git behavior"). That forced a second route for existing branches; `path` entry needs no second route. -2. **Wrong exit-time semantics for durable worktrees.** Worktrees created by +2. **No repo targeting.** `EnterWorktree({name})` creates the worktree wherever + the session is rooted; it can't make one in another repo, which `wt -C` does + trivially in step 1. +3. **Wrong exit-time semantics for durable worktrees.** Worktrees created by `name` are tracked for session-exit cleanup: when the session has no user-set title, a clean worktree (no changed files, no new commits) is *silently auto-removed* at exit (binary: auto-remove iff zero changes, @@ -138,31 +149,26 @@ worktrees: changes)"). A worktrunk worktree the user asked for should outlive the session (`wt list`, later `wt merge`). Path-entered worktrees get exactly that: left in place, no prompt ("worktree at left in place"). -3. **Hook contract details leak into the skill.** stdout's last non-empty +4. **Hook contract details leak into the skill.** stdout's last non-empty line must be an existing directory, etc. — irrelevant when the skill reads `.path` from `wt --format=json` directly. -### One guard (cross-repo), and no others - -Guard a case when the check is cheap and deterministic *and* the fallback is -worse, not merely different. Try-then-fallback when the state is opaque *and* -the fallback is fine. The removed guards and the kept one split exactly on -this. +### Why escalate instead of grinding through absolute paths -The old flow opened with "if already inside a worktree, reuse it", a -`cd`-then-`pwd` check, and a pinned-cwd branch — each a workaround for a -hypothesis about opaque harness state, one of which (pinned cwd) was simply -wrong, and each landing in the same place a failed `EnterWorktree` lands -anyway. Both tests fail: the state is unknowable without trying, and the -fallback is fine. So they go, and step 4 just attempts entry and falls back. +The original incident worked a cross-repo task via absolute paths from a session +rooted elsewhere: every `cd` into the worktree reset, so each command needed an +absolute prefix, and the session never gained the worktree cwd. It produced +correct output but read as a failure. File tools (absolute paths) and `git -C` +are cwd-independent, so the work is *possible* that way — but it is friction the +user shouldn't absorb when the fix is one line of config. -The cross-repo split (step 2) passes both tests. The check is a path -comparison the agent already has the inputs for, with no harness state to -predict. And the fallback is genuinely worse: a substantial task run through -absolute paths breaks the skill's one promise and pays per-command friction, -and the right answer isn't a fallback at all but a different tool (a session -rooted in the target repo) that only an up-front decision can reach. Trying -first can't discover that — it just creates a worktree no one can enter. +So when the worktree is unreachable, the skill escalates with the concrete fix +rather than degrading silently. This is not a predictive guard: the skill +doesn't refuse to create cross-repo and doesn't guess reachability. It creates, +attempts entry, and lets a single `cd` reveal reachability — the escalation +fires only on an actual reset. Cheap to attempt, and the handback is actionable +and durable (a `~/workspace` entry, set once, covers every future cross-repo +task). ## The hooks.json pipefail wrapper (agent-isolation path, not this skill) @@ -181,19 +187,16 @@ exits 1 with empty stdout. ## Known limits (deliberate) -- A session can't re-root across repos (Claude Code restriction, not a skill - gap). The skill routes this before creating anything (step 2): substantial - work to a session *started* in that repo (the only re-root), and work wanted - in this conversation to `/add-dir ` so `cd` there persists. Both beat - the silent absolute-paths mode — every `cd` reset, an absolute prefix per - command, no worktree cwd — which is what's left when neither lever is used. - A non-interactive session has only that mode (re-root is impossible and - `/add-dir` is user-typed), so a cross-repo task is best provisioned at launch: - start the session in the target repo. -- Pinned and already-in-worktree sessions can't re-root into a sibling-layout - worktree either; that surfaces as the step-4 rejection. There's no cross-repo - handoff to make here: the worktree is already in the session's own repo, so - absolute paths in place lose only the cwd convenience, not the right repo. (A - pinned agent couldn't spawn a fresh rooted session anyway.) +- `EnterWorktree` re-roots only within the repo the cwd is in, and the cwd can't + `cd` outside the session's `additionalDirectories` (Claude Code restrictions, + not skill gaps). So another repo is reachable only when it, or a parent like + `~/workspace`, is in `additionalDirectories` — then the session `cd`s in and + works there (and, from a plain session, can re-root within it). Otherwise the + skill escalates for a one-line config add rather than degrading to + absolute-paths mode. The agent can't enlarge the set itself (`/add-dir` is + user-typed), so this handback is irreducible — but set once, it is permanent. +- A pinned or already-in-worktree session can't even re-enter a *same-repo* + sibling worktree (the stricter `.claude/worktrees/` check); it lands in the + same reachability test and the same escalation. - `wt switch --create` is not idempotent. If that ever changes upstream - (enter-if-exists), step 3's existing-branch retry collapses to nothing. + (enter-if-exists), step 2's existing-branch retry collapses to nothing.