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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ own quest, and it becomes ready once its children have merged. The top folders
(`m0`, `m1`, ...) are **milestones** that give the work a delivery horizon.

When a quest is complete, its PR removes the plan and the links that depended
on it, so the quests that required it can become ready. Milestones are never
on it, so the quests that required it can become ready. A PR that leaves work
rewrites the plan to what remains. Milestones are never
deleted, so a PR that finishes one (its README has no `Required` left) removes
the links to that milestone too; `quest check` flags any it misses. The
finished plan stays in Git history, beside the code that completed it.
Expand Down Expand Up @@ -181,6 +182,35 @@ checks only on PRs ready for review: the `pull_request` trigger lists
`ready_for_review`, and each job has the condition
`github.event_name != 'pull_request' || github.event.pull_request.draft == false`.

## Check a quest's PR

A PR from a quest's branch must leave that quest accurate. `quest check --base
<ref>` runs the usual check, then compares the working tree with its merge base
with `<ref>`. When the branch is a quest's path without `.md` (an epic's is
`quest/<path>/README`) and that quest exists at the merge base, the diff must
delete or edit it. Any other branch passes. The branch defaults to the current one;
`--branch <name>` names the PR's head branch instead:

```sh
git fetch origin main
quest check --base origin/main --branch quest/m0/export
```

Fetch the base first, since a stale one can predate the quest. The
`quest-merge` skill runs this before enabling auto-merge. A repository with
`quest` installed in CI can also run it on pull requests, where the checkout is
detached, so pass the head branch:

```yaml
- uses: actions/checkout@v7
with:
fetch-depth: 0
- run: quest check --base "origin/$GITHUB_BASE_REF" --branch "$GITHUB_HEAD_REF"
```

The check is a floor, not a review: any edit to the quest passes, so reviewers
still judge whether a rewrite matches what remains.

## Move and delete quests

`quest mv` and `quest rm` move or delete a quest or an epic with `git mv` and
Expand Down
5 changes: 5 additions & 0 deletions assets/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ For example, `quest/m1/foo/bar.md` is branch `quest/m1/foo/bar`.
Push updates with an explicit refspec: `git push <remote> refs/heads/<quest-branch>:refs/heads/<quest-branch>`.
A bare branch name can push to the base branch instead, for example under `push.default=upstream` when the quest branch tracks it.

A quest's PR leaves its quest accurate when it merges.
The PR that finishes a quest deletes its file and every link to it.
A PR that leaves work rewrites the quest to what remains, rather than noting it only in the PR body.
`quest check --base <ref>` flags a quest branch whose diff does neither.

Run the repository's checks locally while the PR is a draft; CI may run only on PRs ready for review.
When a quest is complete with no open decisions, mark its PR ready for review.
Otherwise keep it a draft and hand it back: add a `## Decisions needed` section to the body listing its open decisions, or what remains unfinished.
Expand Down
5 changes: 5 additions & 0 deletions assets/skills/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ run each wait in the foreground instead, bounded by the shell's own time limit,
and re-run it with `--since` until it returns activity.

Merge the PR *only* after all outstanding decisions have been confirmed.
Before enabling auto-merge, fetch the PR's base branch and head, check out the head, and run `quest --root <head-checkout> check --base <remote>/<base> --branch <head-branch>`.
Run a `quest` the PR cannot modify, such as an installed release or a build from the base, from a directory outside the head checkout: the head's files, including runtime config such as `bunfig.toml`, are untrusted.
A stale local base can predate the quest and skip the check.
On a finding, fix the branch: delete the finished quest with `quest rm`, or rewrite it to the remainder the PR body names.
Then push, re-check, and continue; ask the user only when what remains is unclear.
Comment on lines +43 to +47

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 Verify a clean head before trusting the landed check

When the PR head is already checked out with an uncommitted edit or deletion of its quest, landed.ts compares the merge base against that working tree, so this check passes even though the fetched PR head does not contain the change. Because this sequence pushes only after a finding, it can then enable auto-merge without publishing the quest update; git checkout -h confirms that only a forced checkout throws away local modifications. Require a clean checkout or validate the committed fetched head before trusting the result.

AGENTS.md reference: assets/AGENTS.md:L129-L132

Useful? React with 👍 / 👎.

Leave a summary of the changes and decisions made and enable auto-merge with the full 40-character head SHA.
If your caller said it watches the merge queue, report once auto-merge is enabled.
Otherwise wait until the PR merges. If it leaves the queue unmerged or auto-merge
Expand Down
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ ownership-manifest design:

## Required

- [Accurate landed quests](/quest/m0/landed-quests.md) - a quest-branch PR deletes or rewrites its quest, and `quest check --base` refuses one that does neither
- [Expensive command gate](/quest/m0/local-gate.md) - shared CPU admission across worktrees and repositories
- [Recoverable worktrees](/quest/m0/worktree.md) - disk admission, checkpoints, release, and restore
- [Agent lifecycle integration](/quest/m0/worktree-agents.md) - reclaim completed and suspended checkouts through their harness
Expand Down
72 changes: 0 additions & 72 deletions quest/m0/landed-quests.md

This file was deleted.

61 changes: 61 additions & 0 deletions src/cli/landed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Whether a PR from a quest's branch leaves that quest accurate: it deletes
// the finished quest or rewrites it to what remains, so either way the diff
// from the base touches the quest's file. Reading the diff needs git, so it
// lives here rather than in the core.

import { spawnSync } from "node:child_process";

import { type Finding } from "../core";

/**
* A finding when `branch` (default: the current branch) is a quest's branch
* and the working tree neither deletes nor modifies that quest relative to its
* merge base with `base`. Not a quest branch, or no such quest at the merge
* base, is no finding.
*/
export function landed(root: string, base: string, branch?: string): Finding[] {
const name = branch ?? currentBranch(root);
const mergeBase = git(root, ["merge-base", base, "HEAD"]);
if (mergeBase.status !== 0) {
throw new Error(`no merge base between ${base} and HEAD: ${mergeBase.stderr.trim() || "unrelated histories"}`);
}
const commit = mergeBase.stdout.trim();
const path = questAt(root, commit, name);
if (path === null) return [];
// The pathspec and `<rev>:./path` are relative to the root, which may sit
// below the repository's top level.
const diff = git(root, ["diff", "--quiet", "--no-ext-diff", commit, "--", `./${path}`]);
if (diff.status === 1) return [];
Comment on lines +27 to +28

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 Ignore mode-only changes when checking quest edits

On Unix repositories with core.fileMode enabled, merely toggling the executable bit on the quest makes git diff --quiet return 1, so this function reports success even though the quest content was neither deleted nor rewritten. A PR can therefore leave the stale quest intact while satisfying the new guard; compare the blob contents or otherwise reject metadata-only changes.

Useful? React with 👍 / 👎.

if (diff.status !== 0) throw new Error(`git diff ${commit} -- ${path}: ${diff.stderr.trim()}`);
return [
{
path,
line: null,
message:
`branch ${name} neither deletes nor edits its quest since ${base}; a PR that finishes a quest deletes ` +
"it and every link to it, and one that leaves work rewrites it to what remains (`quest guide`, Execution)",
},
];
}

/**
* The quest file `branch` names, if it existed at `commit`: the branch plus
* `.md`, so an epic's branch ends in `/README`.
*/
function questAt(root: string, commit: string, branch: string): string | null {
if (!branch.startsWith("quest/") || branch.endsWith("/")) return null;
const path = `${branch}.md`;
return git(root, ["cat-file", "-e", `${commit}:./${path}`]).status === 0 ? path : null;
}

function currentBranch(root: string): string {
const head = git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
if (head.status !== 0) throw new Error("HEAD is not on a branch; pass --branch with the PR's head branch");
return head.stdout.trim();
}

function git(root: string, args: string[]): { status: number | null; stdout: string; stderr: string } {
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
if (result.error) throw new Error(`running git: ${result.error.message}`);
return result;
}
19 changes: 17 additions & 2 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../core";
import { claims, questBranch } from "./claims";
import { gitHubDependencies, GitHubInputError, pullRequest } from "./github";
import { landed } from "./landed";
import { type Report, moveQuest, removeQuest } from "./move";
import { init, uninstall } from "./setup";
import { Refusal, describe, releaseQuest, startQuest } from "./start";
Expand Down Expand Up @@ -69,9 +70,23 @@ export function main(argv: string[], out: Output): number | Promise<number> {

program
.command("check")
.description("Report every structural mistake in the tree; exit non-zero if any")
.action(() => {
.summary("Report every structural mistake in the tree; exit non-zero if any")
.description(
"Report every structural mistake in the tree; exit non-zero if any.\n\n" +
"With --base, also check that a PR from a quest's branch leaves that quest accurate. When the branch " +
"is a quest's path without .md (an epic's is quest/<path>/README) and that quest exists at the merge " +
"base with <ref>, the working tree must delete or edit it; a PR that finishes the quest deletes it, and " +
"one that leaves work rewrites it to what remains. Any other branch adds no finding. Fetch <ref> " +
"first: a stale base can predate the quest and skip it.",
)
.option("--base <ref>", "Also check the quest branch's diff from its merge base with this ref")
.option("--branch <name>", "The PR's head branch, when it differs from the current branch (needs --base)")
.action((options: { base?: string; branch?: string }) => {
if (options.branch !== undefined && options.base === undefined) {
throw new UsageError("--branch requires --base");
}
const findings = check(load(root()), (path) => exists(root(), path));
if (options.base !== undefined) findings.push(...landed(root(), options.base, options.branch));
if (findings.length === 0) {
const count = collect(root()).length;
print(theme ? theme.checked(count) : `quest: ${count} documents ok`);
Expand Down
142 changes: 142 additions & 0 deletions test/landed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// `quest check --base`: a PR from a quest's branch deletes or edits its quest.

import { execFileSync } from "node:child_process";

import { expect, test } from "vitest";

import { Tree } from "./fixture";

const FINDING = "neither deletes nor edits its quest since main";

/** The baseline tree committed on `main`, then checked out on `branch`. */
function repo(branch: string): Tree {
const tree = Tree.baseline();
git(tree, "init", "--quiet", "--initial-branch=main");
commit(tree, "baseline");
git(tree, "switch", "--quiet", "-c", branch);
return tree;
}

function git(tree: Tree, ...args: string[]): string {
return execFileSync("git", ["-C", tree.path, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}

function commit(tree: Tree, message: string) {
git(tree, "add", "--all");
git(
tree,
"-c",
"user.name=Quest",
"-c",
"user.email=quest@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"--quiet",
"--allow-empty",
"-m",
message,
);
}

test("a quest branch that deletes its quest passes", () => {
const tree = repo("quest/m0/epic/one");
tree.remove("quest/m0/epic/one.md")
.write(
"quest/m0/epic/README.md",
"# Epic\n\n## Goal\n\nAn epic.\n\n## Required\n\n- [Two](/quest/m0/epic/two.md)\n",
)
.write("quest/m0/epic/two.md", "# [S] Two\n\n## Goal\n\nAnother quest.\n");
commit(tree, "finish one");
expect(tree.run("check", "--base", "main")).toEqual({ code: 0, stdout: "quest: 4 documents ok\n", stderr: "" });
});

test("a quest branch that edits its quest passes", () => {
const tree = repo("quest/m0/epic/one");
tree.append("quest/m0/epic/one.md", "\n## Plan\n\nWhat remains.\n");
commit(tree, "trim one");
expect(tree.run("check", "--base", "main").code).toBe(0);
});

test("a quest branch that leaves its quest untouched is a finding", () => {
const tree = repo("quest/m0/epic/one");
tree.write("src.ts", "work\n");
commit(tree, "work");
const out = tree.run("check", "--base", "main");
expect(out.code).toBe(1);
expect(out.stderr).toBe(
`quest: quest/m0/epic/one.md: branch quest/m0/epic/one ${FINDING}; a PR that finishes a quest deletes it and ` +
"every link to it, and one that leaves work rewrites it to what remains (`quest guide`, Execution)\n",
);
// A plain check is unchanged.
expect(tree.run("check").code).toBe(0);
});

test("the working tree counts, before it is committed", () => {
const tree = repo("quest/m0/epic/one");
expect(tree.run("check", "--base", "main").stderr).toContain(FINDING);
tree.append("quest/m0/epic/one.md", "\nMore.\n");
expect(tree.run("check", "--base", "main").code).toBe(0);
});

test("an epic's branch, its README's path without .md, is checked against its README", () => {
const tree = repo("quest/m0/epic/README");
expect(tree.run("check", "--base", "main").stderr).toContain(
`quest/m0/epic/README.md: branch quest/m0/epic/README ${FINDING}`,
);
tree.append("quest/m0/epic/README.md", "\nMore.\n");
expect(tree.run("check", "--base", "main").code).toBe(0);
});

test("an epic's directory is not its branch", () => {
expect(repo("quest/m0/epic").run("check", "--base", "main").code).toBe(0);
});

test("a branch that is not a quest branch adds no finding", () => {
const tree = repo("fix/typo");
expect(tree.run("check", "--base", "main").code).toBe(0);
});

test("a branch naming no quest at the merge base adds no finding", () => {
const tree = repo("quest/m0/epic/three");
tree.write("quest/m0/epic/three.md", "# [S] Three\n\n## Goal\n\nNew.\n");
tree.append("quest/m0/epic/README.md", "- [Three](/quest/m0/epic/three.md)\n");
commit(tree, "plan three");
expect(tree.run("check", "--base", "main").code).toBe(0);
});

test("--branch overrides the current branch", () => {
const tree = repo("local-name");
expect(tree.run("check", "--base", "main").code).toBe(0);
expect(tree.run("check", "--base", "main", "--branch", "quest/m0/epic/two").stderr).toContain(
`quest/m0/epic/two.md: branch quest/m0/epic/two ${FINDING}`,
);
});

test("the base is compared from its merge base, not its tip", () => {
const tree = repo("quest/m0/epic/two");
// main moves on and edits the quest; the branch itself never touched it.
git(tree, "switch", "--quiet", "main");
tree.append("quest/m0/epic/two.md", "\nUpstream.\n");
commit(tree, "upstream");
git(tree, "switch", "--quiet", "quest/m0/epic/two");
expect(tree.run("check", "--base", "main").stderr).toContain(FINDING);
});

test("a detached HEAD needs --branch", () => {
const tree = repo("quest/m0/epic/one");
git(tree, "switch", "--quiet", "--detach");
const out = tree.run("check", "--base", "main");
expect(out.code).toBe(1);
expect(out.stderr).toContain("HEAD is not on a branch; pass --branch");
});

test("an unknown base fails", () => {
const out = repo("quest/m0/epic/one").run("check", "--base", "nope");
expect(out.code).toBe(1);
expect(out.stderr).toContain("no merge base between nope and HEAD");
});

test("--branch without --base is a usage error", () => {
expect(Tree.baseline().run("check", "--branch", "quest/m0/epic/one").code).toBe(2);
});
Loading