Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 18 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 7 additions & 9 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
33 changes: 0 additions & 33 deletions quest/m0/setup-worktree-hooks.md

This file was deleted.

80 changes: 68 additions & 12 deletions src/cli/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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.
Expand All @@ -51,13 +68,18 @@ type Json = Record<string, unknown>;

/**
* 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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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];
}
Expand All @@ -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<HookEntry, "command" | "timeout"> {
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]);
Expand Down
13 changes: 10 additions & 3 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -891,7 +891,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
.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-<number>, which Claude gives the worktree for --worktree '#<number>' or a " +
Expand Down Expand Up @@ -1413,12 +1413,19 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"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);
});

Expand Down
40 changes: 39 additions & 1 deletion test/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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", "[]"],
Expand Down
Loading
Loading