From 2fa4b2bf150039287ed7af1d9cf4e0f662d24f60 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 28 Jul 2026 18:07:21 -0700 Subject: [PATCH 1/3] chore: keep eslint out of agent worktrees, correct the worktrees skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worktree under .claude/worktrees/ is a full checkout nested in the repo, so a root `eslint .` walked into it — 2983 files across two live agent worktrees, linting half-finished work and failing on code the developer never wrote. Scoped the ignore to `.claude/worktrees/` rather than all of `.claude/`, so anything else we put there is still checked. Verified the blast radius is eslint alone: prettier's globs don't descend into dot-directories, and typecheck runs per-package through turbo rather than from the root. The skill said to put worktrees in a sibling directory, which the harness doesn't do and which leaves orphaned checkouts holding branch locks after a repo is deleted. It now documents .claude/worktrees/, the one-worktree-per-branch constraint, and the ignore requirement for any future repo-root tool that walks the tree. Also settles enableGlobalVirtualStore as rejected rather than pending: experimental, documented as broken for ESM under hoisted dependencies, and this repo is ESM throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .agents/skills/worktrees-pnpm/SKILL.md | 52 +++++++++++++++----------- eslint.config.js | 6 +++ 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.agents/skills/worktrees-pnpm/SKILL.md b/.agents/skills/worktrees-pnpm/SKILL.md index 606d1131..a9d75d53 100644 --- a/.agents/skills/worktrees-pnpm/SKILL.md +++ b/.agents/skills/worktrees-pnpm/SKILL.md @@ -1,6 +1,6 @@ --- name: worktrees-pnpm -description: Create and work in git worktrees in this pnpm workspace. Use when creating a worktree, delegating to a background agent with worktree isolation, or when a worktree fails at commit time with a missing prettier/eslint/tsx binary. Covers why a worktree needs its own pnpm install and how to make that install cheap. +description: Create and work in git worktrees in this pnpm workspace. Use when creating a worktree, delegating to a background agent with worktree isolation, or when a worktree fails at commit time with a missing prettier/eslint/tsx binary. Covers why a worktree needs its own pnpm install, where worktrees belong, and why repo-root tooling must ignore them. --- # Worktrees in a pnpm workspace @@ -14,11 +14,15 @@ problem in this repo. **`git worktree add` is not finished until `pnpm install` has run inside the new worktree.** ```bash -git worktree add ../skills- -b -cd ../skills- +git worktree add .claude/worktrees/ # or -b for a new one +cd .claude/worktrees/ pnpm install # ← not optional ``` +Put worktrees in **`.claude/worktrees/`**. It is already gitignored (`.gitignore:19`), it is where the agent harness creates them, and living inside the repo means they disappear when the repo does. Sibling directories (`../skills-`) outlive a deleted repo as orphaned checkouts still holding branch locks with no obvious owner. + +A branch can only be checked out in one worktree at a time. If the branch you want is checked out in the primary tree, move that tree to another branch first. + Skipping the install leaves a checkout that looks fine and fails the moment you try to accomplish anything: @@ -38,26 +42,32 @@ pnpm does not provide worktree subcommands. Use `git worktree` — it is the cor (pnpm's own repository has a `worktree:new` helper script, but that is a script in _their_ repo, not a pnpm feature. Do not go looking for it here.) -What pnpm contributes is making the required install cheap; see below. +pnpm's only contribution here is the install you owe the new worktree. + +## What the install actually costs + +`du` reports `node_modules` at roughly 370 MB, but that is apparent size. The pnpm store lives +on the same APFS volume, so package content is shared by copy-on-write rather than duplicated; +the real incremental cost of another worktree is far smaller than the number suggests. Disk is +not the reason to avoid a worktree. -## Making the install cheap +**`enableGlobalVirtualStore` is not used here, and that is settled.** It would make installs a +tree of symlinks into one shared store, but it is experimental, pnpm documents it as not working +with ESM under hoisted dependencies, and this repo is ESM throughout. We gain nothing worth that +risk. Do not re-propose it. -`enableGlobalVirtualStore: true` in `pnpm-workspace.yaml` makes `node_modules` a tree of -symlinks into one content-addressable store shared across worktrees, so a second worktree's -`pnpm install` is close to instant instead of a full materialization. +So a worktree install is a normal install. Budget for it; do not skip it. -It is **not currently enabled in this repo**, deliberately. Before turning it on, know: +## Worktrees live inside the repo, so repo-wide tooling must ignore them -- Requires pnpm ≥ **10.12.1** (this repo runs 10.12.4, so the version is fine). -- Defaults to `false`, and pnpm disables it automatically in detected CI. -- **It does not work with ESM when hoisted dependencies are used**, because Node no longer - honours `NODE_PATH` in ESM. This repo is ESM (`packages/cli` is `"type": "module"`) and - currently sets no hoisting configuration, so it is likely fine — but "likely" is why it is - off. Enable it as its own change, with the full test suite as the check. -- The store assumes mutually trusting users and processes. Do not share one writable store - across untrusted agents. +A worktree under `.claude/worktrees/` is a complete second checkout nested in the tree. Anything +that walks the repo from the root will walk into it — linting or formatting an agent's +half-finished work, and failing on code you did not write. -Until it is enabled, a worktree install is a normal install. Budget for it; do not skip it. +`eslint.config.js` ignores `.claude/worktrees/` for exactly this reason. Verified as not +affected: prettier (its globs do not descend into dot-directories) and `tsc` (typecheck runs +per-package through turbo, not from the root). **If you add another repo-root tool that walks +the tree, give it the same ignore.** ## Delegating to a background agent with worktree isolation @@ -77,8 +87,8 @@ Until it is enabled, a worktree install is a normal install. Budget for it; do n ## Cleaning up ```bash -git worktree remove ../skills- # add --force if it has uncommitted changes -git worktree list # confirm +git worktree remove .claude/worktrees/ # add --force if it has uncommitted changes +git worktree list # confirm ``` Removing the directory by hand leaves a stale registration; `git worktree prune` clears it. @@ -96,7 +106,7 @@ git checkout ## When a worktree is worth it -Worktrees cost a full `node_modules` (until the global virtual store is enabled). They earn it +Worktrees cost a `pnpm install` and a little wall-clock. They earn it when you need two branches checked out at once — running a long test suite on one branch while editing another, or letting a background agent work without disturbing your tree. diff --git a/eslint.config.js b/eslint.config.js index 112c58c7..16c2dd44 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,12 @@ export default tseslint.config( "openspec/", "**/test/fixtures/", "tmp/", + // Agent worktrees are full checkouts nested inside the repo. Without + // this, a root `eslint .` lints every worktree's copy of the tree — + // slow, and it fails on whatever an agent has mid-edit. Scoped to + // `worktrees` rather than all of `.claude/` so anything else we put + // there is still checked. + ".claude/worktrees/", // Zero-dependency CommonJS workflow scripts (covered by their own // node:test suite); the app's TS/ESM-oriented rules don't apply. ".github/scripts/", From a94e87a6f1f6d7d8dbff3830034a6e6ee4b307ae Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 28 Jul 2026 19:26:15 -0700 Subject: [PATCH 2/3] chore: place worktrees beside the repo via WorktreeCreate hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worktree is a full second checkout, so nesting it at .claude/worktrees/ means every tool that walks the tree from the root walks into it. The eslint ignore in the previous commit patched one tool; this removes the class of problem instead. Worktrees now land in ../skills-worktrees/, which also makes "what worktrees do I have" a single ls. Worktree placement turned out to be configurable after all — a WorktreeCreate hook replaces the default logic entirely and returns the path it created, with WorktreeRemove as its counterpart. Both scripts verified end to end against synthetic payloads before being wired up. The remove hook deliberately refuses to delete a worktree with uncommitted changes: its failures are logged in debug mode only and never surface, so silently discarding work would be invisible, whereas a stale worktree is visible in `git worktree list`. Note the settings schema requires the nested hooks form; the flat [{type, command}] shape shown on one docs page does not validate. The .claude/worktrees ignores stay as a backstop for anything that bypasses the hooks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .agents/skills/worktrees-pnpm/SKILL.md | 33 ++++++++++-------- .claude/hooks/worktree-create.sh | 47 ++++++++++++++++++++++++++ .claude/hooks/worktree-remove.sh | 46 +++++++++++++++++++++++++ .claude/settings.json | 23 +++++++++++++ 4 files changed, 135 insertions(+), 14 deletions(-) create mode 100755 .claude/hooks/worktree-create.sh create mode 100755 .claude/hooks/worktree-remove.sh diff --git a/.agents/skills/worktrees-pnpm/SKILL.md b/.agents/skills/worktrees-pnpm/SKILL.md index a9d75d53..ba2d05b1 100644 --- a/.agents/skills/worktrees-pnpm/SKILL.md +++ b/.agents/skills/worktrees-pnpm/SKILL.md @@ -1,6 +1,6 @@ --- name: worktrees-pnpm -description: Create and work in git worktrees in this pnpm workspace. Use when creating a worktree, delegating to a background agent with worktree isolation, or when a worktree fails at commit time with a missing prettier/eslint/tsx binary. Covers why a worktree needs its own pnpm install, where worktrees belong, and why repo-root tooling must ignore them. +description: Create and work in git worktrees in this pnpm workspace. Use when creating a worktree, delegating to a background agent with worktree isolation, or when a worktree fails at commit time with a missing prettier/eslint/tsx binary. Covers why a worktree needs its own pnpm install, and why worktrees live beside the repo rather than inside it. --- # Worktrees in a pnpm workspace @@ -14,12 +14,14 @@ problem in this repo. **`git worktree add` is not finished until `pnpm install` has run inside the new worktree.** ```bash -git worktree add .claude/worktrees/ # or -b for a new one -cd .claude/worktrees/ +git worktree add ../skills-worktrees/ # or -b for a new one +cd ../skills-worktrees/ pnpm install # ← not optional ``` -Put worktrees in **`.claude/worktrees/`**. It is already gitignored (`.gitignore:19`), it is where the agent harness creates them, and living inside the repo means they disappear when the repo does. Sibling directories (`../skills-`) outlive a deleted repo as orphaned checkouts still holding branch locks with no obvious owner. +Worktrees go **beside the repo**, in `../skills-worktrees/`, never inside it. Agent worktrees land there too: the `WorktreeCreate` / `WorktreeRemove` hooks in `.claude/settings.json` (scripts under `.claude/hooks/`) replace the default placement, which would otherwise nest them at `.claude/worktrees/`. + +That is not a matter of taste. A worktree is a complete second checkout, so nesting it inside the repo means every tool that walks the tree from the root walks into it. We hit exactly that: a root `eslint .` traversed 2983 files across two agent worktrees and failed on code an agent had half-written. An ignore rule patches one tool; a sibling directory makes the whole class of problem impossible. One directory also answers "what worktrees do I have?" at a glance — `ls ../skills-worktrees/`. A branch can only be checked out in one worktree at a time. If the branch you want is checked out in the primary tree, move that tree to another branch first. @@ -58,16 +60,15 @@ risk. Do not re-propose it. So a worktree install is a normal install. Budget for it; do not skip it. -## Worktrees live inside the repo, so repo-wide tooling must ignore them +## The in-repo ignore is a backstop, not the mechanism -A worktree under `.claude/worktrees/` is a complete second checkout nested in the tree. Anything -that walks the repo from the root will walk into it — linting or formatting an agent's -half-finished work, and failing on code you did not write. +`eslint.config.js` still ignores `.claude/worktrees/`, and `.gitignore` still lists it. With the +hooks in place nothing should land there — the ignores exist so that a worktree created by hand +in the old location, or by a tool that bypasses the hooks, cannot silently break a root lint. +Cheap insurance against a failure that is otherwise invisible. -`eslint.config.js` ignores `.claude/worktrees/` for exactly this reason. Verified as not -affected: prettier (its globs do not descend into dot-directories) and `tsc` (typecheck runs -per-package through turbo, not from the root). **If you add another repo-root tool that walks -the tree, give it the same ignore.** +Verified as unaffected by nesting either way: prettier (its globs do not descend into +dot-directories) and `tsc` (typecheck runs per-package through turbo, not from the root). ## Delegating to a background agent with worktree isolation @@ -87,12 +88,16 @@ the tree, give it the same ignore.** ## Cleaning up ```bash -git worktree remove .claude/worktrees/ # add --force if it has uncommitted changes -git worktree list # confirm +git worktree remove ../skills-worktrees/ # add --force if it has uncommitted changes +git worktree list # confirm ``` Removing the directory by hand leaves a stale registration; `git worktree prune` clears it. +To relocate an existing worktree, use `git worktree move ` — it rewrites the `.git` +pointers, and `node_modules` comes along, so no reinstall is needed. Moving the directory +yourself leaves the worktree pointing at a path that no longer exists. + ## Recovery: the main checkout got switched onto an agent's branch Your work is safe as long as it was pushed — confirm `origin/` and the PR head SHA diff --git a/.claude/hooks/worktree-create.sh b/.claude/hooks/worktree-create.sh new file mode 100755 index 00000000..1c4f1ec6 --- /dev/null +++ b/.claude/hooks/worktree-create.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# WorktreeCreate hook — place worktrees BESIDE the repo, not inside it. +# +# Default Claude Code behavior creates worktrees at /.claude/worktrees/. +# A worktree is a complete second checkout, so nesting it inside the repo means +# every tool that walks the tree from the root walks into it. We hit that: a root +# `eslint .` traversed 2983 files across two agent worktrees and failed on code an +# agent had half-written. An ignore rule patches one tool; placing worktrees +# outside the repo makes the whole class of problem impossible. +# +# Layout: /path/to/ -> /path/to/-worktrees/ +# +# Contract (docs: code.claude.com/docs/en/hooks): +# stdin - JSON with .worktree_id (and .base_path, .cwd, .session_id, ...) +# stdout - the absolute path of the created worktree, plain text, REQUIRED +# exit - non-zero, or zero with empty stdout, fails worktree creation +# This hook replaces the default logic entirely, so it must run `git worktree add`. +set -euo pipefail + +payload=$(cat) +worktree_id=$(printf '%s' "$payload" | jq -r '.worktree_id // empty') +if [ -z "$worktree_id" ]; then + echo "WorktreeCreate: no .worktree_id on stdin" >&2 + exit 1 +fi + +# Resolve against the repo this hook was invoked for, not $PWD. +cwd=$(printf '%s' "$payload" | jq -r '.cwd // empty') +repo_root=$(git -C "${cwd:-$PWD}" rev-parse --show-toplevel) +worktree_dir="$(dirname "$repo_root")/$(basename "$repo_root")-worktrees/$worktree_id" + +# Idempotent: an existing worktree at this path is returned as-is. +if [ -d "$worktree_dir" ]; then + echo "$worktree_dir" + exit 0 +fi + +mkdir -p "$(dirname "$worktree_dir")" + +# Mirror the default: a fresh branch per worktree, based on current HEAD. The +# agent checks out whatever branch it actually needs once inside. -B rather than +# -b so a leftover branch from a removed worktree does not wedge creation. +# All git chatter goes to stderr; stdout carries the path and nothing else. +git -C "$repo_root" worktree add -B "worktree-$worktree_id" "$worktree_dir" >&2 + +echo "$worktree_dir" diff --git a/.claude/hooks/worktree-remove.sh b/.claude/hooks/worktree-remove.sh new file mode 100755 index 00000000..86a1b0b1 --- /dev/null +++ b/.claude/hooks/worktree-remove.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# WorktreeRemove hook — counterpart to worktree-create.sh. +# +# Fires when a session ends, a subagent finishes, or a background session is +# deleted. Because WorktreeCreate replaced the default creation logic, this must +# do the corresponding removal. +# +# Contract (docs: code.claude.com/docs/en/hooks): +# stdin - JSON with .worktree_path +# stdout - ignored; this event is side-effect only +# exit - failures are logged in debug mode only and never surface to the user +# +# Because failures are invisible, this errs toward leaving things in place rather +# than deleting aggressively: uncommitted work in a worktree is real work, and a +# stale worktree is a cheap, visible problem (`git worktree list`) whereas +# silently discarded changes are not. +set -euo pipefail + +payload=$(cat) +worktree_path=$(printf '%s' "$payload" | jq -r '.worktree_path // empty') +[ -n "$worktree_path" ] || exit 0 +[ -d "$worktree_path" ] || exit 0 + +repo_root=$(git -C "$worktree_path" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | xargs dirname) || exit 0 + +# Refuse to discard uncommitted changes. `git worktree remove` without --force +# already refuses, but check explicitly so the reason is greppable in debug logs. +if [ -n "$(git -C "$worktree_path" status --porcelain 2>/dev/null)" ]; then + echo "WorktreeRemove: $worktree_path has uncommitted changes; leaving it in place" >&2 + exit 0 +fi + +git -C "$repo_root" worktree remove "$worktree_path" >&2 || { + echo "WorktreeRemove: could not remove $worktree_path; leaving it for manual cleanup" >&2 + exit 0 +} + +# Drop the per-worktree branch this hook's counterpart created, but only if it is +# fully merged — an unmerged branch may be the only reference to real work. +branch="worktree-$(basename "$worktree_path")" +git -C "$repo_root" branch -d "$branch" >&2 2>/dev/null || true + +# Remove the parent directory only when it is empty, so the sibling directory +# does not linger once the last worktree is gone. +rmdir "$(dirname "$worktree_path")" 2>/dev/null || true diff --git a/.claude/settings.json b/.claude/settings.json index 62c502ca..8ca4ed73 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -51,5 +51,28 @@ "**/credentials/**", "**/secrets/**" ] + }, + "hooks": { + "WorktreeCreate": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-create.sh\"", + "statusMessage": "Creating worktree beside the repo" + } + ] + } + ], + "WorktreeRemove": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-remove.sh\"" + } + ] + } + ] } } From 4a70b71535e6cf5299f96dc8e5a7c372e930cb33 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 28 Jul 2026 20:04:35 -0700 Subject: [PATCH 3/3] fix: validate worktree_id and verify idempotency against git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the create hook, all real: The id reached both a filesystem path and a git ref unvalidated. It comes from the harness, but a trust assumption is worth enforcing rather than assuming — `../` would escape the sibling directory and a leading `-` could be read as a flag. Now constrained to a simple slug, with `..` rejected outright. The idempotency check tested only that the directory existed, so a stray path from a partial cleanup or interrupted run would be returned as though creation had succeeded, handing the caller a directory git knows nothing about. It now asks `git worktree list --porcelain`, and a path that exists without being a registered worktree fails loudly. Also untangled `>&2 2>/dev/null` on the branch delete in the remove hook, which sent success to stderr and swallowed failures entirely. Both now go to stderr for debug logs; neither is fatal. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .claude/hooks/worktree-create.sh | 23 +++++++++++++++++++++-- .claude/hooks/worktree-remove.sh | 6 ++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/worktree-create.sh b/.claude/hooks/worktree-create.sh index 1c4f1ec6..994b9eda 100755 --- a/.claude/hooks/worktree-create.sh +++ b/.claude/hooks/worktree-create.sh @@ -25,17 +25,36 @@ if [ -z "$worktree_id" ]; then exit 1 fi +# The id reaches both a filesystem path and a git ref, so constrain it rather +# than trusting its origin: `..` would escape the sibling directory, a leading +# `-` could be read as a flag, and ref-invalid characters would fail the +# `worktree add` further down with a much less obvious error. The harness only +# ever sends simple slugs; this makes that assumption explicit and cheap to keep. +if ! [[ "$worktree_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || [[ "$worktree_id" == *..* ]]; then + echo "WorktreeCreate: refusing unsafe worktree_id '$worktree_id'" >&2 + exit 1 +fi + # Resolve against the repo this hook was invoked for, not $PWD. cwd=$(printf '%s' "$payload" | jq -r '.cwd // empty') repo_root=$(git -C "${cwd:-$PWD}" rev-parse --show-toplevel) worktree_dir="$(dirname "$repo_root")/$(basename "$repo_root")-worktrees/$worktree_id" -# Idempotent: an existing worktree at this path is returned as-is. -if [ -d "$worktree_dir" ]; then +# Idempotent, but only for a real worktree. A bare directory test would hand back +# a path that git knows nothing about — left by a partial cleanup, an interrupted +# run, or a stray mkdir — and the caller would treat creation as successful. +if git -C "$repo_root" worktree list --porcelain | grep -qxF "worktree $worktree_dir"; then echo "$worktree_dir" exit 0 fi +# A directory that is NOT a registered worktree is an unsafe place to add one: +# `git worktree add` would fail on a non-empty path anyway, so say why plainly. +if [ -e "$worktree_dir" ]; then + echo "WorktreeCreate: $worktree_dir exists but is not a registered worktree" >&2 + exit 1 +fi + mkdir -p "$(dirname "$worktree_dir")" # Mirror the default: a fresh branch per worktree, based on current HEAD. The diff --git a/.claude/hooks/worktree-remove.sh b/.claude/hooks/worktree-remove.sh index 86a1b0b1..74b2703f 100755 --- a/.claude/hooks/worktree-remove.sh +++ b/.claude/hooks/worktree-remove.sh @@ -37,9 +37,11 @@ git -C "$repo_root" worktree remove "$worktree_path" >&2 || { } # Drop the per-worktree branch this hook's counterpart created, but only if it is -# fully merged — an unmerged branch may be the only reference to real work. +# fully merged — `-d` (not `-D`) so an unmerged branch, which may be the only +# reference to real work, is left alone. Best-effort: both the "Deleted branch" +# notice and any refusal go to stderr for debug logs, and neither is fatal. branch="worktree-$(basename "$worktree_path")" -git -C "$repo_root" branch -d "$branch" >&2 2>/dev/null || true +git -C "$repo_root" branch -d "$branch" >&2 || true # Remove the parent directory only when it is empty, so the sibling directory # does not linger once the last worktree is gone.