From 7823e79c5f1fa9ec6f7dbb853ba4095c76efb143 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 14:33:22 -0800 Subject: [PATCH 1/2] chore(quest): claim quest/m0/token-waste/coordinator-waits.md Quest-Claim: 55e0360b005f0226 From afb31de068816fef2d7f8c73595b9837ac3c9ba7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 14:39:12 -0800 Subject: [PATCH 2/2] feat(setup): block waits in one call and keep coordinator context lean Add `quest setup`, which sets Claude Code's BASH_MAX_TIMEOUT_MS to 55 minutes in the user's settings so a foreground wait outlasts the 10-minute default without polling. The shared rules now name the polling shapes to avoid, batch independent reads into one turn, cap a sub-agent's report at what its caller acts on, and have coordinators lean on compaction with durable state in the forge and the quest tree. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++ SETUP.md | 9 +++++ assets/CONTEXT.md | 7 +++- assets/skills/complete.md | 1 + quest/m0/token-waste/README.md | 1 - quest/m0/token-waste/coordinator-waits.md | 47 ----------------------- src/cli/main.ts | 17 +++++++- src/cli/setup.ts | 39 +++++++++++++++++++ test/setup.test.ts | 39 +++++++++++++++++++ 9 files changed, 116 insertions(+), 51 deletions(-) delete mode 100644 quest/m0/token-waste/coordinator-waits.md create mode 100644 test/setup.test.ts diff --git a/README.md b/README.md index e417c563..375b02b9 100644 --- a/README.md +++ b/README.md @@ -1597,6 +1597,13 @@ Follow https://github.com/moq-dev/quest/blob/main/SETUP.md to set up Quest here. ``` The [getting started guide](docs/getting-started.md) covers what happens next. + +`quest setup` configures your own Claude Code settings for quest work, in +every repository: it sets `env.BASH_MAX_TIMEOUT_MS` to 3300000, so a +foreground command such as `quest wait` can block for 55 minutes, just under +the 1h prompt-cache TTL, instead of the 10-minute default. It edits +`$CLAUDE_CONFIG_DIR/settings.json`, or `~/.claude/settings.json`, keeps your +other settings, prints each setting it changed, and is safe to rerun. `quest context` prints the rules agents follow in every task, and `quest guide` the quest format and workflow they read before working on a quest. diff --git a/SETUP.md b/SETUP.md index 5c7326ad..2c3e5186 100644 --- a/SETUP.md +++ b/SETUP.md @@ -56,6 +56,15 @@ 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. +## Configure the user's agent + +`quest setup` configures the user's own Claude Code settings, not the +repository's: it sets `env.BASH_MAX_TIMEOUT_MS` in +`$CLAUDE_CONFIG_DIR/settings.json` (or `~/.claude/settings.json`) to 55 +minutes, so agents can block on a long wait in one call instead of polling. +Offer it, explaining that it applies in every repository, and run it with +consent. It keeps the file's other settings and prints what it changed. + ## Opt into worktree hooks Quest can create and reclaim Claude Code's worktrees, sharing one disk budget diff --git a/assets/CONTEXT.md b/assets/CONTEXT.md index f5f7e747..8e91db66 100644 --- a/assets/CONTEXT.md +++ b/assets/CONTEXT.md @@ -67,11 +67,16 @@ Run such a command in the foreground and read its exit code directly, not throug When it may outlast the tool's time limit, start it in the background and block on it in the same turn with the harness's wait for that task. Run no more sub-agents at once than the harness allows; queue the rest and start one as each reports. -Wait on a PR's CI or reviews with `quest wait`, and on other background work with a blocking command that ends on its terminal state, not timed polls. +Every turn re-sends the whole context, so spend turns on the work. +Batch independent reads, searches, and checks into one turn, as parallel tool calls or one command. +Wait on a PR's CI or reviews with `quest wait`, and on other background work with one blocking call that ends on its terminal state. +Never poll: no `sleep`, no `until` or `while` loop re-checking a log or status, no wait with a timeout far below the tool's limit, and no repeated status checks or output reads of a running task. Bound each wait, not the work, under an hour so the prompt cache stays warm; `quest wait` has no deadline without `--timeout`. Re-run a wait that times out, passing the resume point a timed-out `quest wait` printed as its `--since`, so activity during the first wait is still reported. A sub-agent ends its turn only with a final result or a question, never with background work still running or an interim status report. +Its final report holds only what its caller acts on, such as its PR's URL, state, and head, and each decision it needs with a recommendation; details go in the PR body. A coordinator relays no interim report: it resumes a sub-agent that ended its turn on one, telling it to block until its work finishes. +A long-running coordinator lets compaction bound its context: it keeps what must outlast compaction in the forge and the quest tree, and reads a PR's state afresh before acting on it rather than trusting earlier turns. Back out when progress stalls rather than burn tokens; a gate's queue or a long command still running is not a stall. ## Questions diff --git a/assets/skills/complete.md b/assets/skills/complete.md index 38f5c440..13035cb6 100644 --- a/assets/skills/complete.md +++ b/assets/skills/complete.md @@ -42,6 +42,7 @@ Consider ordering, queuing any merges if they would result in conflicts. Run each `/quest-review` or `/quest-iterate` in its own sub-agent with its own isolated worktree. A sub-agent cannot prompt, so settle a PR's decisions for the user here with the grill skill's rounds before dispatching `/quest-iterate` for it, whatever dispatches it. Each sub-agent reports back only when done or blocked, and never waits on GitHub CI, reviews, or merges. +Its brief names the PR's standing pick, if any, and its report repeats it, so the pick outlasts compaction. Do not watch the queue or wait for merges. An open PR that left the queue unmerged shows why under `landing-stopped` in `quest status `. diff --git a/quest/m0/token-waste/README.md b/quest/m0/token-waste/README.md index 8c9e9763..93f0efaa 100644 --- a/quest/m0/token-waste/README.md +++ b/quest/m0/token-waste/README.md @@ -70,7 +70,6 @@ Decision prompts, chosen options marked ✅: - [Single-pass review](/quest/m0/token-waste/review-single-pass.md) - a review round explores the change once, with the diff and REVIEW.md given up front - [Rules load once](/quest/m0/token-waste/rule-loading.md) - skills and rules arrive in the first turn and survive compaction -- [Coordinators and waits](/quest/m0/token-waste/coordinator-waits.md) - coordinators lean on compaction, children report briefly, and waits block - [Epics list no children](/quest/m0/token-waste/child-lists.md) - an epic's children are its directory, so finishing a quest edits no README - [Base merges only when needed](/quest/m0/token-waste/base-merges.md) - a branch merges its base on a conflict, not before every queue attempt - [Mechanical rules become checks](/quest/m0/token-waste/rule-checks.md) - hooks enforce em dashes, push refspecs, no rebase or force-push, the footer, and `.tmp/`, and their prose leaves the always-loaded rules diff --git a/quest/m0/token-waste/coordinator-waits.md b/quest/m0/token-waste/coordinator-waits.md deleted file mode 100644 index 24cb99da..00000000 --- a/quest/m0/token-waste/coordinator-waits.md +++ /dev/null @@ -1,47 +0,0 @@ -# [M] Coordinators and waits - -## Goal - -Long coordinators (`/quest-complete`, `/quest-spawn`, `/quest-afk`) keep -their state in GitHub and the quest tree and rely on compaction instead of -growing without bound. Sub-agent reports stay short. Every wait blocks on a -terminal state in one call instead of polling, and agents batch independent -reads and searches into one turn. - -## Plan - -Decided 2026-11-05 (user, /quest-plan, the -[token waste](/quest/m0/token-waste/README.md) audit). - -Measured, Claude Code 2026-10-28 to 11-05: - -- The most expensive session was a `/quest-complete` coordinator: 1,895 - turns at a mean context of 444k, peaking at 966k, with 374 delegated-task - reports of up to 37k characters each. `/quest-complete` and - `/quest-spawn` are 32% of all cost. -- Polling cost 264M (6.8%): 1,165 `until grep -q "^EXIT=" ...; do sleep` - loops, 2,701 sleep calls, 850 background waits that returned nothing, and - 1,204 `quest wait` calls. In Codex interactive sessions, polling requests - were 19.6% of input. -- Serial exploration: search and read calls trigger 35% of turn cost, at - 1.11 tool calls per request, so each read re-sends the whole context. -- T3 Code sessions never saw the dotfiles' `BASH_MAX_TIMEOUT_MS`, so - foreground waits hit the 10-minute cap. The maintainer's user settings now - set it to 3300000, just under the 1h cache TTL. - -Decision prompts, chosen options marked ✅: - -- Coordinator context: ✅ rely on compaction (rejected: a handoff and - restart after N picks). Claude Code reminds the session which background - agents still run after compacting, so finished children shrink to the - summary without a status file. Automatic compaction cannot be steered, so - state that must persist belongs in GitHub and the quest tree. - -Recommendations for the implementer: - -- Cap a child's report at the PR, its state, and the decisions it needs, - with details in the PR body. -- `quest setup` sets the Bash timeout cap for Claude Code under the cache - TTL, so no session depends on a shell profile. -- Name the banned shapes (sleep or `until` loops, short-timeout waits) once, - where waits are described. diff --git a/src/cli/main.ts b/src/cli/main.ts index d094ee97..bfd8118d 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -63,7 +63,7 @@ import { roundLines, } from "./review"; import { type Recorded, recordRound } from "./review-record"; -import { heal, init, uninstall } from "./setup"; +import { claudeSettings, heal, init, setup, uninstall } from "./setup"; import { Refusal, describe, notUpstream, releaseQuest, startQuest } from "./start"; import { CONTEXT, GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; @@ -1299,9 +1299,22 @@ export function main(argv: string[], out: Output): number | Promise { for (const line of theme ? theme.uninstall(changes) : changes) print(line); }); + program + .command("setup") + .description("Configure your own Claude Code settings for quest work, keeping your other settings") + .addHelpText( + "after", + "\nSets env.BASH_MAX_TIMEOUT_MS in the Claude Code user settings (CLAUDE_CONFIG_DIR's settings.json, or " + + "~/.claude/settings.json) to 3300000, so a foreground command, such as a blocking wait, may run 55 minutes: " + + "just under the 1h prompt-cache TTL. Prints each setting it changed, and is safe to rerun. Touches no repository.", + ) + .action(() => { + for (const line of setup(claudeSettings())) print(line); + }); + // Fresh clones and worktrees get the merge driver from whichever command runs first. program.hook("preAction", (_program, action) => { - if (!["init", "uninstall"].includes(action.name())) heal(root()); + if (!["init", "uninstall", "setup"].includes(action.name())) heal(root()); }); try { diff --git a/src/cli/setup.ts b/src/cli/setup.ts index ffe24d1b..1f592422 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -1,4 +1,5 @@ // `quest init` and `quest uninstall`: install skill stubs and reverse it. +// `quest setup`: configure the user's own agent settings. // // Init applies the core's file plan (`planInit`) to the checkout, then // registers the clone's merge driver. Uninstall never deletes the quest tree. @@ -16,6 +17,7 @@ import { statSync, writeFileSync, } from "node:fs"; +import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { @@ -212,3 +214,40 @@ function removeLine(root: string, path: string, marker: string): boolean { } return true; } + +/** + * 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. + */ +export const BASH_MAX_TIMEOUT_MS = "3300000"; + +/** The Claude Code user settings file: under `CLAUDE_CONFIG_DIR`, or `~/.claude`. */ +export function claudeSettings(env: NodeJS.ProcessEnv = process.env): string { + return join(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json"); +} + +/** + * Set Quest's entries in the Claude Code user settings at `path`, keeping every + * other setting. Returns a line per setting it changed. Malformed settings throw + * before anything is written. + */ +export function setup(path: string): string[] { + let settings: Record = {}; + if (existsSync(path)) { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isObject(parsed)) throw new Error(`${path} is not a JSON object`); + settings = parsed; + } + const env = settings.env ?? {}; + if (!isObject(env)) throw new Error(`${path}: env is not a JSON object`); + if (env.BASH_MAX_TIMEOUT_MS === BASH_MAX_TIMEOUT_MS) return []; + const was = env.BASH_MAX_TIMEOUT_MS === undefined ? "" : ` (was ${JSON.stringify(env.BASH_MAX_TIMEOUT_MS)})`; + settings.env = { ...env, BASH_MAX_TIMEOUT_MS }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`); + return [`${path}: env.BASH_MAX_TIMEOUT_MS=${BASH_MAX_TIMEOUT_MS}${was}`]; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 00000000..03a4f61e --- /dev/null +++ b/test/setup.test.ts @@ -0,0 +1,39 @@ +// `quest setup` against temporary Claude Code settings. + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { expect, test } from "vitest"; + +import { BASH_MAX_TIMEOUT_MS, claudeSettings, setup } from "../src/cli/setup"; +import { tempDir } from "./fixture"; + +const read = (path: string) => JSON.parse(readFileSync(path, "utf8")); + +test("setup creates missing settings and is idempotent", () => { + const path = join(tempDir(), "profile", "settings.json"); + expect(setup(path)).toEqual([`${path}: env.BASH_MAX_TIMEOUT_MS=${BASH_MAX_TIMEOUT_MS}`]); + expect(read(path)).toEqual({ env: { BASH_MAX_TIMEOUT_MS } }); + expect(setup(path)).toEqual([]); +}); + +test("setup keeps other settings and replaces a different cap", () => { + const path = join(tempDir(), "settings.json"); + writeFileSync(path, JSON.stringify({ theme: "dark", env: { FOO: "1", BASH_MAX_TIMEOUT_MS: "600000" } })); + expect(setup(path)).toEqual([`${path}: env.BASH_MAX_TIMEOUT_MS=${BASH_MAX_TIMEOUT_MS} (was "600000")`]); + expect(read(path)).toEqual({ theme: "dark", env: { FOO: "1", BASH_MAX_TIMEOUT_MS } }); +}); + +test("setup refuses malformed settings without writing", () => { + const dir = tempDir(); + for (const content of ["{", "[]", '{"env":"x"}']) { + const path = join(dir, "settings.json"); + writeFileSync(path, content); + expect(() => setup(path)).toThrow(); + expect(readFileSync(path, "utf8")).toBe(content); + } +}); + +test("settings live under CLAUDE_CONFIG_DIR when set", () => { + expect(claudeSettings({ CLAUDE_CONFIG_DIR: "/profile" })).toBe(join("/profile", "settings.json")); +});