From 83beb4c4ee52ef1e26cdbe0c517012bd928d92ff Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:44:22 -0700 Subject: [PATCH 1/5] chore(quest): claim quest/m0/landed-quests.md Co-Authored-By: Claude Opus 5.5 From 9e77cb34dea4377815215bc5328c2e22b009d66a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:48:27 -0700 Subject: [PATCH 2/5] feat(check): guard that a quest's PR leaves its quest accurate quest check --base [--branch ] flags a quest branch whose diff from the merge base neither deletes nor edits its quest. The guide states the rule once, and the merge skill runs the guard before enabling auto-merge. Co-Authored-By: Claude Opus 5.5 --- README.md | 32 ++++++++- assets/AGENTS.md | 5 ++ assets/skills/merge.md | 4 ++ quest/m0/README.md | 1 - quest/m0/landed-quests.md | 77 --------------------- src/cli/landed.ts | 60 +++++++++++++++++ src/cli/main.ts | 19 +++++- test/landed.test.ts | 138 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 255 insertions(+), 81 deletions(-) delete mode 100644 quest/m0/landed-quests.md create mode 100644 src/cli/landed.ts create mode 100644 test/landed.test.ts diff --git a/README.md b/README.md index 577dd266..e937ff4d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -146,6 +147,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. +## Check a quest's PR + +A PR from a quest's branch must leave that quest accurate. `quest check --base +` runs the usual check, then compares the working tree with its merge base +with ``. When the branch is `quest/`, naming `quest/.md` or an +epic's `quest//README.md` at the merge base, the diff must delete or edit +that file. Any other branch passes. The branch defaults to the current one; +`--branch ` 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. + ## Wait for pull request activity `quest wait` blocks until a GitHub PR has a review, comment, review-thread diff --git a/assets/AGENTS.md b/assets/AGENTS.md index c071251a..62829848 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -125,6 +125,11 @@ For example, `quest/m1/foo/bar.md` is branch `quest/m1/foo/bar`. Push claims and updates with an explicit refspec: `git push refs/heads/:refs/heads/`. 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 ` flags a quest branch whose diff does neither. + When a quest is complete with no open decisions, mark its PR ready for review. Otherwise keep it a draft and list its open decisions, or what remains unfinished, in the body. A draft suggests `/quest-iterate`; a PR ready for review suggests `/quest-merge`. diff --git a/assets/skills/merge.md b/assets/skills/merge.md index a79cfc69..66656c7a 100644 --- a/assets/skills/merge.md +++ b/assets/skills/merge.md @@ -39,6 +39,10 @@ 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 check --base / --branch `. +A stale local base can predate the quest and skip the check. +On a finding, fix the branch: delete the finished quest and its links, 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. 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 diff --git a/quest/m0/README.md b/quest/m0/README.md index fe6546c4..69bd032d 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -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 diff --git a/quest/m0/landed-quests.md b/quest/m0/landed-quests.md deleted file mode 100644 index 66e7dcf9..00000000 --- a/quest/m0/landed-quests.md +++ /dev/null @@ -1,77 +0,0 @@ -# [S] A quest's PR leaves its quest accurate - -## Goal - -A PR merged from a quest's branch leaves the tree accurate: a PR that -finishes its quest deletes the file and every link to it, and a PR that -leaves work rewrites the quest to that remainder instead of noting it only -in the PR body. Merging refuses a quest-branch PR that does neither. - -In moq-dev/moq on 2026-10-20, the `audit-quests` run found quests left -behind by merged PRs from their own branches. Three finished their quest -without deleting it ([moq-dev/moq#5926](https://github.com/moq-dev/moq/pull/5926), -[#5992](https://github.com/moq-dev/moq/pull/5992), -[#5993](https://github.com/moq-dev/moq/pull/5993)). Three partial drafts -merged through `/quest-complete` with the remainder written only in the PR -body ([#5989](https://github.com/moq-dev/moq/pull/5989), -[#5991](https://github.com/moq-dev/moq/pull/5991), -[#6006](https://github.com/moq-dev/moq/pull/6006)). Stale quests then rank as -ready work and get picked up again. No skill step deletes or trims the quest, -and `merge` enables auto-merge without looking at the file. - -Non-goals: judging whether a rewrite is a faithful remainder (the agent and -reviewers still do), and PRs not on a quest branch, such as a contributor's -fork branch that implements a quest; those skip the guard. Merges that -bypass the merge skill, such as a maintainer's click on GitHub or in the -[decision inbox](/quest/m0/kix/merge.md), are guarded only where the -repository runs the guard as a required check; the hosted service has no -checkout to diff. - -## Plan - -Decided 2026-10-20 (user, planned from moq-dev/moq): - -- One quest: the guide rule and the mechanical guard land together. -- State the rule once, in `assets/AGENTS.md`'s Execution section next to the - existing "When a quest is complete" paragraph: the PR that finishes a quest - deletes its file and every link to it, and a PR that leaves work rewrites - the quest to what remains. The other skills already read the guide, so they - don't repeat it. Update `README.md`'s matching sentence if it disagrees. - Rejected: repeating the rule in start, iterate, ready, merge, and complete. -- Guard: `quest check --base `. When the head branch is a quest branch - (`quest/`, the quest's path without `.md`), the diff from the merge - base of `` must delete or modify that quest file (`quest/.md`, or - `quest//README.md` for an epic); otherwise it is a finding naming the - quest and the rule. A head branch that is not a quest branch, or names no - existing quest at the base, adds no finding. The head defaults to the - current branch; `--branch ` overrides it, because agents often push a - differently named local branch to the quest branch with an explicit - refspec. A plain `quest check` is unchanged. The diff needs git, not a - forge, so it lives in `src/cli/` beside the other git callers and keeps - `src/core` free of it. Rejected: a CI action alone (skills would run - nothing locally), and a skill step alone with no code. -- A modified file passes even if the edit does not trim the quest (a link - fix, say). Accepted: the guard is a floor against forgetting, not a review. -- `assets/skills/merge.md` fetches the PR's base and head, then runs - `quest check --base / --branch ` on a checkout of the - PR head before enabling auto-merge. A stale or missing local base branch - would put the merge base before the quest existed and skip it. On a - finding, it fixes the branch: delete the quest and its links, or rewrite it - to the remainder the PR body names, then push, re-check, and continue. It - asks the user only when what remains is unclear. `/quest-complete` and - `/quest-ready` reach merging through the merge skill, so they need no change - 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. - -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 diff --git a/src/cli/landed.ts b/src/cli/landed.ts new file mode 100644 index 00000000..46695b15 --- /dev/null +++ b/src/cli/landed.ts @@ -0,0 +1,60 @@ +// 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 `:./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 []; + 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`: `.md`, or an epic's `/README.md`. */ +function questAt(root: string, commit: string, branch: string): string | null { + if (!branch.startsWith("quest/") || branch.endsWith("/")) return null; + for (const path of [`${branch}.md`, `${branch}/README.md`]) { + if (git(root, ["cat-file", "-e", `${commit}:./${path}`]).status === 0) return path; + } + return 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; +} diff --git a/src/cli/main.ts b/src/cli/main.ts index e75c0d52..59ab0ba6 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -7,6 +7,7 @@ import { Command, CommanderError, InvalidArgumentError, Option } from "commander import { description, version } from "../../package.json"; import { blockers, check, formatFinding, label, lookup, ready, renderBlocker, unblocked } from "../core"; +import { landed } from "./landed"; import { init, uninstall } from "./setup"; import { GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; @@ -54,9 +55,23 @@ export function main(argv: string[], out: Output): number | Promise { 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 quest/, naming quest/.md or an epic's quest//README.md at the merge base with " + + ", the working tree must delete or edit that file; 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 " + + "first: a stale base can predate the quest and skip it.", + ) + .option("--base ", "Also check the quest branch's diff from its merge base with this ref") + .option("--branch ", "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`); diff --git a/test/landed.test.ts b/test/landed.test.ts new file mode 100644 index 00000000..c541da54 --- /dev/null +++ b/test/landed.test.ts @@ -0,0 +1,138 @@ +// `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 is checked against its README", () => { + const tree = repo("quest/m0/epic"); + expect(tree.run("check", "--base", "main").stderr).toContain( + `quest/m0/epic/README.md: branch quest/m0/epic ${FINDING}`, + ); + tree.append("quest/m0/epic/README.md", "\nMore.\n"); + expect(tree.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); +}); From cf27b053dbe7b8fc1333b426be0414d36d1c6b08 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:02:35 -0700 Subject: [PATCH 3/5] fix(skills): run the merge guard with a trusted quest and name epic branches Address Codex review on kixelated/quest#169: the merge skill runs a quest the PR cannot modify, pointed at the head with --root, and the guide states that an epic's branch is its directory, matching the guard and kix. Co-Authored-By: Claude Opus 5.5 --- assets/AGENTS.md | 4 ++-- assets/skills/merge.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/assets/AGENTS.md b/assets/AGENTS.md index 62829848..3f6f89b8 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -119,8 +119,8 @@ stops. If an upstream branch looks stale (old, with no open PR), offer the user to take it over. -A quest's branch is its path without `.md`. -For example, `quest/m1/foo/bar.md` is branch `quest/m1/foo/bar`. +A quest's branch is its path without `.md`, and an epic's is its directory. +For example, `quest/m1/foo/bar.md` is branch `quest/m1/foo/bar`, and `quest/m1/foo/README.md` is `quest/m1/foo`. Push claims and updates with an explicit refspec: `git push refs/heads/:refs/heads/`. A bare branch name can push to the base branch instead, for example under `push.default=upstream` when the quest branch tracks it. diff --git a/assets/skills/merge.md b/assets/skills/merge.md index 66656c7a..8ce2c8b1 100644 --- a/assets/skills/merge.md +++ b/assets/skills/merge.md @@ -39,7 +39,8 @@ 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 check --base / --branch `. +Before enabling auto-merge, fetch the PR's base branch and head, check out the head, and run `quest --root check --base / --branch `. +Run a `quest` the PR cannot modify, such as an installed release or a build from the base, not one from the head. A stale local base can predate the quest and skip the check. On a finding, fix the branch: delete the finished quest and its links, 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. From 5372292a3c4681a83b720830ff1ce3deea593558 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:21:06 -0700 Subject: [PATCH 4/5] fix(check): resolve a README-named epic's branch to its own README A branch ending in /README names a nested epic called README; README.md is its parent's epic, whose branch is the directory. Address Codex review on kixelated/quest#169. Co-Authored-By: Claude Opus 5.5 --- src/cli/landed.ts | 9 +++++++-- test/landed.test.ts | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/cli/landed.ts b/src/cli/landed.ts index 46695b15..3e623a4b 100644 --- a/src/cli/landed.ts +++ b/src/cli/landed.ts @@ -38,10 +38,15 @@ export function landed(root: string, base: string, branch?: string): Finding[] { ]; } -/** The quest file `branch` names, if it existed at `commit`: `.md`, or an epic's `/README.md`. */ +/** + * The quest file `branch` names, if it existed at `commit`: `.md`, or an + * epic's `/README.md`. A `README.md` is its directory's epic, so a branch + * ending in `/README` names only a nested epic called `README`. + */ function questAt(root: string, commit: string, branch: string): string | null { if (!branch.startsWith("quest/") || branch.endsWith("/")) return null; - for (const path of [`${branch}.md`, `${branch}/README.md`]) { + const epic = `${branch}/README.md`; + for (const path of branch.endsWith("/README") ? [epic] : [`${branch}.md`, epic]) { if (git(root, ["cat-file", "-e", `${commit}:./${path}`]).status === 0) return path; } return null; diff --git a/test/landed.test.ts b/test/landed.test.ts index c541da54..57d9f2f4 100644 --- a/test/landed.test.ts +++ b/test/landed.test.ts @@ -8,9 +8,10 @@ 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 { +/** The baseline tree, after `setup`, committed on `main`, then checked out on `branch`. */ +function repo(branch: string, setup?: (tree: Tree) => void): Tree { const tree = Tree.baseline(); + setup?.(tree); git(tree, "init", "--quiet", "--initial-branch=main"); commit(tree, "baseline"); git(tree, "switch", "--quiet", "-c", branch); @@ -88,6 +89,20 @@ test("an epic's branch is checked against its README", () => { expect(tree.run("check", "--base", "main").code).toBe(0); }); +test("an epic named README is checked against its own README, not its parent's", () => { + const tree = repo("quest/m0/epic/README", (tree) => + tree + .write("quest/m0/epic/README/README.md", "# [S] Nested\n\n## Goal\n\nAn epic named README.\n") + .append("quest/m0/epic/README.md", "- [Nested](/quest/m0/epic/README/README.md)\n"), + ); + tree.append("quest/m0/epic/README.md", "\nParent.\n"); + expect(tree.run("check", "--base", "main").stderr).toContain( + `quest/m0/epic/README/README.md: branch quest/m0/epic/README ${FINDING}`, + ); + tree.append("quest/m0/epic/README/README.md", "\nMore.\n"); + expect(tree.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); From 1cafbdcd80859d37f90222c9773894f9b9e02e9f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:14:15 -0700 Subject: [PATCH 5/5] fix(check): name an epic's branch by its README's path without .md An epic's branch is quest//README, matching the guide and the CLI's questBranch. The directory form collides with child branches in git's ref namespace. Revert the guide's directory wording and drop the README-named epic special case, which no longer applies. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- assets/AGENTS.md | 4 ++-- src/cli/landed.ts | 12 ++++-------- src/cli/main.ts | 4 ++-- test/landed.test.ts | 25 +++++++------------------ 5 files changed, 18 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8690ee3e..4db589f3 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,9 @@ local readiness as `ready`; verify claims and outside conditions before starting A PR from a quest's branch must leave that quest accurate. `quest check --base ` runs the usual check, then compares the working tree with its merge base -with ``. When the branch is `quest/`, naming `quest/.md` or an -epic's `quest//README.md` at the merge base, the diff must delete or edit -that file. Any other branch passes. The branch defaults to the current one; +with ``. When the branch is a quest's path without `.md` (an epic's is +`quest//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 ` names the PR's head branch instead: ```sh diff --git a/assets/AGENTS.md b/assets/AGENTS.md index fdc02d0b..970c8a11 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -119,8 +119,8 @@ stops. If an upstream branch looks stale (old, with no open PR), offer the user to take it over. -A quest's branch is its path without `.md`, and an epic's is its directory. -For example, `quest/m1/foo/bar.md` is branch `quest/m1/foo/bar`, and `quest/m1/foo/README.md` is `quest/m1/foo`. +A quest's branch is its path without `.md`. +For example, `quest/m1/foo/bar.md` is branch `quest/m1/foo/bar`. Push claims and updates with an explicit refspec: `git push refs/heads/:refs/heads/`. A bare branch name can push to the base branch instead, for example under `push.default=upstream` when the quest branch tracks it. diff --git a/src/cli/landed.ts b/src/cli/landed.ts index 3e623a4b..85c43949 100644 --- a/src/cli/landed.ts +++ b/src/cli/landed.ts @@ -39,17 +39,13 @@ export function landed(root: string, base: string, branch?: string): Finding[] { } /** - * The quest file `branch` names, if it existed at `commit`: `.md`, or an - * epic's `/README.md`. A `README.md` is its directory's epic, so a branch - * ending in `/README` names only a nested epic called `README`. + * 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 epic = `${branch}/README.md`; - for (const path of branch.endsWith("/README") ? [epic] : [`${branch}.md`, epic]) { - if (git(root, ["cat-file", "-e", `${commit}:./${path}`]).status === 0) return path; - } - return null; + const path = `${branch}.md`; + return git(root, ["cat-file", "-e", `${commit}:./${path}`]).status === 0 ? path : null; } function currentBranch(root: string): string { diff --git a/src/cli/main.ts b/src/cli/main.ts index 89f1ae54..37c91534 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -62,8 +62,8 @@ export function main(argv: string[], out: Output): number | Promise { .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 quest/, naming quest/.md or an epic's quest//README.md at the merge base with " + - ", the working tree must delete or edit that file; a PR that finishes the quest deletes it, and " + + "is a quest's path without .md (an epic's is quest//README) and that quest exists at the merge " + + "base with , 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 " + "first: a stale base can predate the quest and skip it.", ) diff --git a/test/landed.test.ts b/test/landed.test.ts index 57d9f2f4..8aa8fa34 100644 --- a/test/landed.test.ts +++ b/test/landed.test.ts @@ -8,10 +8,9 @@ import { Tree } from "./fixture"; const FINDING = "neither deletes nor edits its quest since main"; -/** The baseline tree, after `setup`, committed on `main`, then checked out on `branch`. */ -function repo(branch: string, setup?: (tree: Tree) => void): Tree { +/** The baseline tree committed on `main`, then checked out on `branch`. */ +function repo(branch: string): Tree { const tree = Tree.baseline(); - setup?.(tree); git(tree, "init", "--quiet", "--initial-branch=main"); commit(tree, "baseline"); git(tree, "switch", "--quiet", "-c", branch); @@ -80,27 +79,17 @@ test("the working tree counts, before it is committed", () => { expect(tree.run("check", "--base", "main").code).toBe(0); }); -test("an epic's branch is checked against its README", () => { - const tree = repo("quest/m0/epic"); +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 ${FINDING}`, + `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 named README is checked against its own README, not its parent's", () => { - const tree = repo("quest/m0/epic/README", (tree) => - tree - .write("quest/m0/epic/README/README.md", "# [S] Nested\n\n## Goal\n\nAn epic named README.\n") - .append("quest/m0/epic/README.md", "- [Nested](/quest/m0/epic/README/README.md)\n"), - ); - tree.append("quest/m0/epic/README.md", "\nParent.\n"); - expect(tree.run("check", "--base", "main").stderr).toContain( - `quest/m0/epic/README/README.md: branch quest/m0/epic/README ${FINDING}`, - ); - tree.append("quest/m0/epic/README/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", () => {