SLOPSHOPPER

worktree

/wt creates a worktree for the work under discussion and moves the session into it, without a turn of the main model.

newcommandmodelprocesstimer
v0.1.0no licenseupdated 2026-10-02qiushiyan/dotfiles/claude/.claude/mods/worktree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worktree
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /wt ⎿ worktree: The reply named no usable branch: ⎿ worktree: OK ⎿ worktree: Name one: /wt <branch> [base]. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
Source 2 files
hooks/register.ts 79 lines
1// worktree: /wt puts the session in a new worktree without a turn of the main
2// model. A fork of the session picks the branch (the same model over the same
3// conversation, served from the prompt cache) and gwt creates it. Then, once
4// the command has answered, the built-in /cd moves the session and the
5// instruction typed with /wt is submitted as the person's next prompt, so
6// work starts in the worktree at once.
7//
8// The enter-worktree skill does the same through a full turn and cannot go on
9// in that turn, since its move lands when the turn ends; it stays for Codex.
10
11import type { EngineInterface, Register } from 'claude-code'
12
13import { createdText, namePrompt, parsePlan, typed } from './plan'
14import type { Plan } from './plan'
15
16async function recentBranches($: EngineInterface): Promise<string[]> {
17  const ran = await $.process
18    .run(['git', 'for-each-ref', '--sort=-committerdate', '--count=20', '--format=%(refname:short)', 'refs/heads'])
19    .catch(() => undefined)
20  return ran === undefined || ran.exitCode !== 0 ? [] : ran.stdout.split('\n').filter(line => line !== '')
21}
22
23async function choose($: EngineInterface, instruction: string): Promise<Plan | string> {
24  const reply = await $.model.fork({ prompt: namePrompt(instruction, await recentBranches($)) })
25  if (!reply.isAnswered) {
26    return reply.reason === 'nothing-to-fork'
27      ? 'There is no conversation yet to name a branch from. Name one: /wt <branch> [base].'
28      : `No branch name came back (${reply.reason}). Name one: /wt <branch> [base].`
29  }
30  return parsePlan(reply.text) ?? `The reply named no usable branch:\n${reply.text.trim()}\nName one: /wt <branch> [base].`
31}
32
33/**
34 * Moves the session, then hands over the instruction. It runs from a timer:
35 * the engine refuses a command run from inside a `command.run` hook, which
36 * would wait on the turn that hook is holding.
37 */
38async function enter($: EngineInterface, plan: Plan, path: string, instruction: string) {
39  const refusal = await $.command.run({ command: 'cd', args: path }).then(
40    () => undefined,
41    (error: unknown) => String(error),
42  )
43  if (refusal !== undefined) {
44    $.ui.log(`worktree: the session did not move (${refusal}). Run /cd ${path}`)
45    return
46  }
47  if (plan.shouldContinue) await $.prompt.submit({ text: instruction, asUser: true })
48}
49
50export const register: Register = on => {
51  on('session.start', async ($, e, next) => {
52    await $.command.register({
53      name: 'wt',
54      description: 'Create a worktree for the work under discussion and move the session into it',
55      argumentHint: '[<branch> [base] | what to do once there]',
56    })
57
58    return next(e)
59  })
60
61  on('command.run', { command: 'wt' }, async ($, e) => {
62    const instruction = e.args.trim()
63    const plan = typed(instruction) ?? (await choose($, instruction))
64    if (typeof plan === 'string') return { text: plan }
65
66    const argv = ['gwt', 'create', '--non-interactive', plan.branch, ...(plan.base === undefined ? [] : [plan.base])]
67    const made = await $.process.run(argv, { timeoutMs: 120_000 }).catch((error: unknown) => String(error))
68    if (typeof made === 'string') return { text: `gwt did not run: ${made}` }
69    const path = made.stdout.trim().split('\n').at(-1) ?? ''
70    if (made.exitCode !== 0 || path === '') {
71      return { text: `gwt could not create ${plan.branch}:\n${made.stderr.trim()}` }
72    }
73
74    $.clock.after(0, () => void enter($, plan, path, instruction))
75
76    return { text: createdText(plan, path) }
77  })
78}
79
hooks/plan.ts 63 lines
1// The mod's pure half: reading a branch out of what the person typed or out of
2// the fork's reply, and the prompts either side reads.
3
4export type Plan = { branch: string; base?: string; shouldContinue: boolean }
5
6const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
7const isRef = (text: string): boolean => REF.test(text) && !text.includes('..') && !text.endsWith('/')
8
9/**
10 * The plan when the arguments are a branch and at most a base, typed out. A
11 * branch typed out carries a slash: one bare word is an instruction ("go").
12 */
13export function typed(args: string): Plan | undefined {
14  const [branch, base, ...more] = args.trim().split(/\s+/)
15  if (branch === undefined || more.length > 0 || !branch.includes('/') || !isRef(branch)) return undefined
16  if (base !== undefined && !isRef(base)) return undefined
17  return base === undefined ? { branch, shouldContinue: false } : { branch, base, shouldContinue: false }
18}
19
20export function namePrompt(instruction: string, branches: readonly string[]): string {
21  return `The user ran /wt to move this session into a new git worktree for the work this conversation has settled on. Their instruction with the command:
22
23<instruction>
24${instruction === '' ? '(none)' : instruction}
25</instruction>
26
27Choose the branch. Its name describes the goal and scope of that work and follows this repository's naming conventions, which the most recent branches show:
28
29<recent_branches>
30${branches.length === 0 ? '(none)' : branches.join('\n')}
31</recent_branches>
32
33Name a base only when the conversation chose one; with none, the tool applies its configured default.
34
35Reply with exactly these three lines and nothing else:
36branch: <the branch name>
37base: <a ref, or none>
38continue: <yes when the instruction asks for work beyond creating or entering the worktree, else no>`
39}
40
41const field = (reply: string, name: string): string | undefined =>
42  new RegExp(`^\\s*${name}:\\s*\`?([^\\s\`]+)\`?\\s*$`, 'im').exec(reply)?.[1]
43
44/** The fork's three lines as a plan; undefined when the branch is missing or not a ref. */
45export function parsePlan(reply: string): Plan | undefined {
46  const branch = field(reply, 'branch')
47  const base = field(reply, 'base')
48  if (branch === undefined || !isRef(branch)) return undefined
49  const shouldContinue = field(reply, 'continue')?.toLowerCase() === 'yes'
50  return base === undefined || base.toLowerCase() === 'none' || !isRef(base)
51    ? { branch, shouldContinue }
52    : { branch, base, shouldContinue }
53}
54
55/** What the transcript, and so the model, reads as the command's output: the move itself comes next. */
56export function createdText(plan: Plan, path: string): string {
57  const from = plan.base === undefined ? '' : `, from ${plan.base}`
58  const next = plan.shouldContinue
59    ? ' The instruction given with /wt then follows as the next message; its worktree part is done.'
60    : ''
61  return `Worktree ${plan.branch} created at ${path}${from}. The session moves there now.${next}`
62}
63