diff --git a/README.md b/README.md index 3cf4c75c..9de74eeb 100644 --- a/README.md +++ b/README.md @@ -1335,7 +1335,7 @@ The experimental `quest worktree` commands share one disk budget across this user's repositories and release a checkout's disk by deleting its declared scratch in place. They only operate on Git worktrees they create; use the owning application for Codex or other managed checkouts. Claude Code's -checkouts are Quest's own once `quest init` installs its hooks (see +checkouts are Quest's own once you opt into its hooks (see [Claude Code checkouts](#claude-code-checkouts)). Set a shared capacity and an explicit reservation in bytes, covering the @@ -1436,9 +1436,24 @@ stolen. This initial interface refuses unknown or changed checkout ownership. ### Claude Code checkouts -`quest init` adds hook entries to the repository's `.claude/settings.json` that -run `quest worktree hook` for Claude Code's `WorktreeCreate`, `WorktreeRemove`, -`SubagentStart`, and `SubagentStop` events, so `quest` must be on the `PATH` +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": "quest worktree hook" }] }], + "WorktreeRemove": [{ "hooks": [{ "type": "command", "command": "quest worktree hook" }] }], + "SubagentStart": [{ "hooks": [{ "type": "command", "command": "quest worktree hook" }] }], + "SubagentStop": [{ "hooks": [{ "type": "command", "command": "quest worktree hook" }] }] + } +} +``` + +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 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 diff --git a/SETUP.md b/SETUP.md index cdab05f7..5c7326ad 100644 --- a/SETUP.md +++ b/SETUP.md @@ -48,10 +48,7 @@ without creating symbolic links (existing linked layouts continue to work), crea starting with `Quests: ` to the root `AGENTS.md`, or `CLAUDE.md` when only that exists. It also appends `quest/** merge=quest` to `.gitattributes` and sets `merge.quest.driver` in the clone's git config, so local merges resolve -concurrent edits to quest link lists. It adds hook entries running -`quest worktree hook` to `.claude/settings.json`, keeping the file's other -settings, so Quest creates and reclaims Claude Code's worktrees; Claude Code -then needs `quest` on its `PATH` to create any worktree in the repository. It +concurrent edits to quest link lists. It writes no Claude Code hooks. It prints each path it changed and is safe to rerun. Rerun after an upgrade, it also rewrites the unmodified stubs whose description or setup link changed and removes the unmodified stubs of skills the new version no longer ships. @@ -59,6 +56,18 @@ removes the unmodified stubs of skills the new version no longer ships. If init refuses, it names the conflicting path. Report it and ask the user how to proceed; never delete or rename their files to get past it. +## Opt into worktree hooks + +Quest can create and reclaim Claude Code's worktrees, sharing one disk budget +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. + ## Adapt the instructions Read the root instructions with the new line in place: @@ -102,11 +111,13 @@ work or `/quest-import` to bring in open GitHub issues. 1. Run `quest skill export` and follow it, so active quests become GitHub issues. Skip this only if the user says so. 2. Run `quest uninstall`. It removes only unmodified stubs of any version, the - `Quests: ` line, Quest's hook entries, and the merge driver, and never - deletes the quest tree. + `Quests: ` line, and the merge driver, and never deletes the quest tree. 3. Remove the pin added above: the mise entry, or the flake input and dev 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. + 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. 4. Ask the user whether to delete `quest/`. Git history keeps the plans either way. diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index f0a8ef27..7f53d50a 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -607,12 +607,12 @@ Submitting it opens the App's setup pull request, with no runner involved `quest init` applies to a checkout, plans it from the default branch's tree: the skill stubs this deployment's `quest` ships (rendered into `build/stubs.json` at build time), `quest/README.md` with the answer as its -Goal, the `Quests: ` line in the root agent instructions, the `.gitattributes` -line, and Claude Code's hook entries in `.claude/settings.json`. Heading +Goal, the `Quests: ` line in the root agent instructions, and the +`.gitattributes` line. Heading syntax in the answer is escaped, and the planned `quest/README.md` must pass `quest check`, so a goal that would fail it, such as one linking a missing quest, is refused before anything is written instead of when enabling. A path init would refuse, such as a skill -directory that is not a Quest stub or invalid settings JSON, is named on the +directory that is not a Quest stub, is named on the page and nothing is written; kix never deletes or renames a repository's files. The plan does not follow symlinks, since a Git tree stores their targets as data: one on a planned path is refused the same way, pointing at running diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index 31db0831..d9d6c1a8 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -1462,7 +1462,7 @@ export class GitHubService { mode: "100644" as const, })); const message = - "chore: set up Quest\n\nWrites what `quest init` installs: skill stubs, quest/README.md, the agent\ninstructions' Quests line, the merge driver's .gitattributes line, and\nClaude Code's worktree hooks.\n"; + "chore: set up Quest\n\nWrites what `quest init` installs: skill stubs, quest/README.md, the agent\ninstructions' Quests line, and the merge driver's .gitattributes line.\n"; // A retry builds on the default branch's files, but keeps kix's earlier commit as a parent, // so the branch only fast-forwards: a push since the check above makes it fail rather than vanish. const commit = diff --git a/cloudflare/src/github/setup.ts b/cloudflare/src/github/setup.ts index 0938c722..8d860e23 100644 --- a/cloudflare/src/github/setup.ts +++ b/cloudflare/src/github/setup.ts @@ -27,7 +27,7 @@ export type TreeEntry = { path: string; type: string; mode: string; sha: string /** Whether the plan may read the file at `path`, so the service fetches it with the tree. */ export function planned(path: string): boolean { - if (["AGENTS.md", "CLAUDE.md", ".gitattributes", ".claude/settings.json"].includes(path)) return true; + if (["AGENTS.md", "CLAUDE.md", ".gitattributes"].includes(path)) return true; return SKILL_DIRS.some((dir) => { const rest = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1).split("/") : []; return rest.length === 2 && rest[0]!.startsWith("quest-") && rest[1] === "SKILL.md"; @@ -124,7 +124,6 @@ function purpose(change: InitChange): string { if (path.endsWith("/SKILL.md")) return "a skill stub"; if (path === "quest/README.md") return "the quest tree's root, with the project's goal"; if (path === ".gitattributes") return "routes `quest/**` through the `quest` merge driver"; - if (path === ".claude/settings.json") return "Claude Code's worktree hooks, which run `quest worktree hook`"; return "points agents at `quest context`"; } diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx index 3a8d9b69..8b2ff71d 100644 --- a/cloudflare/src/projects.tsx +++ b/cloudflare/src/projects.tsx @@ -259,8 +259,7 @@ export function QuestSetupPage(props: {

This repository has no Quest files on its default branch yet. The App opens a pull request with what{" "} quest init writes: skill stubs, quest/README.md, one line in the agent - instructions, a .gitattributes line, and Claude Code's worktree hooks. Contributions - are enabled when you merge it. + instructions, and a .gitattributes line. Contributions are enabled when you merge it.

diff --git a/cloudflare/test/setup.test.ts b/cloudflare/test/setup.test.ts index 9b933e44..43d49951 100644 --- a/cloudflare/test/setup.test.ts +++ b/cloudflare/test/setup.test.ts @@ -2,7 +2,7 @@ // the setting-up state, and enabling once it merges, against the fake GitHub. import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { HOOK_COMMAND, REFERENCE_LINE } from "quest/core"; +import { REFERENCE_LINE } from "quest/core"; import { app } from "../src/index"; import { STUBS, planCheck, setupGoal, treeCheckout } from "../src/github/setup"; import { type Pull, type Repo, type World, fakeGitHub, intercept } from "../dev/github"; @@ -115,7 +115,7 @@ describe("the setup pull request", () => { expect(files.get("quest/README.md")).toBe("# Quests\n\n## Goal\n\nShip a fast parser.\n\\# Not a heading\n"); expect(files.get("AGENTS.md")).toBe(`# Agents\n\nRun the tests.\n\n${REFERENCE_LINE}\n`); expect(files.get(".gitattributes")).toBe("quest/** merge=quest\n"); - expect(files.get(".claude/settings.json")).toContain(HOOK_COMMAND); + expect(files.has(".claude/settings.json")).toBe(false); expect(await setup()).toMatchObject({ state: "open", pull: pull!.number, enrolledBy: user, pending: null }); expect(await enrolled()).toBeNull(); diff --git a/quest/m0/README.md b/quest/m0/README.md index 92d57161..47f2e4b3 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -72,13 +72,13 @@ ownership-manifest design: - The binary carries the skills, the quest contract, and all hook logic. `quest init` installs only static stubs into a repository: one-line skill - stubs, one reference line in its root instructions, one `.gitattributes` - line, and Claude hook entries in `.claude/settings.json` that only call a - `quest` subcommand. Upgrading never rewrites repository files. (Amended - 2026-10-26 (user), replacing "Nothing is vendored", so `quest init` can - install the agent lifecycle hooks. Amended 2026-11-01 (user, /quest-plan): - `quest init` also rewrites a Quest-written stub nobody edited when its - text is out of date.) + stubs, one reference line in its root instructions, and one + `.gitattributes` line. Upgrading never rewrites repository files. (Amended + 2026-11-01 (user, /quest-plan): `quest init` also rewrites a Quest-written + stub nobody edited when its text is out of date. Amended 2026-11-03 + (maintainer, /quest-unblock): `quest init` writes no Claude hook entries; + Quest's worktree hooks are a user-level opt-in, see + [user-dev-shell](/quest/m0/user-dev-shell/README.md).) - The repository's own tool manager pins the version: mise (release binaries) or a nix flake input (any rev, built from source). Quest ships no launcher and no pin file; an install outside either is simply unpinned. diff --git a/quest/m0/session-checkouts.md b/quest/m0/session-checkouts.md index 3c9cbce8..b3756c3e 100644 --- a/quest/m0/session-checkouts.md +++ b/quest/m0/session-checkouts.md @@ -24,10 +24,11 @@ Evidence, gathered 2026-11-02: Work: - Add `SessionEnd` and `SessionStart` entries to the opt-in user-level hook - setup the docs describe. Amended 2026-11-03 (maintainer, /quest-unblock): - `quest init` writes no hook entries anymore - ([quest init writes no hooks](/quest/m0/user-dev-shell/init-hooks.md)), - so this quest no longer installs them through `init`. + setup: the JSON block in the README's "Claude Code checkouts" section, which + SETUP.md's "Opt into worktree hooks" step points to, and `HOOK_EVENTS` in + `src/cli/worktree-hook.ts`. Amended 2026-11-03 (maintainer, /quest-unblock): + `quest init` writes no hook entries anymore, so this quest no longer + installs them through `init`. - `SessionEnd` releases (never removes) the session's checkout; `SessionStart` claims it again on resume. `/clear` also fires `SessionEnd`, so release is idempotent and the next `SessionStart` diff --git a/quest/m0/user-dev-shell/README.md b/quest/m0/user-dev-shell/README.md index 441ea38c..0e2f1ca2 100644 --- a/quest/m0/user-dev-shell/README.md +++ b/quest/m0/user-dev-shell/README.md @@ -73,5 +73,4 @@ moq-dev/moq's `.codex/hooks/direnv.sh`. ## Required -- [quest init writes no hooks](/quest/m0/user-dev-shell/init-hooks.md) - `quest init` stops writing hook entries and removes the ones it or a wrapper wrote; the docs describe the hooks as user-level opt-in -- [Repositories drop dev-shell config](/quest/m0/user-dev-shell/repo-config.md) - quest, moq, moq.pro, rind, and rust2 drop their dev-shell hooks, Quest worktree hooks, and `nix develop` instructions +- [Repositories drop dev-shell config](/quest/m0/user-dev-shell/repo-config.md) - quest, moq, moq.pro, rind, and rust2 drop their dev-shell hooks and `nix develop` instructions diff --git a/quest/m0/user-dev-shell/init-hooks.md b/quest/m0/user-dev-shell/init-hooks.md deleted file mode 100644 index e93d5158..00000000 --- a/quest/m0/user-dev-shell/init-hooks.md +++ /dev/null @@ -1,42 +0,0 @@ -# [S] quest init writes no hook entries - -## Goal - -`quest init` no longer writes Claude Code hook entries into a repository's -`.claude/settings.json`, and removes the `quest worktree hook` entries an -earlier version wrote, including ones a repository wrapped, such as -moq-dev/moq's `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. -`quest uninstall` removes both forms too. The README and SETUP.md describe the -worktree hooks as an opt-in a user adds to their own Claude settings, with -the entries to add. - -## Plan - -Decided 2026-11-03 (maintainer, /quest-unblock): repository config applies -to every contributor, so Quest's hooks become user-level opt-in; see the -[epic](/quest/m0/user-dev-shell/README.md). - -- Replaces the wrapped-hooks quest (moq-dev/moq, 2026-11-03), which widened - `isQuestHook` (`src/core/init.ts`) so `init` would accept wrapped hooks. - For removal, a command hook is Quest's only when it runs Quest: the plain - `quest worktree hook`, or a wrapper whose command ends in `worktree hook` - and names the `quest` executable or package as a word or flake attribute, - such as moq's `nix run "${CLAUDE_PROJECT_DIR}#quest" -- worktree hook`. - Another tool's command that merely ends in `worktree hook` stays. -- `HOOK_EVENTS` and the hook plan in `src/core/init.ts` stop adding - entries; `init` reports each entry it removes. The enrollment setup PR - ([moq-dev/quest#431](https://github.com/moq-dev/quest/pull/431)) uses the - same plan, so it stops proposing hooks too. -- The `quest worktree hook` command stays, for users who opt in. -- Removing moq's four wrapped entries, whether `init` here or - [repo-config](/quest/m0/user-dev-shell/repo-config.md) does it first, ends - Quest's reclaim of Claude Code checkouts in moq. Nothing replaces it: the - maintainer's user-level setup (kixelated/dotfiles) loads only the dev - shell, and the epic's decision rejected moving Quest's hooks into the - maintainer's settings. So this quest doesn't wait on that setup, and the - same end state results in either order. A user who wants reclaim adds the - opt-in entries the docs describe. Any removal through the enrollment setup - PR shows in that PR's diff. -- Test `init` and `uninstall` on a repository with plain and wrapped - entries, and with other hooks beside them, which stay, including another - tool's command that ends in `worktree hook`. diff --git a/quest/m0/user-dev-shell/repo-config.md b/quest/m0/user-dev-shell/repo-config.md index 19bceb61..e1e479b6 100644 --- a/quest/m0/user-dev-shell/repo-config.md +++ b/quest/m0/user-dev-shell/repo-config.md @@ -5,8 +5,8 @@ moq-dev/quest, moq, moq.pro, rind, and rust2 carry no dev-shell hooks and no instructions to wrap commands in Nix: each drops its direnv `SessionStart` hook from `.claude/settings.json` and `.codex/hooks.json` with the scripts -and tests behind them, moq drops its four Quest worktree hook entries, and -each AGENTS.md drops its `nix develop` instructions. Agents there still run +and tests behind them, and each AGENTS.md drops its `nix develop` +instructions. Agents there still run the pinned tools, through the maintainer's user-level hooks. One PR per repository. @@ -15,6 +15,10 @@ repository. Decided 2026-11-03 (maintainer, /quest-unblock); see the [epic](/quest/m0/user-dev-shell/README.md). +- moq's four Quest worktree hook entries are not this quest's: decided + 2026-11-04 (maintainer, /quest-complete), + [moq-dev/moq#7889](https://github.com/moq-dev/moq/pull/7889) removes them + on its own. - Keep every other hook: moq.pro's `submodule.sh`, `worktree.sh` (its own submodule-aware worktrees, not Quest's), and `tofu-secrets.py`. - CONTRIBUTING.md's human setup (`nix develop` to match CI) stays; only the diff --git a/quest/m0/worktree-routes.md b/quest/m0/worktree-routes.md index 44ef6442..4787196f 100644 --- a/quest/m0/worktree-routes.md +++ b/quest/m0/worktree-routes.md @@ -24,8 +24,8 @@ agent can't do, so its README description comes from Claude Code's own code: since it records no Git root for one, so quest's lock stays in place. Check it from a scratch repository with the worktree hooks configured (the -opt-in user-level setup once -[quest init writes no hooks](/quest/m0/user-dev-shell/init-hooks.md) lands), with +opt-in user-level setup the README's "Claude Code checkouts" section +describes), with `QUEST_WORKTREE_HOME` isolated: start `claude remote-control --spawn worktree`, spawn a session from claude.ai, end it once without changes and once with an untracked file, and watch `quest worktree status`. Fix the README if it diff --git a/quest/m2/hook-path-error.md b/quest/m2/hook-path-error.md index e9b8ccd8..b48d42b6 100644 --- a/quest/m2/hook-path-error.md +++ b/quest/m2/hook-path-error.md @@ -17,14 +17,9 @@ Decided 2026-11-04 (maintainer, /quest-plan). otherwise prints the message on stderr and exits 2, which Claude Code shows and treats as blocking. A `WorktreeCreate` failure then aborts the worktree with that message. -- After [init-hooks](/quest/m0/user-dev-shell/init-hooks.md), the entries are - a user-level opt-in that the README and SETUP.md describe, so the guarded - entry is the documented one. `quest uninstall`'s match for Quest's own - hooks must still recognize it. +- The entries are a user-level opt-in whose JSON the README's "Claude Code + checkouts" section gives and SETUP.md points to, so the guarded entry is the + documented one. - The test runs the documented entry with a `PATH` that lacks `quest` and checks the exit status and message, and with `quest` present checks that the hook runs. - -## Required - -- [quest init writes no hooks](/quest/m0/user-dev-shell/init-hooks.md) - the documented user-level hook entries this guard changes diff --git a/src/cli/main.ts b/src/cli/main.ts index 31f2d298..ac5179c1 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -846,10 +846,9 @@ export function main(argv: string[], out: Output): number | Promise { .command("hook") .summary("Handle a Claude Code worktree or subagent hook event read on stdin") .description( - "Handle the Claude Code hook event whose JSON input arrives on stdin; `quest init` installs it in " + - ".claude/settings.json for " + + "Handle the Claude Code hook event whose JSON input arrives on stdin, for " + HOOK_EVENTS.join(", ") + - ".\n\n" + + ". Add it to your own Claude Code settings for those events to opt in, as the README describes.\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 " + @@ -1232,9 +1231,7 @@ export function main(argv: string[], out: Output): number | Promise { program .command("init") - .description( - "Install skill stubs, a quest root, agent pointers, Claude Code worktree hooks, and the merge driver for this repository", - ) + .description("Install skill stubs, a quest root, agent pointers, and the merge driver for this repository") .action(() => { const changes = init(root()); for (const line of theme ? theme.init(changes) : changes) print(line); @@ -1242,7 +1239,7 @@ export function main(argv: string[], out: Output): number | Promise { program .command("uninstall") - .description("Remove Quest stubs, markers, hooks, and the merge driver installed by `quest init`") + .description("Remove Quest stubs, markers, and the merge driver installed by `quest init`") .action(() => { const changes = uninstall(root()); for (const line of theme ? theme.uninstall(changes) : changes) print(line); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index c1838feb..ffe24d1b 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -20,25 +20,20 @@ import { dirname, join, resolve } from "node:path"; import { ATTRIBUTES_LINE, - CLAUDE_SETTINGS, type Checkout, - HOOK_EVENTS, type Kind, REFERENCE_MARKER, SKILL_DIRS, installedStubs, - isQuestHook, lines, newline, - parseSettings, planInit, - renderSettings, } from "../core"; import { git } from "./git"; import { DRIVER, OLD_DRIVERS } from "./resolve"; import { SKILLS } from "./skills"; -export { ATTRIBUTES_LINE, HOOK_COMMAND, REFERENCE_LINE } from "../core"; +export { ATTRIBUTES_LINE, REFERENCE_LINE } from "../core"; /** * Install Quest stubs and markers under `root`. Returns the paths it changed, relative to `root`. @@ -123,7 +118,6 @@ function unregister(root: string): void { /** Remove Quest stubs and markers under `root`. Returns the paths it changed, relative to `root`. */ export function uninstall(root: string): string[] { const changes: string[] = []; - if (uninstallHooks(root)) changes.push(CLAUDE_SETTINGS); for (const skills of SKILL_DIRS) { if (!isRealDir(join(root, skills))) continue; for (const dir of installedStubs(checkout(root), skills)) changes.push(removeStub(root, dir)); @@ -218,38 +212,3 @@ function removeLine(root: string, path: string, marker: string): boolean { } return true; } - -/** - * Remove exactly Quest's hook handlers, then the groups, events, and hooks object they leave empty, - * deleting the file when nothing else remains. A file that is missing or malformed is left alone. - */ -function uninstallHooks(root: string): boolean { - let parsed; - try { - parsed = isFile(join(root, CLAUDE_SETTINGS)) ? parseSettings(read(root, CLAUDE_SETTINGS)) : undefined; - } catch { - return false; - } - if (!parsed?.settings.hooks) return false; - const { settings, indent, eol } = parsed; - const hooks = settings.hooks!; - let changed = false; - for (const event of HOOK_EVENTS) { - const groups = hooks[event]; - if (!groups) continue; - const kept = groups.flatMap((group) => { - const handlers = group.hooks ?? []; - const others = handlers.filter((handler) => !isQuestHook(handler)); - if (others.length === handlers.length) return [group]; - changed = true; - return others.length ? [{ ...group, hooks: others }] : []; - }); - if (kept.length) hooks[event] = kept; - else delete hooks[event]; - } - if (!changed) return false; - if (Object.keys(hooks).length === 0) delete settings.hooks; - if (Object.keys(settings).length === 0) rmSync(join(root, CLAUDE_SETTINGS)); - else writeFileSync(join(root, CLAUDE_SETTINGS), renderSettings(settings, indent, eol)); - return true; -} diff --git a/src/cli/worktree-hook.ts b/src/cli/worktree-hook.ts index 7baf8245..a651e050 100644 --- a/src/cli/worktree-hook.ts +++ b/src/cli/worktree-hook.ts @@ -1,5 +1,6 @@ -// Claude Code's worktree lifecycle hooks, backed by `quest worktree`. `quest init` installs one -// static entry per event, each calling `quest worktree hook`, so the logic upgrades with the binary. +// Claude Code's worktree lifecycle hooks, backed by `quest worktree`. A user opts in with one static +// entry per event in their own Claude settings, each calling `quest worktree hook`, so the logic +// upgrades with the binary. import { existsSync } from "node:fs"; import { join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; @@ -14,8 +15,8 @@ import { mainCheckout, } from "./worktree"; -// The events this handles are the core's HOOK_EVENTS, which `quest init` installs. -export { HOOK_EVENTS } from "../core"; +/** The Claude Code events `quest worktree hook` handles. */ +export const HOOK_EVENTS = ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"] as const; /** * The guide's workspace, which holds an agent's reports and logs. Its parent reads them after it stops, diff --git a/src/core/index.ts b/src/core/index.ts index f7782dd3..e2067e52 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -42,10 +42,7 @@ export { export { MAINTAINER, WAITING, type Wait, onMaintainer, parseWait, waits } from "./waiting"; export { ATTRIBUTES_LINE, - CLAUDE_SETTINGS, type Checkout, - HOOK_COMMAND, - HOOK_EVENTS, type InitChange, InitRefused, type Kind, @@ -53,16 +50,12 @@ export { REFERENCE_LINE, REFERENCE_MARKER, SKILL_DIRS, - type Settings, type Stub, installedStubs, - isQuestHook, lines, newline, - parseSettings, planInit, questReadme, - renderSettings, } from "./init"; export { type Chunk, type Clean, type Conflict, LABELS, chunks, render, resolveLists } from "./merge"; diff --git a/src/core/init.ts b/src/core/init.ts index 98996f24..173d4c31 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -5,8 +5,9 @@ // // The plan touches only skill stubs (writing missing or outdated ones and // removing those of retired skills), one reference line in the root agent -// instructions, one `.gitattributes` line, `quest/README.md` when missing, and -// Quest's hook entries in `.claude/settings.json`. +// instructions, one `.gitattributes` line, and `quest/README.md` when missing. +// It writes no Claude Code hooks: Quest's worktree hooks are a user-level +// opt-in, since a repository's settings would force them on every contributor. /** Installed skills carry this prefix so they don't collide with a repository's own. */ export const PREFIX = "quest-"; @@ -32,18 +33,6 @@ export const SKILL_DIRS = [".claude/skills", ".agents/skills"] as const; /** Routes quest files through the `quest` merge driver; uninstall removes lines starting with it. */ export const ATTRIBUTES_LINE = "quest/** merge=quest"; -/** Claude Code's shared project settings, where init installs the worktree lifecycle hooks. */ -export const CLAUDE_SETTINGS = ".claude/settings.json"; - -/** The one command each installed hook entry runs; the logic lives in the binary, so upgrades never rewrite it. */ -export const HOOK_COMMAND = "quest worktree hook"; - -/** The Claude Code events `quest worktree hook` handles, in the order `quest init` installs them. */ -export const HOOK_EVENTS = ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"] as const; - -/** Events whose single hook decides the outcome, so another hook for them conflicts with Quest's. */ -const EXCLUSIVE_EVENTS = new Set(["WorktreeCreate", "WorktreeRemove"]); - /** The quest tree's root, with the project's goal or a placeholder for it. */ export function questReadme(goal?: string): string { return `# Quests\n\n## Goal\n\n${goal?.trim() || "What this project is working toward."}\n`; @@ -136,12 +125,9 @@ export function planInit(repo: Checkout, stubs: readonly Stub[], options: { goal const instructions = ["AGENTS.md", "CLAUDE.md"].find((name) => repo.stat(name) === "file") ?? "AGENTS.md"; checkPath(repo, instructions, "file"); checkPath(repo, ".gitattributes", "file"); - checkPath(repo, CLAUDE_SETTINGS, "file"); - const settings = installHooks(repo); const changes: InitChange[] = [...written, ...retired.map((dir) => ({ path: `${dir}/SKILL.md`, content: null }))]; if (repo.lstat(readme) === null) changes.push({ path: readme, content: questReadme(options.goal) }); - if (settings !== undefined) changes.push({ path: CLAUDE_SETTINGS, content: settings }); for (const [path, marker, line, old] of [ [instructions, REFERENCE_MARKER, REFERENCE_LINE, OLD_REFERENCE_LINES], [".gitattributes", ATTRIBUTES_LINE, ATTRIBUTES_LINE, []], @@ -240,71 +226,3 @@ function appendLine(content: string, marker: string, line: string, old: readonly } return `${content}${line}${eol}`; } - -/** A settings object as Claude Code reads it: hook groups per event, each with handlers. */ -export type Settings = { hooks?: Record } & Record; - -const record = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -export function isQuestHook(handler: unknown): boolean { - return record(handler) && handler.type === "command" && handler.command === HOOK_COMMAND; -} - -/** - * Claude Code's settings file parsed and checked for the shape init edits, - * with its formatting. Throws an `InitRefused` naming the file when it is not. - */ -export function parseSettings(text: string): { settings: Settings; indent: string; eol: string } { - const invalid = (why: string) => - new InitRefused( - CLAUDE_SETTINGS, - `${CLAUDE_SETTINGS} ${why}; fix it before running \`quest init\` or \`quest uninstall\``, - ); - let settings: unknown; - try { - settings = JSON.parse(text); - } catch { - throw invalid("is not valid JSON"); - } - if (!record(settings)) throw invalid("is not a JSON object"); - if (settings.hooks !== undefined) { - if (!record(settings.hooks)) throw invalid("has hooks that are not an object"); - for (const [event, groups] of Object.entries(settings.hooks)) - if (!Array.isArray(groups) || !groups.every((group) => record(group) && Array.isArray(group.hooks ?? []))) - throw invalid(`has ${event} hooks that are not a list of hook groups`); - } - return { settings: settings as Settings, indent: /\n([ \t]+)"/.exec(text)?.[1] ?? " ", eol: newline(text) }; -} - -/** The settings file's text, in the formatting `parseSettings` read. */ -export function renderSettings(settings: Settings, indent: string, eol: string): string { - return `${JSON.stringify(settings, null, indent).replaceAll("\n", eol)}${eol}`; -} - -/** - * The settings file with one Quest hook group added for each event that lacks one, or undefined when - * nothing changes. Another hook for an event whose single hook decides the outcome is a conflict. - */ -function installHooks(repo: Checkout): string | undefined { - const { settings, indent, eol } = - repo.stat(CLAUDE_SETTINGS) === "file" - ? parseSettings(repo.read(CLAUDE_SETTINGS)) - : { settings: {} as Settings, indent: " ", eol: "\n" }; - const hooks = (settings.hooks ??= {}); - let changed = false; - for (const event of HOOK_EVENTS) { - const groups = (hooks[event] ??= []); - const handlers = groups.flatMap((group) => group.hooks ?? []); - if (EXCLUSIVE_EVENTS.has(event) && handlers.some((handler) => !isQuestHook(handler))) - throw new InitRefused( - CLAUDE_SETTINGS, - `${CLAUDE_SETTINGS} already has a ${event} hook, and only one can manage Claude's worktrees; ` + - "remove it before running `quest init`", - ); - if (handlers.some(isQuestHook)) continue; - groups.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] }); - changed = true; - } - return changed ? renderSettings(settings, indent, eol) : undefined; -} diff --git a/test/cli.test.ts b/test/cli.test.ts index 4b362beb..a66ab598 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -88,7 +88,7 @@ test("init and uninstall print each changed path and nothing else", () => { const init = run("--root", root, "init"); expect(init.code).toBe(0); expect(init.stdout).toMatch(/^(\S+\n)+$/); - expect(init.stdout).toContain("quest/README.md\n.claude/settings.json\nAGENTS.md\n"); + expect(init.stdout).toContain("quest/README.md\nAGENTS.md\n"); expect(run("--root", root, "init").stdout).toBe(""); expect(run("--root", root, "uninstall").stdout).toMatch(/^(\S+\n)+$/); expect(run("--root", root, "uninstall").stdout).toBe(""); diff --git a/test/init.test.ts b/test/init.test.ts index eee7b651..3ceb2d7b 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -14,7 +14,7 @@ import { dirname, join } from "node:path"; import { expect, test } from "vitest"; -import { HOOK_COMMAND, REFERENCE_LINE, init, uninstall } from "../src/cli/setup"; +import { REFERENCE_LINE, init, uninstall } from "../src/cli/setup"; import { SKILLS } from "../src/cli/skills"; import { tempDir } from "./fixture"; @@ -379,66 +379,13 @@ test("uninstall removes stubs that older versions installed", () => { expect(existsSync(join(dir, ".agents"))).toBe(false); }); -const QUEST_HOOK = { type: "command", command: HOOK_COMMAND }; -const SESSION_HOOK = { hooks: [{ type: "command", command: "bash .claude/hooks/direnv.sh" }] }; - -test("init installs Quest's worktree hooks in Claude's project settings, and uninstall removes them", () => { +test("init and uninstall leave Claude Code's project settings alone", () => { const dir = tempDir(); init(dir); - const settings = JSON.parse(read(dir, ".claude/settings.json")); - expect(Object.keys(settings.hooks)).toEqual(["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"]); - for (const groups of Object.values(settings.hooks)) expect(groups).toEqual([{ hooks: [QUEST_HOOK] }]); - uninstall(dir); - expect(existsSync(join(dir, ".claude"))).toBe(false); -}); - -test("init merges hooks into existing settings, keeping their formatting, and uninstall restores them", () => { - const dir = tempDir(); - mkdirSync(join(dir, ".claude")); - const original = { - $schema: "https://json.schemastore.org/claude-code-settings.json", - hooks: { SessionStart: [SESSION_HOOK], SubagentStop: [SESSION_HOOK] }, - }; - const text = `${JSON.stringify(original, null, "\t")}\n`; - writeFileSync(join(dir, ".claude/settings.json"), text); - expect(init(dir)).toContain(".claude/settings.json"); - const merged = read(dir, ".claude/settings.json"); - expect(merged).toContain('\n\t"hooks"'); - const settings = JSON.parse(merged); - expect(settings.hooks.SessionStart).toEqual([SESSION_HOOK]); - expect(settings.hooks.SubagentStop).toEqual([SESSION_HOOK, { hooks: [QUEST_HOOK] }]); - expect(init(dir)).not.toContain(".claude/settings.json"); - expect(uninstall(dir)).toContain(".claude/settings.json"); - expect(read(dir, ".claude/settings.json")).toBe(text); -}); - -test("uninstall removes only Quest's handler from a group it shares", () => { - const dir = tempDir(); - mkdirSync(join(dir, ".claude")); - const shared = { matcher: "", hooks: [QUEST_HOOK, SESSION_HOOK.hooks[0]] }; - writeFileSync(join(dir, ".claude/settings.json"), JSON.stringify({ hooks: { SubagentStart: [shared] } })); + expect(existsSync(join(dir, ".claude/settings.json"))).toBe(false); + const text = `${JSON.stringify({ hooks: { WorktreeCreate: [{ hooks: [{ type: "command", command: "quest worktree hook" }] }] } })}\n`; + write(dir, ".claude/settings.json", text); init(dir); uninstall(dir); - expect(JSON.parse(read(dir, ".claude/settings.json"))).toEqual({ - hooks: { SubagentStart: [{ matcher: "", hooks: SESSION_HOOK.hooks }] }, - }); + expect(read(dir, ".claude/settings.json")).toBe(text); }); - -for (const [why, content, error] of [ - [ - "another WorktreeCreate hook", - JSON.stringify({ hooks: { WorktreeCreate: [SESSION_HOOK] } }), - "already has a WorktreeCreate hook", - ], - ["invalid JSON", "{", "is not valid JSON"], - ["hooks that are not hook groups", JSON.stringify({ hooks: { SubagentStop: {} } }), "not a list of hook groups"], -] as const) { - test(`init refuses settings with ${why} and writes nothing`, () => { - const dir = tempDir(); - mkdirSync(join(dir, ".claude")); - writeFileSync(join(dir, ".claude/settings.json"), content); - const before = snapshot(dir); - expect(() => init(dir)).toThrow(error); - expect(snapshot(dir)).toEqual(before); - }); -}