From 44efb999e422c9c5de527b00a6bf00c36e58804e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 16:11:53 -0800 Subject: [PATCH 1/2] chore(quest): claim quest/m0/setup-worktree-hooks.md Quest-Claim: 7201893bb0702989 From c22fa50f6590be97699d4fdf0f6b2c75ec60b995 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 16:37:01 -0800 Subject: [PATCH 2/2] feat(setup): quest setup --worktrees installs the worktree hooks The README's pasted worktree hook JSON is gone. `quest setup --worktrees` installs WorktreeCreate, WorktreeRemove, SubagentStart, SubagentStop, SessionStart, and SessionEnd (60s timeout) entries running `quest worktree hook`, replacing entries pasted from an older README. A rerun keeps them; --no-worktrees or --remove takes them out. Co-Authored-By: Claude Opus 5.5 --- README.md | 37 +++++++-------- SETUP.md | 16 +++---- quest/m0/setup-worktree-hooks.md | 33 ------------- src/cli/hooks.ts | 80 +++++++++++++++++++++++++++----- src/cli/main.ts | 13 ++++-- test/hooks.test.ts | 40 +++++++++++++++- test/worktree.test.ts | 32 +------------ 7 files changed, 143 insertions(+), 108 deletions(-) delete mode 100644 quest/m0/setup-worktree-hooks.md diff --git a/README.md b/README.md index b3077d16..ca26c106 100644 --- a/README.md +++ b/README.md @@ -1649,27 +1649,25 @@ stolen. This initial interface refuses unknown or changed checkout ownership. ### Claude Code checkouts -Quest's hooks for Claude Code are an opt-in each user adds to their own -settings, `~/.claude/settings.json`, so they apply in every repository that -user opens and no repository forces them on its contributors. Merge these -entries into the file's `hooks`: - -```json -{ - "hooks": { - "WorktreeCreate": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], - "WorktreeRemove": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], - "SubagentStart": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], - "SubagentStop": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], - "SessionStart": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], - "SessionEnd": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook", "timeout": 60 }] }] - } -} +Quest's worktree hooks for Claude Code are an opt-in each user installs in their +own settings, so they apply in every repository that user opens and no +repository forces them on its contributors: + +```sh +quest setup --worktrees ``` +It adds `WorktreeCreate`, `WorktreeRemove`, `SubagentStart`, `SubagentStop`, +`SessionStart`, and `SessionEnd` entries running `quest worktree hook` to +[`quest setup`](#configure-claude-code)'s other changes, replacing worktree +entries pasted from an earlier README. Rerun `quest setup` after upgrading to +pick up new events; `--no-worktrees` or `--remove` takes them out. + Only one hook can manage Claude's worktrees, so remove any other `WorktreeCreate` or `WorktreeRemove` hook first. `quest` must be on the `PATH` -Claude Code runs hooks with. For `claude --bg` and agent view, that is the +Claude Code runs hooks with: start Claude with `nix develop --command claude` +in a repository whose dev shell provides it, or install it on that `PATH`. +For `claude --bg` and agent view, that is the environment of the shell that started the session. The hook then creates every worktree Claude makes in the repository: for `--worktree`, `EnterWorktree`, isolated subagents and workflow agents, background sessions, and `claude remote-control --spawn @@ -1784,8 +1782,9 @@ when the session compacts. Instead, `quest setup` installs a `SessionStart` hook (startup, resume, clear, and compact) and a `SubagentStart` hook, each running `quest hook`, which adds `quest context` to the session in a repository whose root `AGENTS.md` or `CLAUDE.md` has the `Quests: ` line, and -nothing elsewhere. `quest` must be on the `PATH` Claude Code starts with, as -for the worktree hooks. +nothing elsewhere. `quest` must be on the `PATH` Claude Code starts with. +`quest setup --worktrees` also installs the +[worktree hooks](#claude-code-checkouts). It edits `$CLAUDE_CONFIG_DIR/settings.json`, or `~/.claude/settings.json`, keeps your other settings, refuses a malformed file unchanged, writes once, diff --git a/SETUP.md b/SETUP.md index c56e8fd7..a3a1bf45 100644 --- a/SETUP.md +++ b/SETUP.md @@ -80,10 +80,10 @@ across the user's repositories, through hooks in the user's own Claude Code settings rather than the repository's, since repository settings apply to every contributor. Offer them, explaining that they then apply in every repository the user opens in Claude Code and need `quest` on the `PATH` -Claude Code runs hooks with. With consent, merge the entries in -[Claude Code checkouts](README.md#claude-code-checkouts) into -`~/.claude/settings.json`, keeping its other settings. If it already has a -`WorktreeCreate` or `WorktreeRemove` hook, ask the user which to keep. +Claude Code runs hooks with. If `~/.claude/settings.json` already has a +`WorktreeCreate` or `WorktreeRemove` hook, ask the user which to keep. With +consent, run `quest setup --worktrees`, which keeps the file's other settings; +see [Claude Code checkouts](README.md#claude-code-checkouts). ## Adapt the instructions @@ -133,10 +133,8 @@ work or `/quest-import` to bring in open GitHub issues. shell package. For an unpinned install, delete the `quest` binary the installer placed (`~/.local/bin/quest` by default, or `quest.exe` in the chosen Windows install directory). Remove any PATH entry you added. If the - user opted into Quest's worktree hooks and Claude Code no longer finds - `quest`, remove those entries from `~/.claude/settings.json`, since without - it they stop Claude Code making worktrees. Before removing the binary, run - `quest setup --remove` if the user ran `quest setup` and uses Quest in no - other repository. + user uses Quest in no other repository, run `quest setup --remove` before + removing the binary, since Quest's worktree hooks stop Claude Code making + worktrees once it cannot find `quest`. 4. Ask the user whether to delete `quest/`. Git history keeps the plans either way. diff --git a/quest/m0/setup-worktree-hooks.md b/quest/m0/setup-worktree-hooks.md deleted file mode 100644 index 38b03477..00000000 --- a/quest/m0/setup-worktree-hooks.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] quest setup installs the worktree hooks - -## Goal - -`quest setup` installs Quest's Claude Code worktree hooks with the rest of -Quest's user-level hooks, so a user enables them and later picks up new -events by rerunning one command. The README's pasted hook JSON is gone. - -## Plan - -Decided 2026-11-07 (coordinator, follow-up of -[moq-dev/quest#556](https://github.com/moq-dev/quest/pull/556)): Quest -installs user-level hooks two ways. `quest setup` writes the `SessionStart` and -`SubagentStart` rule hooks from the `HOOKS` table in `src/cli/hooks.ts` -([moq-dev/quest#517](https://github.com/moq-dev/quest/pull/517)). The worktree -hooks are JSON users paste from the README's "Claude Code checkouts" section. -#556 added `SessionStart` and `SessionEnd` worktree entries, so every user had -to re-paste them, and -[moq-dev/quest#544](https://github.com/moq-dev/quest/pull/544) had to guard -each pasted command against a missing `quest`. Quest is internal tooling for -the maintainer's own hosts, so one installer is enough. - -- Add the worktree events to `quest setup`, behind an opt-in such as - `--worktrees`, since a worktree hook changes where every session runs. The - rule hooks stay on by default. `quest hook` (or `quest worktree hook`) - dispatches every event. Rerunning replaces Quest's earlier entries, and - `--remove` takes them all out. -- Delete the README's pasted JSON and point at `quest setup --worktrees`. - Keep #544's message for a missing `quest`, either in the installed command - or in `quest setup`'s output, whichever is simpler. -- Migrate the maintainer's own hosts: entries that `quest setup` didn't write - but that run `quest worktree hook` are recognized as Quest's and replaced, not - duplicated. diff --git a/src/cli/hooks.ts b/src/cli/hooks.ts index fbd099cf..18bcec34 100644 --- a/src/cli/hooks.ts +++ b/src/cli/hooks.ts @@ -11,17 +11,23 @@ import { dirname, join } from "node:path"; import { REFERENCE_MARKER, lines } from "../core"; import { CONTEXT } from "./skills"; +import { HOOK_EVENTS } from "./worktree-hook"; -/** The command every Quest entry runs; `quest setup` recognizes its own entries by it. */ +/** The command the rule hooks run. */ export const HOOK_COMMAND = "quest hook"; +/** The command the worktree hooks run; README-pasted entries ending in it are recognized too. */ +export const WORKTREE_COMMAND = "quest worktree hook"; + /** Claude Code truncates longer hook context to a preview and a file path it never asks Claude to read. */ export const CONTEXT_LIMIT = 10_000; -/** One installed entry: the event, and the matcher narrowing it, if any. */ +/** One installed entry: the event, the matcher narrowing it, the command (`HOOK_COMMAND` by default), and its timeout in seconds. */ export interface HookEntry { event: string; matcher?: string; + command?: string; + timeout?: number; } /** @@ -35,6 +41,17 @@ export const HOOKS: readonly HookEntry[] = [ { event: "SubagentStart" }, ]; +/** + * The entries `quest setup --worktrees` adds, handing Claude Code's worktrees, + * and the subagent and session lifecycles that hold them, to `quest worktree`. + * Claude Code gives SessionEnd hooks at most a minute, which its timeout asks for. + */ +export const WORKTREE_HOOKS: readonly HookEntry[] = HOOK_EVENTS.map((event) => ({ + event, + command: WORKTREE_COMMAND, + ...(event === "SessionEnd" ? { timeout: 60 } : {}), +})); + /** * Claude Code's cap on a foreground command's timeout: 55 minutes, just under the * 1h prompt-cache TTL, so a blocking wait returns while the session's cache is warm. @@ -51,13 +68,18 @@ type Json = Record; /** * Set Quest's settings in the Claude Code settings file at `path`: the - * foreground timeout cap and `entries` as Quest's hooks. With `remove`, take + * foreground timeout cap and `entries` as Quest's hooks, by default the rule + * hooks plus the worktree hooks when `worktrees` is true, or when it is + * unset and the file already has them. With `remove`, take * Quest's hooks out, and the cap while it still holds Quest's value. Keeps * every other setting and hook, and writes once. Returns a line per change. A * file that is not a JSON object, or whose `env` or `hooks` is malformed, * throws before anything is written. */ -export function setup(path: string, options: { remove?: boolean; entries?: readonly HookEntry[] } = {}): string[] { +export function setup( + path: string, + options: { remove?: boolean; worktrees?: boolean; entries?: readonly HookEntry[] } = {}, +): string[] { const original = existsSync(path) ? readFileSync(path, "utf8") : null; let settings: unknown = {}; if (original !== null) { @@ -70,7 +92,9 @@ export function setup(path: string, options: { remove?: boolean; entries?: reado if (!isObject(settings)) throw new Error(`${path} does not hold a JSON object`); const env = settings.env ?? {}; if (!isObject(env)) throw new Error(`${path}: env is not an object`); - const hooked = withHooks(settings, options.remove ? [] : (options.entries ?? HOOKS), path); + const worktrees = options.worktrees ?? hasWorktreeHooks(settings); + const entries = options.entries ?? (worktrees ? [...HOOKS, ...WORKTREE_HOOKS] : HOOKS); + const hooked = withHooks(settings, options.remove ? [] : entries, path); const changes: string[] = []; let updated = hooked; @@ -117,17 +141,21 @@ function withHooks(settings: Json, entries: readonly HookEntry[], path: string): const only = group.hooks.length === 1 && Object.keys(group).every((key) => ["matcher", "hooks"].includes(key)); if (only && (group.matcher === undefined || typeof group.matcher === "string")) - installed.push({ event, ...(group.matcher === undefined ? {} : { matcher: group.matcher as string }) }); + installed.push({ + event, + ...(group.matcher === undefined ? {} : { matcher: group.matcher as string }), + ...handlerEntry(group.hooks[0] as Json & { command: string }), + }); else scattered = true; return handlers.length > 0 ? [{ ...group, hooks: handlers }] : []; }); if (left.length > 0 || groups.length === 0) kept[event] = left; } if (!scattered && sameEntries(installed, entries)) return settings; - for (const { event, matcher } of entries) { + for (const { event, matcher, command = HOOK_COMMAND, timeout } of entries) { const group = { ...(matcher === undefined ? {} : { matcher }), - hooks: [{ type: "command", command: HOOK_COMMAND }], + hooks: [{ type: "command", command, ...(timeout === undefined ? {} : { timeout }) }], }; kept[event] = [...((kept[event] as unknown[] | undefined) ?? []), group]; } @@ -137,13 +165,41 @@ function withHooks(settings: Json, entries: readonly HookEntry[], path: string): return rest; } -/** Whether a hook handler is one `quest setup` wrote. */ -function isOurs(handler: unknown): boolean { - return isObject(handler) && handler.type === "command" && handler.command === HOOK_COMMAND; +/** Whether a hook handler is Quest's: one `quest setup` wrote, or a worktree entry pasted from an older README. */ +function isOurs(handler: unknown): handler is Json & { command: string } { + return ( + isObject(handler) && + handler.type === "command" && + typeof handler.command === "string" && + (handler.command === HOOK_COMMAND || handler.command.endsWith(WORKTREE_COMMAND)) + ); +} + +/** The command and timeout of Quest's `handler`, with any other field making it differ from every entry. */ +function handlerEntry(handler: Json & { command: string }): Pick { + const extra = Object.keys(handler).some((key) => !["type", "command", "timeout"].includes(key)); + const timeout = typeof handler.timeout === "number" ? handler.timeout : undefined; + return { command: extra ? `${handler.command}\0` : handler.command, ...(timeout === undefined ? {} : { timeout }) }; +} + +/** Whether `settings` holds any of Quest's worktree hooks. */ +function hasWorktreeHooks(settings: Json): boolean { + if (!isObject(settings.hooks)) return false; + return Object.values(settings.hooks).some( + (groups) => + Array.isArray(groups) && + groups.some( + (group) => + isObject(group) && + Array.isArray(group.hooks) && + group.hooks.some((handler) => isOurs(handler) && handler.command.endsWith(WORKTREE_COMMAND)), + ), + ); } function sameEntries(a: readonly HookEntry[], b: readonly HookEntry[]): boolean { - const key = (entry: HookEntry) => `${entry.event}\0${entry.matcher ?? ""}`; + const key = (entry: HookEntry) => + [entry.event, entry.matcher ?? "", entry.command ?? HOOK_COMMAND, entry.timeout ?? ""].join("\0"); const left = a.map(key).sort(); const right = b.map(key).sort(); return left.length === right.length && left.every((value, i) => value === right[i]); diff --git a/src/cli/main.ts b/src/cli/main.ts index 61cf9a7e..8f178f4f 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -64,7 +64,7 @@ import { roundLines, } from "./review"; import { type Recorded, pullModels, recordRound } from "./review-record"; -import { BASH_MAX_TIMEOUT_MS, HOOKS, hook, settingsPath, setup } from "./hooks"; +import { BASH_MAX_TIMEOUT_MS, HOOKS, WORKTREE_HOOKS, hook, settingsPath, setup } from "./hooks"; import { heal, init, uninstall } from "./setup"; import { Refusal, describe, notUpstream, releaseQuest, startQuest } from "./start"; import { CONTEXT, GUIDE, GUIDE_SECTIONS, SKILLS, guideSections, sectionHeading, skill } from "./skills"; @@ -891,7 +891,7 @@ export function main(argv: string[], out: Output): number | Promise { .description( "Handle the Claude Code hook event whose JSON input arrives on stdin, for " + HOOK_EVENTS.join(", ") + - ". Add it to your own Claude Code settings for those events to opt in, as the README describes.\n\n" + + ". `quest setup --worktrees` installs it for those events.\n\n" + "WorktreeCreate checks out the remote's default branch as last fetched (origin/HEAD), as Claude does by " + "default, or the session's commit without one, as a quest worktree under the main checkout's .worktrees/, " + "initializes its submodules recursively, and prints its path. A name pr-, which Claude gives the worktree for --worktree '#' or a " + @@ -1413,12 +1413,19 @@ export function main(argv: string[], out: Output): number | Promise { "which add `quest context` to each session and subagent in a repository that uses Quest, and again " + "after compaction, so agents never run it themselves. Each runs `quest hook`, so `quest` must be on " + "the PATH Claude Code starts with.\n\n" + + "With --worktrees, also installs " + + WORKTREE_HOOKS.map((h) => h.event).join(", ") + + " hooks running `quest worktree hook`, which hand Claude Code's worktrees to `quest worktree`; " + + "--no-worktrees takes them out, and without either a rerun keeps them as they are. Only one hook " + + "can manage Claude's worktrees, so remove any other WorktreeCreate or WorktreeRemove hook first.\n\n" + "Validates the whole file, then writes it once and prints each change; settings that are not a JSON " + "object, or whose env or hooks are malformed, are an error and stay unchanged. Rerun it after " + "upgrading Quest; it replaces Quest's earlier hooks and changes nothing when all is set.", ) + .option("--worktrees", "Also install the worktree hooks") + .option("--no-worktrees", "Remove the worktree hooks") .option("--remove", "Remove Quest's hooks, and env.BASH_MAX_TIMEOUT_MS while it still holds Quest's value") - .action((options: { remove?: boolean }) => { + .action((options: { remove?: boolean; worktrees?: boolean }) => { for (const line of setup(settingsPath(), options)) print(line); }); diff --git a/test/hooks.test.ts b/test/hooks.test.ts index 221a8a29..d45bda33 100644 --- a/test/hooks.test.ts +++ b/test/hooks.test.ts @@ -6,7 +6,16 @@ import { join } from "node:path"; import { expect, test } from "vitest"; import { REFERENCE_LINE } from "../src/core"; -import { BASH_MAX_TIMEOUT_MS, HOOKS, HOOK_COMMAND, hook, settingsPath, setup } from "../src/cli/hooks"; +import { + BASH_MAX_TIMEOUT_MS, + HOOKS, + HOOK_COMMAND, + WORKTREE_COMMAND, + WORKTREE_HOOKS, + hook, + settingsPath, + setup, +} from "../src/cli/hooks"; import { CONTEXT } from "../src/cli/skills"; import { tempDir } from "./fixture"; @@ -92,6 +101,35 @@ test("removing Quest's only settings drops the hooks and env keys", () => { expect(setup(path, { remove: true })).toEqual([]); }); +test("setup installs the worktree hooks on request, keeps them on rerun, and replaces pasted ones", () => { + const path = join(tempDir(), "settings.json"); + const pasted = { type: "command", command: `command -v quest >/dev/null || exit 1; ${WORKTREE_COMMAND}` }; + const worktree = { type: "command", command: WORKTREE_COMMAND }; + writeFileSync( + path, + JSON.stringify({ + hooks: { WorktreeCreate: [{ hooks: [pasted] }], SessionEnd: [{ hooks: [{ ...pasted, timeout: 60 }] }] }, + }), + ); + setup(path); + const settings = read(path); + expect(settings.hooks.WorktreeCreate).toEqual([{ hooks: [worktree] }]); + expect(settings.hooks.SessionEnd).toEqual([{ hooks: [{ ...worktree, timeout: 60 }] }]); + expect(settings.hooks.SessionStart).toEqual([{ matcher: HOOKS[0]!.matcher, hooks: [ours] }, { hooks: [worktree] }]); + expect(Object.keys(settings.hooks).sort()).toEqual( + [...new Set([...HOOKS, ...WORKTREE_HOOKS].map((h) => h.event))].sort(), + ); + expect(setup(path)).toEqual([]); + expect(setup(path, { worktrees: true })).toEqual([]); + + expect(setup(path, { worktrees: false })).toEqual([`${path}: installed Quest's hooks`]); + expect(Object.keys(read(path).hooks)).toEqual(HOOKS.map((h) => h.event)); + expect(setup(path)).toEqual([]); + expect(setup(path, { worktrees: true })).toEqual([`${path}: installed Quest's hooks`]); + setup(path, { remove: true }); + expect(read(path)).toEqual({}); +}); + test.each([ ["invalid JSON", "{ nope"], ["a JSON array", "[]"], diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 2b937a10..ad7c0245 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -21,7 +21,7 @@ import { type Owner, Retained, type Scopes, currentOwner, kill, killable } from import { GitHub, mergedHead } from "../src/cli/github"; import { main } from "../src/cli/main"; import { Worktrees, canonical, derivedDefaults, diskUsage } from "../src/cli/worktree"; -import { HOOK_EVENTS, claudeHook } from "../src/cli/worktree-hook"; +import { claudeHook } from "../src/cli/worktree-hook"; import { initRepository, run, template } from "./fixture"; const source = dirname(dirname(fileURLToPath(import.meta.url))); @@ -1224,34 +1224,4 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { ).status, ).toBe(1); }); - it.skipIf(process.platform === "win32")("the documented entries say so when quest is not on PATH", () => { - const readme = readFileSync(join(source, "README.md"), "utf8"); - const json = [...readme.matchAll(/```json\n(.*?)```/gs)] - .map((match) => match[1]) - .find((text) => text.includes('"WorktreeCreate"')); - if (json === undefined) throw new Error("README has no hook settings block"); - const { hooks } = JSON.parse(json) as { hooks: Record }; - const commands = HOOK_EVENTS.map((event) => hooks[event][0].hooks[0].command); - expect(new Set(commands).size).toBe(1); - const [command] = commands; - const run = (path: string, input = "") => - spawnSync("/bin/sh", ["-c", command], { input, encoding: "utf8", env: { PATH: path } }); - const dir = (): string => { - const root = mkdtempSync(join(tmpdir(), "quest-hook-")); - roots.push(root); - return root; - }; - const missing = run(dir()); - expect(missing.status).toBe(1); - expect(missing.stdout).toBe(""); - expect(missing.stderr).toBe( - "quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH\n", - ); - const bin = dir(); - writeFileSync(join(bin, "quest"), '#!/bin/sh\necho "quest $*"\ncat\n', { mode: 0o755 }); - const input = JSON.stringify({ hook_event_name: "WorktreeCreate", cwd: "/repo", name: "agent-1" }); - const present = run(`${bin}:${process.env.PATH}`, input); - expect(present.status).toBe(0); - expect(present.stdout).toBe(`quest worktree hook\n${input}`); - }); });