Skip to content
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,35 @@ Invalid trees fail with diagnostics and no ranking. This command uses the same
local readiness as `ready`; verify claims and outside conditions before starting.
`quest-spawn` uses the ranking unless you explicitly select work in another order.

## Move and delete quests

`quest mv` and `quest rm` move or delete a quest or an epic with `git mv` and
`git rm`, then fix the links so the tree still passes `quest check`:

```sh
quest mv quest/m0/export.md quest/m1/export.md
quest mv quest/m0/drain quest/m1/drain
quest rm quest/m0/download.md
quest rm -r quest/m1/drain
```

`mv` rewrites every root-absolute link to the moved path, and to everything
under a moved epic. `rm` drops each `Required`, `Related`, or `Closes` entry
that links to what it deleted, and the heading if that was the last entry.
It also drops every `Required` link to a finished milestone, including one it
just finished, but keeps the root's listing of the milestone.
Both stage the change, print one line per file (`R from -> to`, `D path`, or
`M path`), and report on stderr any other mention of the path, such as prose
or files outside `quest/`, without editing it. They run `quest check` on the
result and exit 1 if it fails, for example when a quest moved out of its epic.

An epic is its directory or README; removing one that holds other quests needs
`-r`. The root and milestones are never moved or removed. `mv` refuses a
destination that exists, and refuses to move a quest while it has a claim
branch or open PR on `origin`, or on the repository a GitHub fork came from,
since the branch would no longer name it.
Neither command touches branches or PRs.

## Wait for pull request activity

`quest wait` blocks until a GitHub PR has a review, comment, review-thread
Expand Down
2 changes: 1 addition & 1 deletion assets/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,6 @@ Any findings that seem out-of-scope, or bugs encountered during the process, sho

A quest that is no longer needed or cannot be completed is abandoned.
Delete it and explain why in the PR.
Grep its absolute path and remove every reference.
Use `quest rm`, which deletes the quest and every link to it.

Remove the `quest` label from issues no other quest tracks.
2 changes: 1 addition & 1 deletion assets/skills/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ Split the reading across parallel sub-agents, each given a slice of the scope an

Apply factual corrections and changes the user has already decided. Use `/quest-plan` only for unresolved scope, priority, API, or other consequential choices; recommendations are not decisions. Once those choices are settled, apply them without another approval round.

Update every reference and dependency affected by a move or deletion, run `quest check`, and follow the repository's PR workflow within the user's authorized scope. Report the changes with their evidence and any decisions still open. An explicit read-only audit request leaves the tree unchanged.
Move and delete quests with `quest mv` and `quest rm`, which update the links to them; edit any mentions they report, run `quest check`, and follow the repository's PR workflow within the user's authorized scope. Report the changes with their evidence and any decisions still open. An explicit read-only audit request leaves the tree unchanged.
2 changes: 1 addition & 1 deletion assets/skills/delete.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ description: Abandon a quest.

Before you begin, run `quest guide` and read its output completely.

Delete the specified quest file and make a PR.
Delete the specified quest with `quest rm`, edit any mentions it reports, and make a PR.
If the quest has an open PR, repurpose it instead of making a new PR.
Merge the PR; it should only consist of quest deletions.
1 change: 0 additions & 1 deletion quest/m0/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ commands from Sep-Oct. Session counts in each child come from that window.

## Required

- [Move and delete quests](/quest/m0/helpers/move.md) - `quest mv` and `quest rm` keep every root-absolute link valid
- [CI checks in wait](/quest/m0/helpers/wait-checks.md) - `quest wait` wakes on required check results for the head
- [quest start](/quest/m0/helpers/start.md) - claim a quest with a draft PR, the `## Decisions needed` hand-back, `quest unblocked --unclaimed`, and CI on ready PRs only
- [PR state commands](/quest/m0/helpers/pr-states.md) - `quest iterate`, `ready`, `merge`, and `release` label the one owner of a PR
Expand Down
43 changes: 0 additions & 43 deletions quest/m0/helpers/move.md

This file was deleted.

9 changes: 2 additions & 7 deletions quest/m0/landed-quests.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,10 @@ Decided 2026-10-20 (user, planned from moq-dev/moq):
of their own. Rejected: refusing and reporting for `/quest-iterate`.
- Repositories may also run the guard in CI on pull requests; document the
invocation in `README.md`, but don't ship a workflow.
- [Move and delete quests](/quest/m0/helpers/move.md) gives `quest rm`, which
the fix-up step uses once it lands; until then the agent deletes and
unlinks by hand.
- The fix-up step deletes the quest with `quest rm`, which drops the links to
it.

Tests: a quest-branch diff that deletes the quest, one that edits it, and one
that leaves it untouched (the finding); an epic branch checked against its
README; a non-quest branch; a branch naming a quest that does not exist at
the base; and `--branch` overriding the current branch.

## Related

- [Move and delete quests](/quest/m0/helpers/move.md) - `quest rm` deletes a quest and every link to it
113 changes: 113 additions & 0 deletions src/cli/claims.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Who holds a quest: its claim branch on the remote, or an open PR from a
// branch of that name in any repository. Commands that must not disturb held
// work look here first.
import { execFileSync } from "node:child_process";

import {
type GitHubDependencies,
type Repository,
GitHub,
githubRepository,
object,
remoteUrl,
string,
} from "./github";

/** A quest's branch: its path without `.md`. */
export function questBranch(path: string): string {
return path.replace(/\.md$/, "");
}

/** A held quest branch, and the open PR from it if there is one. */
export interface Claim {
branch: string;
url: string | null;
}

export interface ClaimDependencies {
/** Called only when the remote is on GitHub, so other forges need no token. */
github(): GitHubDependencies;
warn(message: string): void;
}

/** The `quest/*` branches on `remote`. Plain git, so it works with any forge. */
export function remoteBranches(root: string, remote: string): string[] {
const out = execFileSync("git", ["-C", root, "ls-remote", remote, "refs/heads/quest/*"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return out
.split("\n")
.filter((line) => line !== "")
.map((line) => line.slice(line.indexOf("\t") + 1).replace(/^refs\/heads\//, ""));
}

/** Every open PR in `repository`, from any head repository, by head branch. */
export async function openPullRequests(
github: GitHub,
repository: Repository,
): Promise<{ branch: string; url: string }[]> {
const rows = await github.all(`${path(repository)}/pulls?state=open&per_page=100`);
return rows.map((row) => ({ branch: string(object(row.head).ref), url: string(row.html_url) }));
}

/**
* The repository `repository` was forked from, or `null` if it is not a fork.
* A fork's clone names the fork `origin`, while its quests are claimed upstream.
*/
export async function forkParent(github: GitHub, repository: Repository): Promise<Repository | null> {
const parent = object(await (await github.request(path(repository))).json()).parent;
if (parent === undefined || parent === null) return null;
const [owner, repo] = string(object(parent).full_name).split("/");
return { owner, repo };
}

/** The `quest/*` branches in a GitHub `repository`, for one that is not a local remote. */
export async function githubBranches(github: GitHub, repository: Repository): Promise<string[]> {
const rows = await github.all(`${path(repository)}/git/matching-refs/heads/quest/`);
return rows.map((row) => string(row.ref).replace(/^refs\/heads\//, ""));
}

function path(repository: Repository): string {
return `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`;
}

/**
* Which of `branches` someone holds: pushed to `remote`, or the head of an
* open PR there. When `remote` is a GitHub fork, its parent's branches and
* open PRs count too. Without the remote it warns and checks nothing; off
* GitHub it checks branches only.
*/
export async function claims(
root: string,
remote: string,
branches: string[],
deps: ClaimDependencies,
): Promise<Claim[]> {
let url: string;
try {
url = remoteUrl(root, remote);
} catch {
deps.warn(`no ${remote} remote, so claims were not checked`);
return [];
}
const wanted = new Set(branches);
const held = new Map<string, string | null>();
for (const branch of remoteBranches(root, remote)) if (wanted.has(branch)) held.set(branch, null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check upstream branches before allowing a move

When origin is a fork, this checks branch claims only in that fork. The new parent-repository lookup fixes the previously reported open-PR case, but a quest branch pushed directly to the upstream repository while its draft PR is being opened still appears in neither remoteBranches(origin) nor the open-PR results, so quest mv can rename actively claimed work. Query the resolved upstream repository's branches as well before allowing the move.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 73d51ad: when origin is a GitHub fork, claims now also include the parent's quest/* branches, listed with git/matching-refs, alongside its open PRs. This supersedes my earlier reply that left upstream branches out.

(Written by Claude Opus 5.5)

const repository = githubRepository(url);
if (repository === null) {
deps.warn(`${remote} is not on GitHub, so only its branches were checked for claims`);
} else {
const github = new GitHub(deps.github());
const parent = await forkParent(github, repository);
if (parent !== null) {
for (const branch of await githubBranches(github, parent)) if (wanted.has(branch)) held.set(branch, null);
}
for (const each of parent === null ? [repository] : [repository, parent]) {
for (const pr of await openPullRequests(github, each)) {
if (wanted.has(pr.branch)) held.set(pr.branch, pr.url);
}
}
}
return [...held].map(([branch, url]) => ({ branch, url })).sort((a, b) => (a.branch < b.branch ? -1 : 1));
}
54 changes: 42 additions & 12 deletions src/cli/github.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
// The GitHub client shared by the PR commands, outside the repository-independent core.
// The GitHub client shared by the commands that talk to GitHub, outside the repository-independent core.
import { execFileSync } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";

const API = "https://api.github.com";
const MAX_SLEEP = 2_147_483_647;

export interface PullRequest {
/** A repository on github.com. */
export interface Repository {
owner: string;
repo: string;
}

export interface PullRequest extends Repository {
number: number;
}

Expand All @@ -34,22 +38,30 @@ export type ObjectValue = Record<string, unknown>;
/** Accept a GitHub PR URL, or a number in the origin repository. */
export function pullRequest(value: string, root: string): PullRequest {
if (/^[1-9]\d*$/.test(value)) {
const origin = execFileSync("git", ["-C", root, "remote", "get-url", "origin"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const match =
/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/.exec(
origin,
);
if (!match) throw new Error("origin must name a github.com repository; pass a GitHub PR URL instead");
return { owner: match[1], repo: match[2], number: Number(value) };
const repository = githubRepository(remoteUrl(root, "origin"));
if (!repository) throw new Error("origin must name a github.com repository; pass a GitHub PR URL instead");
return { ...repository, number: Number(value) };
}
const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/([1-9]\d*)\/?(?:[?#].*)?$/.exec(value);
if (!match) throw new GitHubInputError("expected a GitHub pull request URL or a PR number in origin");
return { owner: match[1], repo: match[2], number: Number(match[3]) };
}

/** The URL a git remote fetches from. */
export function remoteUrl(root: string, remote: string): string {
return execFileSync("git", ["-C", root, "remote", "get-url", remote], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}

/** The github.com repository a remote URL names, or `null` for any other host. */
export function githubRepository(url: string): Repository | null {
const match =
/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
return match ? { owner: match[1], repo: match[2] } : null;
Comment on lines +59 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize credentialed GitHub remote URLs

When origin is a valid credentialed HTTPS URL such as https://user:token@github.com/owner/repo.git, this regex returns null. The claim check then treats GitHub as an unrelated forge and skips all open PRs and fork-parent branches, so quest mv can rename a quest actively claimed from another fork despite its advertised guard. Parse the URL structurally or accept standard credential-bearing GitHub URLs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recorded as a follow-up rather than fixed here: this regex predates the PR (it came from quest wait's and quest status's PR-number parsing), so parsing remote URLs structurally, including credentialed HTTPS ones, belongs in the shared client for every command at once.

(Written by Claude Opus 5.5)

}

export function gitHubDependencies(warn: (message: string) => void): GitHubDependencies {
let token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (!token) {
Expand Down Expand Up @@ -136,6 +148,24 @@ export class GitHub {
}
}

/** One page of a REST listing, and its response for the next link. */
async list(path: string): Promise<{ rows: ObjectValue[]; response: Response }> {
const response = await this.request(path);
if (response.status !== 200) throw new Error("unexpected GitHub 304 without a cached response");
return { rows: array(await response.json()), response };
}

/** Every page of a REST listing. */
async all(path: string): Promise<ObjectValue[]> {
const rows: ObjectValue[] = [];
for (let next: string | undefined = path; next;) {
const page = await this.list(next);
rows.push(...page.rows);
next = link(page.response, "next");
}
return rows;
}

/** Run a GraphQL query and return its data, failing on any reported error. */
async graphql(query: string, variables: ObjectValue): Promise<ObjectValue> {
const response = await this.request("/graphql", undefined, { query, variables });
Expand Down
Loading
Loading