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

hooks/register.ts 79 lines1// 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}
79hooks/plan.ts 63 lines1// 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