From c7ecf30865165a02f58d9fd479fdee70a14f10c4 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 16 Sep 2026 17:15:12 -0700 Subject: [PATCH 1/2] feat(plugin): approve EnterWorktree into wt-managed worktrees; cd before cross-repo entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/wt-switch-create` step 3 enters a `wt`-created worktree with `EnterWorktree({path})`. Claude Code confirms every path entry outside `.claude/worktrees/`, so a background session stalled at that dialog, and from another repository the entry was rejected because the tool re-roots only within the cwd's repository (#4149). - The plugin's `PermissionRequest` hook now runs the hidden `wt config plugins claude approve-enter-worktree`. It prints Claude Code's allow decision when the target is a worktree of the payload cwd's repository at the path `worktree-path` gives its branch. Any other target exits 1, and the same command's `||` sets the 💬 marker. Approval and marker share one command because Claude Code runs matching hooks in parallel. - Step 3 `cd`s into the target repository before entering, so both the tool and the hook see that repository. An unreachable repo hands back without attempting entry. Verified live on Claude Code 2.1.273: same-repo and cross-repo entries are approved with no dialog and the marker stays 🤖. An off-template worktree, or a cross-repo entry without the `cd`, still shows the dialog. Co-Authored-By: Claude Opus 5 (1M context) --- docs/src/content/docs/claude-code.md | 2 + plugins/worktrunk/CLAUDE.md | 2 + plugins/worktrunk/hooks/hooks.json | 2 +- .../skills/worktrunk/reference/claude-code.md | 2 + .../skills/wt-switch-create/SKILL.md | 31 ++-- .../skills/wt-switch-create/rationale.md | 102 ++++++++++--- skills/worktrunk/reference/claude-code.md | 2 + skills/wt-switch-create/SKILL.md | 31 ++-- skills/wt-switch-create/rationale.md | 102 ++++++++++--- src/cli/config.rs | 4 + src/commands/config/mod.rs | 3 +- src/commands/config/plugins.rs | 50 +++++++ src/commands/mod.rs | 16 +- src/main.rs | 20 ++- tests/integration_tests/config_show.rs | 140 ++++++++++++++++++ 15 files changed, 425 insertions(+), 84 deletions(-) diff --git a/docs/src/content/docs/claude-code.md b/docs/src/content/docs/claude-code.md index 720f32a611..2a40a8f07a 100644 --- a/docs/src/content/docs/claude-code.md +++ b/docs/src/content/docs/claude-code.md @@ -150,6 +150,8 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d `/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 shows up in `wt list`; merge or remove it with `wt merge` / `wt remove`. +Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes when the target is a worktree of the repository the session is working in and sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Any other target still asks. + ## Statusline (Claude Code only) `wt list statusline --format=claude-code` outputs a single-line status for the Claude Code statusline. Claude Code runs it in the background, which is what makes the occasional 1–2 second CI fetch invisible. diff --git a/plugins/worktrunk/CLAUDE.md b/plugins/worktrunk/CLAUDE.md index cea1180e5f..9ca8ceecf0 100644 --- a/plugins/worktrunk/CLAUDE.md +++ b/plugins/worktrunk/CLAUDE.md @@ -136,6 +136,8 @@ The Claude hooks track activity via git config (`worktrunk.state.{branch}.marker - `Notification`, `PreToolUse`(`AskUserQuestion`), `PermissionRequest`, `Stop` → 💬 (waiting for input) - `SessionEnd` → clears status +An `EnterWorktree` that `wt config plugins claude approve-enter-worktree` approves shows no dialog, so it leaves the marker alone; the approval and the marker share one `PermissionRequest` command because Claude Code runs matching hooks in parallel (skills/wt-switch-create/rationale.md, "The confirmation hook"). + The 💬 transitions overlap deliberately: `Notification` covers the documented permission/idle path, but on platforms where it doesn't fire (VS Code extension, Windows CLI) `PermissionRequest` and `Stop` still mark the wait; `PreToolUse`(`AskUserQuestion`) catches the built-in question picker, which fires no `Notification` on any platform ([claude-code#13024](https://github.com/anthropics/claude-code/issues/13024)). There is currently no transition back to 🤖 once a turn-end/permission marker is set except a fresh `UserPromptSubmit`, so 💬 can persist into resumed work after a permission grant (the original symptom in [#2916](https://github.com/max-sixty/worktrunk/issues/2916)). **Problem**: If the user interrupts Claude Code (Escape/Ctrl+C), the 🤖 status persists because there's no `UserInterrupt` hook. The `Stop` hook explicitly does not fire on user interrupt. diff --git a/plugins/worktrunk/hooks/hooks.json b/plugins/worktrunk/hooks/hooks.json index 273cee90fe..8e146145a6 100644 --- a/plugins/worktrunk/hooks/hooks.json +++ b/plugins/worktrunk/hooks/hooks.json @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "bash \"$CLAUDE_PLUGIN_ROOT/hooks/wt.sh\" -C \"$CLAUDE_PROJECT_DIR\" config state marker set 💬 || true" + "command": "bash \"$CLAUDE_PLUGIN_ROOT/hooks/wt.sh\" config plugins claude approve-enter-worktree || bash \"$CLAUDE_PLUGIN_ROOT/hooks/wt.sh\" -C \"$CLAUDE_PROJECT_DIR\" config state marker set 💬 || true" } ] } diff --git a/plugins/worktrunk/skills/worktrunk/reference/claude-code.md b/plugins/worktrunk/skills/worktrunk/reference/claude-code.md index cbb5b16245..5aa01848c2 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/claude-code.md +++ b/plugins/worktrunk/skills/worktrunk/reference/claude-code.md @@ -142,6 +142,8 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d `/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 shows up in `wt list`; merge or remove it with `wt merge` / `wt remove`. +Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes when the target is a worktree of the repository the session is working in and sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Any other target still asks. + ## Statusline (Claude Code only) `wt list statusline --format=claude-code` outputs a single-line status for the Claude Code statusline. Claude Code runs it in the background, which is what makes the occasional 1–2 second CI fetch invisible. diff --git a/plugins/worktrunk/skills/wt-switch-create/SKILL.md b/plugins/worktrunk/skills/wt-switch-create/SKILL.md index 661d95a4fd..8ad7c532af 100644 --- a/plugins/worktrunk/skills/wt-switch-create/SKILL.md +++ b/plugins/worktrunk/skills/wt-switch-create/SKILL.md @@ -69,18 +69,23 @@ design choices behind this — read it before re-adding guards or routes. --> 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. + With a repo argument, `cd ` next, in its own `Bash` call. + `EnterWorktree` re-roots only within the repository the cwd is in, and + worktrunk's `PermissionRequest` hook answers the confirmation that call + asks for only for a `wt` worktree of that same repository. If the `cd` + reports `Shell cwd was reset`, the repo is unreachable: skip the entry and + hand back as in **Unreachable** below. + Then 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). - **Tool error** — the tool ran and returned an error (`Cannot enter worktree: …`) → graceful; nothing moved, and one recovery covers them - all. Common causes: the cwd resolves to no git repo (e.g. a non-git - parent like `~/workspace` that only holds repos, as in a background job) - or to a different repo than the target; or the session is already rooted - in a worktree (or is a pinned agent), which limits entry to the current - repo's `.claude/worktrees/` and excludes even a same-repo `wt` sibling. - The recovery test is whether you can `cd` into the worktree, which works + all. The common cause is a session already rooted in a worktree (or a + pinned agent), which limits entry to the current repo's + `.claude/worktrees/` and excludes even a same-repo `wt` sibling. The + recovery test is whether you can `cd` into the worktree, which works when it's inside an allowed directory. So `cd ` and read the result: - no `Shell cwd was reset` notice → it stuck; the worktree is reachable. @@ -88,19 +93,21 @@ design choices behind this — read it before re-adding guards or routes. --> revert to the session's launch worktree across turns (and in spawned subagents); pin commands with `git -C ` / `wt -C ` rather than trusting the `cd` to persist. - - `Shell cwd was reset` → not reachable. Stop and ask the user to make it - reachable: add the repo, or a parent like `~/workspace`, to + - `Shell cwd was reset` → **Unreachable.** Stop and ask the user to make + the directory whose `cd` reset reachable: add a parent that holds both + the repo and its worktrees, 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. + `/add-dir` on it (this session). Then continue from that `cd`. Don't + grind through absolute paths with `cd` resetting on every command. - **Denied** — the call itself was refused, with no tool error → however the denial is worded, it is the user's answer to the confirmation Claude Code shows for entering a worktree outside `.claude/worktrees/`, unless there was no user to ask (the denial says the session couldn't prompt), which decides nothing — take the recovery above. On the user's answer: the worktree `wt` just created still exists; only the entry didn't - happen. Report its path and ask how to proceed, since reaching it - through `cd` would override that answer. + happen. `cd` back out of the repo you moved into, if any, then report + the worktree's path and ask how to proceed, since reaching it through + `cd` would override that answer. ## Cleanup diff --git a/plugins/worktrunk/skills/wt-switch-create/rationale.md b/plugins/worktrunk/skills/wt-switch-create/rationale.md index 1a872f4bfb..1013dd3d27 100644 --- a/plugins/worktrunk/skills/wt-switch-create/rationale.md +++ b/plugins/worktrunk/skills/wt-switch-create/rationale.md @@ -10,8 +10,10 @@ 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). Entry — the confirmation, both `EnterWorktree` routes, and what each leaves behind at exit — was re-run live on 2026-07-28 against Claude Code -2.1.220 and wt v0.69.2. Re-verify against current versions before relying on a -specific behavior; the *shape* of the argument should outlive the details. +2.1.220 and wt v0.69.2. The confirmation hook and `cd`-before-entry for another +repo were verified live on 2026-09-16 against Claude Code 2.1.273 (#4149). +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 @@ -22,11 +24,15 @@ Binary symbol names are deliberately omitted — they re-minify every build. confirmation from firing. It fails on an existing branch and from a session that already entered a worktree, and it has no repo targeting. 2. Otherwise `wt -C switch --create --no-cd --format=json` in - Bash, then `EnterWorktree({path})`. `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. Entry that - someone declined ends there, with the worktree left unentered (M2). + Bash, then, for another repo, `cd `, then `EnterWorktree({path})`. + `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, and both constraints read the + repository from the cwd: the tool re-roots only within it (M2), and + worktrunk's hook answers the confirmation only for that repository's + worktrees ("The confirmation hook"). The `cd` satisfies both. Entry that someone declined + ends there, with the worktree left unentered (M2). 3. On a tool error (or a denial with no user behind it), the session can still work there iff the path sits inside a directory it's allowed in (an `additionalDirectories` entry). A @@ -34,7 +40,9 @@ Binary symbol names are deliberately omitted — they re-minify every build. 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. + one-line, set-once handback, not a silent degrade. A `cd ` that + resets reaches the same handback without attempting entry, which would only + stop at a confirmation the hook can't answer and then fail. Two independent harness facts underlie this — re-root is repo-scoped, and `cd` persistence is working-directory-membership-scoped — detailed below. @@ -114,7 +122,10 @@ cwd. yes, and `permissions.allow` entries for `EnterWorktree` (bare, `(*)`, or a path glob) don't suppress it. It asks wherever the session can prompt (`default`, `acceptEdits`, `auto`); `bypassPermissions` allows without - asking, and a session that can't prompt denies without asking. + asking. A `PermissionRequest` hook's `allow` decision answers it in place of + the click, and a session that can't prompt runs the same hooks and denies + only when none decides (hooks docs, "PermissionRequest"). Worktrunk's hook + is one ("The confirmation hook"). `EnterWorktree({name})` passes no `path`, so it never asks. - **Reading the failure:** step 3 splits tool errors from denials structurally rather than by parsing the denial's wording. The tool's own @@ -150,6 +161,54 @@ 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. +### The confirmation hook + +The plugin's `PermissionRequest` command runs the hidden +`wt config plugins claude approve-enter-worktree`, which reads the hook payload +and prints an `allow` decision when the call is `EnterWorktree` and its `path` +is a worktree of the repository the payload's `cwd` is in, at the path the +`worktree-path` template gives its branch. Anything else leaves stdout empty +and exits 1, which leaves the dialog (or, where no dialog can show, the denial) +in place. The tool's own validation still runs after an approval. + +The rule extends Claude Code's exemption rather than overriding its check. +Claude Code enters a worktree under `.claude/worktrees/` without asking because +that location is its own convention, whichever name the model picked; +worktrunk's template location is the same kind of convention, and in the +default layout it holds every worktree `wt` creates. A worktree registered +anywhere else still asks, so a `git worktree add` into an arbitrary directory +(approvable in auto mode or by an allow rule) doesn't carry the permission root +there unconfirmed. The rule is not a boundary against a session that rewrites +the repository's own git config: `core.worktree`, `worktrunk.default-branch`, +and the remote URL all feed the template, and a session able to run those +commands can already write wherever an entry would let it. + +The payload's `cwd` follows a shell `cd`, which is why step 3 `cd`s into +another repo before entering. The check lives in `wt` because it is +`is_worktree_at_expected_path`, the same test behind `wt list`'s +`branch_mismatch`; a script over `wt list --format=json` would inherit that +command's user config, where `list.full` adds CI fetches and LLM summaries +(33s measured on a 54-worktree repository) and `list.json-schema` and `list.branches` change +the output's shape. A `wt` too old to have the subcommand fails it, which +leaves the dialog as it was before the hook existed. + +The approval shares one `hooks.json` command with the 💬 marker, +`wt … approve-enter-worktree || wt … marker set 💬`, because Claude Code runs +all matching hooks in parallel. A separate `EnterWorktree` entry would still +fire the catch-all marker hook, and the launch worktree would read 💬 while the +session works on. With one command, an approval skips the marker and every +other permission request sets it as before. The `permission_prompt` +notification can't set it either: Claude Code sends it only after a shown +prompt has waited about six seconds. + +Verified live in `--permission-mode default` sessions: a same-repo entry, and a +cross-repo entry after `cd`, both reported "Allowed by PermissionRequest hook" +with no dialog, and the launch repo's marker still read 🤖 afterwards. The same +cross-repo call without the `cd` showed the dialog and set 💬, and so did a +worktree registered off the template path. An approved entry into the main +working tree, which sits at the default branch's expected path, was still +refused by the tool. + ### How they compose Whether you can work in — or re-root into — a repo reduces to whether your cwd @@ -186,10 +245,11 @@ integrated machine and a no-op elsewhere. Don't drop it. ### What `EnterWorktree({name})` costs, and where it stops -The `name` route is worth having because it skips M2's confirmation entirely, -which no configuration can do for a path entry. The same plugin hook backs -`isolation: "worktree"` agents, so the worktree it produces is the one `wt` -would have made either way. Three properties come with it, all verified live: +The `name` route is worth having because it never asks M2's confirmation, with +or without worktrunk's hook, and it creates and enters in one call. The same +plugin hook backs `isolation: "worktree"` agents, so the worktree it produces is +the one `wt` would have made either way. Three properties come with it, all +verified live: 1. **An untouched worktree is cleaned up at exit, branch included.** With no changed files, no commits, and no user-set session title, the exiting @@ -255,14 +315,14 @@ exits 1 with empty stdout. - 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. -- The invocations that fall to step 3 still ask the user to confirm, once each, - in any session that can prompt (M2): another repo, an existing branch, a - second worktree in one session. Nothing in the skill's reach removes that — - the check ignores `permissions.allow`, and pointing a project's - `worktree-path` into `.claude/worktrees/` would satisfy it only by leaving - worktrunk's default layout and putting `wt` and Claude Code in one directory, - a combination we haven't run. Removing it there is upstream's to do, by - asking once per repo rather than once per call. +- Where the plugin's hooks don't run, or the target is off the `worktree-path` + template (an existing branch whose worktree lives elsewhere, a detached + worktree), step 3's entry + asks the user to confirm, once per call, and a background session waits at + it until someone attaches (M2). `permissions.allow` can't answer it. +- A subagent's `cd` doesn't carry to its next call, and it gets no reset notice + (M1), so from a subagent step 3 can't enter another repo's worktree: the + entry reaches the hook with the subagent's own repo as `cwd`. - `wt switch --create` is not idempotent. If that ever changes upstream (enter-if-exists), step 3's existing-branch retry collapses to nothing, and the hook stops failing on an existing branch, which removes one of the two diff --git a/skills/worktrunk/reference/claude-code.md b/skills/worktrunk/reference/claude-code.md index cbb5b16245..5aa01848c2 100644 --- a/skills/worktrunk/reference/claude-code.md +++ b/skills/worktrunk/reference/claude-code.md @@ -142,6 +142,8 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d `/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 shows up in `wt list`; merge or remove it with `wt merge` / `wt remove`. +Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes when the target is a worktree of the repository the session is working in and sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Any other target still asks. + ## Statusline (Claude Code only) `wt list statusline --format=claude-code` outputs a single-line status for the Claude Code statusline. Claude Code runs it in the background, which is what makes the occasional 1–2 second CI fetch invisible. diff --git a/skills/wt-switch-create/SKILL.md b/skills/wt-switch-create/SKILL.md index 661d95a4fd..8ad7c532af 100644 --- a/skills/wt-switch-create/SKILL.md +++ b/skills/wt-switch-create/SKILL.md @@ -69,18 +69,23 @@ design choices behind this — read it before re-adding guards or routes. --> 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. + With a repo argument, `cd ` next, in its own `Bash` call. + `EnterWorktree` re-roots only within the repository the cwd is in, and + worktrunk's `PermissionRequest` hook answers the confirmation that call + asks for only for a `wt` worktree of that same repository. If the `cd` + reports `Shell cwd was reset`, the repo is unreachable: skip the entry and + hand back as in **Unreachable** below. + Then 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). - **Tool error** — the tool ran and returned an error (`Cannot enter worktree: …`) → graceful; nothing moved, and one recovery covers them - all. Common causes: the cwd resolves to no git repo (e.g. a non-git - parent like `~/workspace` that only holds repos, as in a background job) - or to a different repo than the target; or the session is already rooted - in a worktree (or is a pinned agent), which limits entry to the current - repo's `.claude/worktrees/` and excludes even a same-repo `wt` sibling. - The recovery test is whether you can `cd` into the worktree, which works + all. The common cause is a session already rooted in a worktree (or a + pinned agent), which limits entry to the current repo's + `.claude/worktrees/` and excludes even a same-repo `wt` sibling. The + recovery test is whether you can `cd` into the worktree, which works when it's inside an allowed directory. So `cd ` and read the result: - no `Shell cwd was reset` notice → it stuck; the worktree is reachable. @@ -88,19 +93,21 @@ design choices behind this — read it before re-adding guards or routes. --> revert to the session's launch worktree across turns (and in spawned subagents); pin commands with `git -C ` / `wt -C ` rather than trusting the `cd` to persist. - - `Shell cwd was reset` → not reachable. Stop and ask the user to make it - reachable: add the repo, or a parent like `~/workspace`, to + - `Shell cwd was reset` → **Unreachable.** Stop and ask the user to make + the directory whose `cd` reset reachable: add a parent that holds both + the repo and its worktrees, 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. + `/add-dir` on it (this session). Then continue from that `cd`. Don't + grind through absolute paths with `cd` resetting on every command. - **Denied** — the call itself was refused, with no tool error → however the denial is worded, it is the user's answer to the confirmation Claude Code shows for entering a worktree outside `.claude/worktrees/`, unless there was no user to ask (the denial says the session couldn't prompt), which decides nothing — take the recovery above. On the user's answer: the worktree `wt` just created still exists; only the entry didn't - happen. Report its path and ask how to proceed, since reaching it - through `cd` would override that answer. + happen. `cd` back out of the repo you moved into, if any, then report + the worktree's path and ask how to proceed, since reaching it through + `cd` would override that answer. ## Cleanup diff --git a/skills/wt-switch-create/rationale.md b/skills/wt-switch-create/rationale.md index 1a872f4bfb..1013dd3d27 100644 --- a/skills/wt-switch-create/rationale.md +++ b/skills/wt-switch-create/rationale.md @@ -10,8 +10,10 @@ 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). Entry — the confirmation, both `EnterWorktree` routes, and what each leaves behind at exit — was re-run live on 2026-07-28 against Claude Code -2.1.220 and wt v0.69.2. Re-verify against current versions before relying on a -specific behavior; the *shape* of the argument should outlive the details. +2.1.220 and wt v0.69.2. The confirmation hook and `cd`-before-entry for another +repo were verified live on 2026-09-16 against Claude Code 2.1.273 (#4149). +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 @@ -22,11 +24,15 @@ Binary symbol names are deliberately omitted — they re-minify every build. confirmation from firing. It fails on an existing branch and from a session that already entered a worktree, and it has no repo targeting. 2. Otherwise `wt -C switch --create --no-cd --format=json` in - Bash, then `EnterWorktree({path})`. `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. Entry that - someone declined ends there, with the worktree left unentered (M2). + Bash, then, for another repo, `cd `, then `EnterWorktree({path})`. + `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, and both constraints read the + repository from the cwd: the tool re-roots only within it (M2), and + worktrunk's hook answers the confirmation only for that repository's + worktrees ("The confirmation hook"). The `cd` satisfies both. Entry that someone declined + ends there, with the worktree left unentered (M2). 3. On a tool error (or a denial with no user behind it), the session can still work there iff the path sits inside a directory it's allowed in (an `additionalDirectories` entry). A @@ -34,7 +40,9 @@ Binary symbol names are deliberately omitted — they re-minify every build. 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. + one-line, set-once handback, not a silent degrade. A `cd ` that + resets reaches the same handback without attempting entry, which would only + stop at a confirmation the hook can't answer and then fail. Two independent harness facts underlie this — re-root is repo-scoped, and `cd` persistence is working-directory-membership-scoped — detailed below. @@ -114,7 +122,10 @@ cwd. yes, and `permissions.allow` entries for `EnterWorktree` (bare, `(*)`, or a path glob) don't suppress it. It asks wherever the session can prompt (`default`, `acceptEdits`, `auto`); `bypassPermissions` allows without - asking, and a session that can't prompt denies without asking. + asking. A `PermissionRequest` hook's `allow` decision answers it in place of + the click, and a session that can't prompt runs the same hooks and denies + only when none decides (hooks docs, "PermissionRequest"). Worktrunk's hook + is one ("The confirmation hook"). `EnterWorktree({name})` passes no `path`, so it never asks. - **Reading the failure:** step 3 splits tool errors from denials structurally rather than by parsing the denial's wording. The tool's own @@ -150,6 +161,54 @@ 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. +### The confirmation hook + +The plugin's `PermissionRequest` command runs the hidden +`wt config plugins claude approve-enter-worktree`, which reads the hook payload +and prints an `allow` decision when the call is `EnterWorktree` and its `path` +is a worktree of the repository the payload's `cwd` is in, at the path the +`worktree-path` template gives its branch. Anything else leaves stdout empty +and exits 1, which leaves the dialog (or, where no dialog can show, the denial) +in place. The tool's own validation still runs after an approval. + +The rule extends Claude Code's exemption rather than overriding its check. +Claude Code enters a worktree under `.claude/worktrees/` without asking because +that location is its own convention, whichever name the model picked; +worktrunk's template location is the same kind of convention, and in the +default layout it holds every worktree `wt` creates. A worktree registered +anywhere else still asks, so a `git worktree add` into an arbitrary directory +(approvable in auto mode or by an allow rule) doesn't carry the permission root +there unconfirmed. The rule is not a boundary against a session that rewrites +the repository's own git config: `core.worktree`, `worktrunk.default-branch`, +and the remote URL all feed the template, and a session able to run those +commands can already write wherever an entry would let it. + +The payload's `cwd` follows a shell `cd`, which is why step 3 `cd`s into +another repo before entering. The check lives in `wt` because it is +`is_worktree_at_expected_path`, the same test behind `wt list`'s +`branch_mismatch`; a script over `wt list --format=json` would inherit that +command's user config, where `list.full` adds CI fetches and LLM summaries +(33s measured on a 54-worktree repository) and `list.json-schema` and `list.branches` change +the output's shape. A `wt` too old to have the subcommand fails it, which +leaves the dialog as it was before the hook existed. + +The approval shares one `hooks.json` command with the 💬 marker, +`wt … approve-enter-worktree || wt … marker set 💬`, because Claude Code runs +all matching hooks in parallel. A separate `EnterWorktree` entry would still +fire the catch-all marker hook, and the launch worktree would read 💬 while the +session works on. With one command, an approval skips the marker and every +other permission request sets it as before. The `permission_prompt` +notification can't set it either: Claude Code sends it only after a shown +prompt has waited about six seconds. + +Verified live in `--permission-mode default` sessions: a same-repo entry, and a +cross-repo entry after `cd`, both reported "Allowed by PermissionRequest hook" +with no dialog, and the launch repo's marker still read 🤖 afterwards. The same +cross-repo call without the `cd` showed the dialog and set 💬, and so did a +worktree registered off the template path. An approved entry into the main +working tree, which sits at the default branch's expected path, was still +refused by the tool. + ### How they compose Whether you can work in — or re-root into — a repo reduces to whether your cwd @@ -186,10 +245,11 @@ integrated machine and a no-op elsewhere. Don't drop it. ### What `EnterWorktree({name})` costs, and where it stops -The `name` route is worth having because it skips M2's confirmation entirely, -which no configuration can do for a path entry. The same plugin hook backs -`isolation: "worktree"` agents, so the worktree it produces is the one `wt` -would have made either way. Three properties come with it, all verified live: +The `name` route is worth having because it never asks M2's confirmation, with +or without worktrunk's hook, and it creates and enters in one call. The same +plugin hook backs `isolation: "worktree"` agents, so the worktree it produces is +the one `wt` would have made either way. Three properties come with it, all +verified live: 1. **An untouched worktree is cleaned up at exit, branch included.** With no changed files, no commits, and no user-set session title, the exiting @@ -255,14 +315,14 @@ exits 1 with empty stdout. - 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. -- The invocations that fall to step 3 still ask the user to confirm, once each, - in any session that can prompt (M2): another repo, an existing branch, a - second worktree in one session. Nothing in the skill's reach removes that — - the check ignores `permissions.allow`, and pointing a project's - `worktree-path` into `.claude/worktrees/` would satisfy it only by leaving - worktrunk's default layout and putting `wt` and Claude Code in one directory, - a combination we haven't run. Removing it there is upstream's to do, by - asking once per repo rather than once per call. +- Where the plugin's hooks don't run, or the target is off the `worktree-path` + template (an existing branch whose worktree lives elsewhere, a detached + worktree), step 3's entry + asks the user to confirm, once per call, and a background session waits at + it until someone attaches (M2). `permissions.allow` can't answer it. +- A subagent's `cd` doesn't carry to its next call, and it gets no reset notice + (M1), so from a subagent step 3 can't enter another repo's worktree: the + entry reaches the hook with the subagent's own repo as `cwd`. - `wt switch --create` is not idempotent. If that ever changes upstream (enter-if-exists), step 3's existing-branch retry collapses to nothing, and the hook stops failing on an existing branch, which removes one of the two diff --git a/src/cli/config.rs b/src/cli/config.rs index 8607c5388c..82bcdb0b4a 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -547,6 +547,10 @@ Preserves existing settings. Creates the config directory and `settings.json` if Skips gracefully if the statusline is already configured."# )] InstallStatusline, + + /// Internal: the plugin's PermissionRequest hook, reading its payload from stdin + #[command(hide = true, name = "approve-enter-worktree")] + ApproveEnterWorktree, } // Ordering: introspection adjacent to invocation — show prints the template, diff --git a/src/commands/config/mod.rs b/src/commands/config/mod.rs index 9d54d8aa34..bcaad28dd3 100644 --- a/src/commands/config/mod.rs +++ b/src/commands/config/mod.rs @@ -25,7 +25,8 @@ pub use omp::{handle_omp_install, handle_omp_uninstall}; pub use opencode::{handle_opencode_install, handle_opencode_uninstall}; pub use pi::{handle_pi_install, handle_pi_uninstall}; pub use plugins::{ - handle_claude_install, handle_claude_install_statusline, handle_claude_uninstall, + handle_claude_approve_enter_worktree, handle_claude_install, handle_claude_install_statusline, + handle_claude_uninstall, }; pub use show::handle_config_show; pub use state::{ diff --git a/src/commands/config/plugins.rs b/src/commands/config/plugins.rs index a5341f0f4f..40883326d7 100644 --- a/src/commands/config/plugins.rs +++ b/src/commands/config/plugins.rs @@ -4,9 +4,13 @@ use std::path::PathBuf; use anyhow::{Context, bail}; use color_print::cformat; +use worktrunk::git::{Repository, WorktrunkError}; +use worktrunk::path::paths_match; use worktrunk::styling::{eprintln, info_message, progress_message, success_message}; use super::show::{claude_config_dir, is_claude_available, is_statusline_configured}; +use crate::commands::is_worktree_at_expected_path; +use crate::output::print_json; use crate::output::prompt::{PromptResponse, prompt_yes_no_preview}; const MARKETPLACE_SOURCE: &str = "max-sixty/worktrunk"; @@ -103,6 +107,52 @@ pub fn handle_claude_uninstall(yes: bool) -> anyhow::Result<()> { Ok(()) } +/// The fields of a Claude Code `PermissionRequest` hook payload this handler +/// reads. +#[derive(serde::Deserialize)] +struct PermissionRequest { + tool_name: String, + cwd: PathBuf, + tool_input: serde_json::Value, +} + +/// Handle `wt config plugins claude approve-enter-worktree`, the plugin's +/// `PermissionRequest` hook. +/// +/// Claude Code confirms an `EnterWorktree` into any worktree outside its own +/// `.claude/worktrees/`, which in worktrunk's layout is every worktree, and a +/// background session waits at that dialog until someone attaches. This +/// extends Claude Code's exemption to worktrunk's managed location: it prints +/// the `allow` decision when the target is a worktree of the repository the +/// payload's `cwd` is in, sitting at the path the `worktree-path` template +/// gives its branch. Every other payload exits 1 with nothing on stdout, so the +/// dialog appears and the hook command's `||` sets the 💬 marker. +pub fn handle_claude_approve_enter_worktree() -> anyhow::Result<()> { + let request: PermissionRequest = serde_json::from_reader(std::io::stdin().lock()) + .context("Failed to parse PermissionRequest payload")?; + + if request.tool_name == "EnterWorktree" + && let Some(target) = request.tool_input.get("path").and_then(|p| p.as_str()) + { + let target = request.cwd.join(target); + let repo = Repository::at(&request.cwd)?; + let config = repo.user_config(); + let managed = repo.list_worktrees()?.iter().any(|wt| { + paths_match(&wt.path, &target) && is_worktree_at_expected_path(wt, &repo, config) + }); + if managed { + return print_json(&serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { "behavior": "allow" }, + } + })); + } + } + + Err(WorktrunkError::AlreadyDisplayed { exit_code: 1 }.into()) +} + /// Whether Claude Code lists the worktrunk plugin, or `None` where its answer /// cannot be read. /// diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4c92bdaf51..ae48d57ab8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -35,14 +35,14 @@ pub(crate) use alias::{ }; pub(crate) use config::{ add_approvals, clear_approvals, handle_alias_dry_run, handle_alias_show, handle_cache_clear, - handle_cache_get, handle_claude_install, handle_claude_install_statusline, - handle_claude_uninstall, handle_codex_install, handle_codex_uninstall, handle_config_create, - handle_config_show, handle_config_update, handle_hints_clear, handle_hints_get, - handle_logs_list, handle_logs_profile, handle_omp_install, handle_omp_uninstall, - handle_opencode_install, handle_opencode_uninstall, handle_pi_install, handle_pi_uninstall, - handle_state_clear, handle_state_clear_all, handle_state_get, handle_state_set, - handle_state_show, handle_vars_clear, handle_vars_get, handle_vars_list, handle_vars_set, - list_approvals, + handle_cache_get, handle_claude_approve_enter_worktree, handle_claude_install, + handle_claude_install_statusline, handle_claude_uninstall, handle_codex_install, + handle_codex_uninstall, handle_config_create, handle_config_show, handle_config_update, + handle_hints_clear, handle_hints_get, handle_logs_list, handle_logs_profile, + handle_omp_install, handle_omp_uninstall, handle_opencode_install, handle_opencode_uninstall, + handle_pi_install, handle_pi_uninstall, handle_state_clear, handle_state_clear_all, + handle_state_get, handle_state_set, handle_state_show, handle_vars_clear, handle_vars_get, + handle_vars_list, handle_vars_set, list_approvals, }; pub(crate) use configure_shell::{ handle_configure_shell, handle_show_theme, handle_unconfigure_shell, diff --git a/src/main.rs b/src/main.rs index 3a8326ab36..8ec1c7d29d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,14 +44,15 @@ use commands::worktree::{PushKind, PushOutcome, PushResult, handle_no_ff_merge, use commands::{ HookCliArgs, MergeFlagOverrides, MergeOptions, RebaseResult, SquashResult, add_approvals, clear_approvals, flag_pair, handle_alias_dry_run, handle_alias_show, handle_cache_clear, - handle_cache_get, handle_claude_install, handle_claude_install_statusline, - handle_claude_uninstall, handle_codex_install, handle_codex_uninstall, handle_completions, - handle_config_create, handle_config_show, handle_config_update, handle_configure_shell, - handle_custom_command, handle_hints_clear, handle_hints_get, handle_hook_show, handle_init, - handle_list, handle_logs_list, handle_logs_profile, handle_merge, handle_omp_install, - handle_omp_uninstall, handle_opencode_install, handle_opencode_uninstall, handle_pi_install, - handle_pi_uninstall, handle_promote, handle_rebase, handle_remove_command, handle_show_theme, - handle_squash, handle_state_clear, handle_state_clear_all, handle_state_get, handle_state_set, + handle_cache_get, handle_claude_approve_enter_worktree, handle_claude_install, + handle_claude_install_statusline, handle_claude_uninstall, handle_codex_install, + handle_codex_uninstall, handle_completions, handle_config_create, handle_config_show, + handle_config_update, handle_configure_shell, handle_custom_command, handle_hints_clear, + handle_hints_get, handle_hook_show, handle_init, handle_list, handle_logs_list, + handle_logs_profile, handle_merge, handle_omp_install, handle_omp_uninstall, + handle_opencode_install, handle_opencode_uninstall, handle_pi_install, handle_pi_uninstall, + handle_promote, handle_rebase, handle_remove_command, handle_show_theme, handle_squash, + handle_state_clear, handle_state_clear_all, handle_state_get, handle_state_set, handle_state_show, handle_switch_command, handle_unconfigure_shell, handle_vars_clear, handle_vars_get, handle_vars_list, handle_vars_set, list_approvals, run_hook, step_commit, step_copy_ignored, step_diff, step_eval, step_for_each, step_prune, step_relocate, step_tether, @@ -660,6 +661,9 @@ fn handle_plugins_command(action: ConfigPluginsCommand, yes: bool) -> anyhow::Re ConfigPluginsClaudeCommand::Install => handle_claude_install(yes), ConfigPluginsClaudeCommand::Uninstall => handle_claude_uninstall(yes), ConfigPluginsClaudeCommand::InstallStatusline => handle_claude_install_statusline(yes), + ConfigPluginsClaudeCommand::ApproveEnterWorktree => { + handle_claude_approve_enter_worktree() + } }, ConfigPluginsCommand::Codex { action } => match action { ConfigPluginsCodexCommand::Install => handle_codex_install(yes), diff --git a/tests/integration_tests/config_show.rs b/tests/integration_tests/config_show.rs index a151605bc3..e626417907 100644 --- a/tests/integration_tests/config_show.rs +++ b/tests/integration_tests/config_show.rs @@ -6292,6 +6292,146 @@ fn test_worktree_remove_hook_skips_path_holding_no_worktree(mut repo: TestRepo) assert!(!live.exists(), "the hook must remove a real worktree"); } +/// The plugin's `PermissionRequest` command answers Claude Code's +/// `EnterWorktree` confirmation for a worktree at worktrunk's managed location +/// in the repository the payload's `cwd` is in, so a background session doesn't +/// wait at it (#4149), and otherwise sets the 💬 marker as before (design: +/// skills/wt-switch-create/rationale.md, "The confirmation hook"). +/// +/// An approval prints the allow decision and leaves the marker unset, since no +/// dialog waits. Each case that declines differs from an approved one in a +/// single input — the tool, a worktree off the `worktree-path` template, the +/// repository of the `cwd` — and must print nothing, so the dialog stays, and +/// set 💬. The `cwd` pair shares its target, which pins that the session's +/// directory decides. It runs the real command out of `hooks.json`. +#[cfg(all(unix, feature = "shell-integration-tests"))] +#[rstest] +fn test_permission_request_hook_approves_entering_managed_worktrees(mut repo: TestRepo) { + use std::io::Write; + use std::path::Path; + use std::process::Stdio; + + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let hooks_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(root.join("plugins/worktrunk/hooks/hooks.json")).unwrap(), + ) + .unwrap(); + let command = hooks_json["hooks"]["PermissionRequest"][0]["hooks"][0]["command"] + .as_str() + .expect("PermissionRequest hook must define a command") + .to_owned(); + + let feature = repo.add_worktree("feature"); + // A symlinked spelling of the same worktree, as macOS `/tmp` is of + // `/private/tmp`. + let feature_link = repo.home_path().join("feature-link"); + std::os::unix::fs::symlink(&feature, &feature_link).unwrap(); + // Registered like any worktree, but not where the template puts its branch. + let stray = repo.add_worktree_at_path("stray", &repo.home_path().join("stray")); + let mut other = TestRepo::standard(); + let other_feature = other.add_worktree("other-feature"); + + let marker_key = format!("worktrunk.state.{}.marker", repo.current_branch()); + let marker = || -> String { + repo.git_command() + .args(["config", "--get", &marker_key]) + .run() + .map_or_else( + |_| String::new(), + |output| String::from_utf8_lossy(&output.stdout).trim().to_owned(), + ) + }; + + // Fire the hook as Claude Code does: the payload on stdin, the plugin root + // and the launch project dir in the environment. Returns stdout and the + // marker the call left, starting from none. + let run_hook = |tool: &str, cwd: &Path, target: &Path| -> (String, String) { + let _ = repo + .git_command() + .args(["config", "--unset", &marker_key]) + .run(); + let mut cmd = std::process::Command::new("bash"); + repo.configure_wt_cmd(&mut cmd); + let mut child = cmd + .args(["-c", &command]) + .env("WORKTRUNK_BIN", crate::common::wt_bin()) + .env("CLAUDE_PLUGIN_ROOT", root.join("plugins/worktrunk")) + .env("CLAUDE_PROJECT_DIR", repo.root_path()) + .current_dir(repo.root_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn bash"); + let payload = serde_json::json!({ + "hook_event_name": "PermissionRequest", + "tool_name": tool, + "cwd": cwd, + "tool_input": { "path": target }, + }); + child + .stdin + .take() + .unwrap() + .write_all(payload.to_string().as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "the hook command must exit 0 for {tool} {}; got {}\nstderr:\n{}", + target.display(), + output.status, + String::from_utf8_lossy(&output.stderr) + ); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + marker(), + ) + }; + + for (cwd, target) in [ + (repo.root_path(), feature.as_path()), + (repo.root_path(), feature_link.as_path()), + (other.root_path(), other_feature.as_path()), + ] { + let (stdout, marker) = run_hook("EnterWorktree", cwd, target); + let decision: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("expected an allow decision, got {stdout:?}: {e}")); + assert_eq!( + decision["hookSpecificOutput"], + serde_json::json!({ + "hookEventName": "PermissionRequest", + "decision": { "behavior": "allow" }, + }), + "entering {} from {} must be approved", + target.display(), + cwd.display() + ); + assert!( + marker.is_empty(), + "an approved entry shows no dialog, so it must not set the marker; got {marker:?}" + ); + } + + for (tool, cwd, target) in [ + ("Bash", repo.root_path(), feature.as_path()), + ("EnterWorktree", repo.root_path(), stray.as_path()), + ("EnterWorktree", repo.root_path(), other_feature.as_path()), + ] { + let (stdout, marker) = run_hook(tool, cwd, target); + assert!( + stdout.is_empty(), + "{tool} {} must get no decision, leaving the dialog; got {stdout:?}", + target.display() + ); + assert!( + marker.contains('💬'), + "{tool} {} leaves a dialog waiting, so it must set 💬; got {marker:?}", + target.display() + ); + } +} + // ==================== Plugin Install-Statusline Tests ==================== #[rstest] From b475347ad1996fe4834d18bd6afe0f2faec00a5a Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 16 Sep 2026 17:22:02 -0700 Subject: [PATCH 2/2] docs: say worktree and main worktree per project terminology --- docs/src/content/docs/claude-code.md | 2 +- .../skills/worktrunk/reference/claude-code.md | 2 +- .../skills/wt-switch-create/rationale.md | 14 ++++++------ skills/worktrunk/reference/claude-code.md | 2 +- skills/wt-switch-create/rationale.md | 14 ++++++------ src/commands/config/plugins.rs | 15 +++++++------ tests/integration_tests/config_show.rs | 22 +++++++++---------- 7 files changed, 36 insertions(+), 35 deletions(-) diff --git a/docs/src/content/docs/claude-code.md b/docs/src/content/docs/claude-code.md index 2a40a8f07a..f24d80087b 100644 --- a/docs/src/content/docs/claude-code.md +++ b/docs/src/content/docs/claude-code.md @@ -150,7 +150,7 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d `/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 shows up in `wt list`; merge or remove it with `wt merge` / `wt remove`. -Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes when the target is a worktree of the repository the session is working in and sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Any other target still asks. +Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes for a worktree of the repository the session is working in that sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Entering any other worktree still asks. ## Statusline (Claude Code only) diff --git a/plugins/worktrunk/skills/worktrunk/reference/claude-code.md b/plugins/worktrunk/skills/worktrunk/reference/claude-code.md index 5aa01848c2..7f26045c48 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/claude-code.md +++ b/plugins/worktrunk/skills/worktrunk/reference/claude-code.md @@ -142,7 +142,7 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d `/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 shows up in `wt list`; merge or remove it with `wt merge` / `wt remove`. -Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes when the target is a worktree of the repository the session is working in and sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Any other target still asks. +Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes for a worktree of the repository the session is working in that sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Entering any other worktree still asks. ## Statusline (Claude Code only) diff --git a/plugins/worktrunk/skills/wt-switch-create/rationale.md b/plugins/worktrunk/skills/wt-switch-create/rationale.md index 1013dd3d27..1cc788d6da 100644 --- a/plugins/worktrunk/skills/wt-switch-create/rationale.md +++ b/plugins/worktrunk/skills/wt-switch-create/rationale.md @@ -206,8 +206,8 @@ cross-repo entry after `cd`, both reported "Allowed by PermissionRequest hook" with no dialog, and the launch repo's marker still read 🤖 afterwards. The same cross-repo call without the `cd` showed the dialog and set 💬, and so did a worktree registered off the template path. An approved entry into the main -working tree, which sits at the default branch's expected path, was still -refused by the tool. +worktree, which sits at the default branch's expected path, was still refused +by the tool. ### How they compose @@ -315,11 +315,11 @@ exits 1 with empty stdout. - 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. -- Where the plugin's hooks don't run, or the target is off the `worktree-path` - template (an existing branch whose worktree lives elsewhere, a detached - worktree), step 3's entry - asks the user to confirm, once per call, and a background session waits at - it until someone attaches (M2). `permissions.allow` can't answer it. +- Where the plugin's hooks don't run, or the worktree is off the + `worktree-path` template (an existing branch whose worktree lives elsewhere, + a detached worktree), step 3's entry asks the user to confirm, once per call, + and a background session waits at it until someone attaches (M2). + `permissions.allow` can't answer it. - A subagent's `cd` doesn't carry to its next call, and it gets no reset notice (M1), so from a subagent step 3 can't enter another repo's worktree: the entry reaches the hook with the subagent's own repo as `cwd`. diff --git a/skills/worktrunk/reference/claude-code.md b/skills/worktrunk/reference/claude-code.md index 5aa01848c2..7f26045c48 100644 --- a/skills/worktrunk/reference/claude-code.md +++ b/skills/worktrunk/reference/claude-code.md @@ -142,7 +142,7 @@ Claude Code agents can run in isolated worktrees (`isolation: "worktree"`). By d `/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 shows up in `wt list`; merge or remove it with `wt merge` / `wt remove`. -Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes when the target is a worktree of the repository the session is working in and sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Any other target still asks. +Claude Code asks for confirmation before a session enters an existing worktree by path outside `.claude/worktrees/`, which in worktrunk's default layout is every worktree. The plugin's `PermissionRequest` hook answers yes for a worktree of the repository the session is working in that sits where `worktree-path` puts its branch, so a background session doesn't stop at that prompt. Entering any other worktree still asks. ## Statusline (Claude Code only) diff --git a/skills/wt-switch-create/rationale.md b/skills/wt-switch-create/rationale.md index 1013dd3d27..1cc788d6da 100644 --- a/skills/wt-switch-create/rationale.md +++ b/skills/wt-switch-create/rationale.md @@ -206,8 +206,8 @@ cross-repo entry after `cd`, both reported "Allowed by PermissionRequest hook" with no dialog, and the launch repo's marker still read 🤖 afterwards. The same cross-repo call without the `cd` showed the dialog and set 💬, and so did a worktree registered off the template path. An approved entry into the main -working tree, which sits at the default branch's expected path, was still -refused by the tool. +worktree, which sits at the default branch's expected path, was still refused +by the tool. ### How they compose @@ -315,11 +315,11 @@ exits 1 with empty stdout. - 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. -- Where the plugin's hooks don't run, or the target is off the `worktree-path` - template (an existing branch whose worktree lives elsewhere, a detached - worktree), step 3's entry - asks the user to confirm, once per call, and a background session waits at - it until someone attaches (M2). `permissions.allow` can't answer it. +- Where the plugin's hooks don't run, or the worktree is off the + `worktree-path` template (an existing branch whose worktree lives elsewhere, + a detached worktree), step 3's entry asks the user to confirm, once per call, + and a background session waits at it until someone attaches (M2). + `permissions.allow` can't answer it. - A subagent's `cd` doesn't carry to its next call, and it gets no reset notice (M1), so from a subagent step 3 can't enter another repo's worktree: the entry reaches the hook with the subagent's own repo as `cwd`. diff --git a/src/commands/config/plugins.rs b/src/commands/config/plugins.rs index 40883326d7..0189380690 100644 --- a/src/commands/config/plugins.rs +++ b/src/commands/config/plugins.rs @@ -123,22 +123,23 @@ struct PermissionRequest { /// `.claude/worktrees/`, which in worktrunk's layout is every worktree, and a /// background session waits at that dialog until someone attaches. This /// extends Claude Code's exemption to worktrunk's managed location: it prints -/// the `allow` decision when the target is a worktree of the repository the -/// payload's `cwd` is in, sitting at the path the `worktree-path` template -/// gives its branch. Every other payload exits 1 with nothing on stdout, so the -/// dialog appears and the hook command's `||` sets the 💬 marker. +/// the `allow` decision when the call's `path` names a worktree of the +/// repository the payload's `cwd` is in, sitting at the path the +/// `worktree-path` template gives its branch. Every other payload exits 1 with +/// nothing on stdout, so the dialog appears and the hook command's `||` sets +/// the 💬 marker. pub fn handle_claude_approve_enter_worktree() -> anyhow::Result<()> { let request: PermissionRequest = serde_json::from_reader(std::io::stdin().lock()) .context("Failed to parse PermissionRequest payload")?; if request.tool_name == "EnterWorktree" - && let Some(target) = request.tool_input.get("path").and_then(|p| p.as_str()) + && let Some(path) = request.tool_input.get("path").and_then(|p| p.as_str()) { - let target = request.cwd.join(target); + let path = request.cwd.join(path); let repo = Repository::at(&request.cwd)?; let config = repo.user_config(); let managed = repo.list_worktrees()?.iter().any(|wt| { - paths_match(&wt.path, &target) && is_worktree_at_expected_path(wt, &repo, config) + paths_match(&wt.path, &path) && is_worktree_at_expected_path(wt, &repo, config) }); if managed { return print_json(&serde_json::json!({ diff --git a/tests/integration_tests/config_show.rs b/tests/integration_tests/config_show.rs index e626417907..2a5b34498b 100644 --- a/tests/integration_tests/config_show.rs +++ b/tests/integration_tests/config_show.rs @@ -6302,7 +6302,7 @@ fn test_worktree_remove_hook_skips_path_holding_no_worktree(mut repo: TestRepo) /// dialog waits. Each case that declines differs from an approved one in a /// single input — the tool, a worktree off the `worktree-path` template, the /// repository of the `cwd` — and must print nothing, so the dialog stays, and -/// set 💬. The `cwd` pair shares its target, which pins that the session's +/// set 💬. The `cwd` pair shares its worktree, which pins that the session's /// directory decides. It runs the real command out of `hooks.json`. #[cfg(all(unix, feature = "shell-integration-tests"))] #[rstest] @@ -6345,7 +6345,7 @@ fn test_permission_request_hook_approves_entering_managed_worktrees(mut repo: Te // Fire the hook as Claude Code does: the payload on stdin, the plugin root // and the launch project dir in the environment. Returns stdout and the // marker the call left, starting from none. - let run_hook = |tool: &str, cwd: &Path, target: &Path| -> (String, String) { + let run_hook = |tool: &str, cwd: &Path, path: &Path| -> (String, String) { let _ = repo .git_command() .args(["config", "--unset", &marker_key]) @@ -6367,7 +6367,7 @@ fn test_permission_request_hook_approves_entering_managed_worktrees(mut repo: Te "hook_event_name": "PermissionRequest", "tool_name": tool, "cwd": cwd, - "tool_input": { "path": target }, + "tool_input": { "path": path }, }); child .stdin @@ -6379,7 +6379,7 @@ fn test_permission_request_hook_approves_entering_managed_worktrees(mut repo: Te assert!( output.status.success(), "the hook command must exit 0 for {tool} {}; got {}\nstderr:\n{}", - target.display(), + path.display(), output.status, String::from_utf8_lossy(&output.stderr) ); @@ -6389,12 +6389,12 @@ fn test_permission_request_hook_approves_entering_managed_worktrees(mut repo: Te ) }; - for (cwd, target) in [ + for (cwd, path) in [ (repo.root_path(), feature.as_path()), (repo.root_path(), feature_link.as_path()), (other.root_path(), other_feature.as_path()), ] { - let (stdout, marker) = run_hook("EnterWorktree", cwd, target); + let (stdout, marker) = run_hook("EnterWorktree", cwd, path); let decision: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("expected an allow decision, got {stdout:?}: {e}")); assert_eq!( @@ -6404,7 +6404,7 @@ fn test_permission_request_hook_approves_entering_managed_worktrees(mut repo: Te "decision": { "behavior": "allow" }, }), "entering {} from {} must be approved", - target.display(), + path.display(), cwd.display() ); assert!( @@ -6413,21 +6413,21 @@ fn test_permission_request_hook_approves_entering_managed_worktrees(mut repo: Te ); } - for (tool, cwd, target) in [ + for (tool, cwd, path) in [ ("Bash", repo.root_path(), feature.as_path()), ("EnterWorktree", repo.root_path(), stray.as_path()), ("EnterWorktree", repo.root_path(), other_feature.as_path()), ] { - let (stdout, marker) = run_hook(tool, cwd, target); + let (stdout, marker) = run_hook(tool, cwd, path); assert!( stdout.is_empty(), "{tool} {} must get no decision, leaving the dialog; got {stdout:?}", - target.display() + path.display() ); assert!( marker.contains('💬'), "{tool} {} leaves a dialog waiting, so it must set 💬; got {marker:?}", - target.display() + path.display() ); } }