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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion assets/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions assets/skills/complete.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr>`.
Expand Down
1 change: 0 additions & 1 deletion quest/m0/token-waste/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 0 additions & 47 deletions quest/m0/token-waste/coordinator-waits.md

This file was deleted.

17 changes: 15 additions & 2 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1299,9 +1299,22 @@ export function main(argv: string[], out: Output): number | Promise<number> {
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 {
Expand Down
39 changes: 39 additions & 0 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -16,6 +17,7 @@ import {
statSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";

import {
Expand Down Expand Up @@ -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<string, unknown> = {};
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
39 changes: 39 additions & 0 deletions test/setup.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
Loading