From b56fa4069e6b123d67a5037d461cc89ff8304185 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 22:38:54 -0700 Subject: [PATCH 1/4] chore(quest): claim quest/m0/kix/epic-branches.md Quest-Claim: 2b961c2f95e38c6d From fbc4794432cf541fe4928661d3b493cc499f7195 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 23:06:40 -0700 Subject: [PATCH 2/4] feat(worker): start and link epics on their README branch kix used an epic's directory as its branch and board page, which git cannot create once a child's branch exists. Share the CLI's rule from the core (questBranch, and branchQuest for the inverse) so the start job, claim mapping, and board links all use quest//README. Co-Authored-By: Claude Opus 5.5 --- cloudflare/src/board/model.ts | 31 ++++++++++++++++++------------- cloudflare/src/github/state.ts | 12 +++++++----- cloudflare/src/inbox/model.ts | 6 +++--- cloudflare/src/jobs/routes.ts | 3 ++- cloudflare/test/board.test.ts | 10 ++++++++-- cloudflare/test/inbox.test.ts | 4 +++- cloudflare/test/jobs.test.ts | 18 ++++++++++++++++++ cloudflare/test/map.test.ts | 4 ++-- cloudflare/test/projects.test.ts | 8 +++++++- quest/m0/kix/README.md | 1 - quest/m0/kix/epic-branches.md | 26 -------------------------- src/cli/claims.ts | 5 ----- src/cli/landed.ts | 9 +++------ src/cli/main.ts | 3 ++- src/cli/move.ts | 4 ++-- src/cli/start.ts | 2 +- src/core/doc.ts | 14 ++++++++++++++ src/core/index.ts | 2 ++ 18 files changed, 92 insertions(+), 70 deletions(-) delete mode 100644 quest/m0/kix/epic-branches.md diff --git a/cloudflare/src/board/model.ts b/cloudflare/src/board/model.ts index 94450e01..a78bc585 100644 --- a/cloudflare/src/board/model.ts +++ b/cloudflare/src/board/model.ts @@ -2,7 +2,20 @@ // status and progress. Readiness comes from the shared core, so the board // always agrees with `quest unblocked`. -import { type Doc, ROOT, blockers, children, comparePaths, entries, isEpic, parse, permanent, ready } from "quest/core"; +import { + type Doc, + ROOT, + blockers, + branchQuest, + children, + comparePaths, + entries, + isEpic, + parse, + permanent, + questBranch, + ready, +} from "quest/core"; import type { Size } from "../layout"; import type { Change, Completed, Project } from "./project"; @@ -65,18 +78,14 @@ export function title(text: string): { size: Size | null; name: string } { return match ? { size: match[1] as Size, name: match[2] } : { size: null, name: text }; } -/** - * A quest's page: its path without `.md`, like its branch name. A README is - * its directory's page. - */ export function boardHref(project: Project): string { return project.href ?? `/repos/${project.name}`; } +/** A quest's page: its branch name, so an epic's ends in `/README`. The root's is the board. */ export function questHref(project: Project, path: string): string { if (path === ROOT) return boardHref(project); - const page = path.replace(/\/README\.md$/, "").replace(/\.md$/, ""); - return `${boardHref(project)}/${encodePath(page)}`; + return `${boardHref(project)}/${encodePath(questBranch(path))}`; } /** @@ -97,13 +106,9 @@ export function milestoneAnchor(milestone: Milestone): string { return milestone.path.split("/")[1] ?? "unsorted"; } -/** The document a page path names: `quest/m0/board`, `quest/m0/epic`, or `quest/m0/epic/README`. */ +/** The document a page path names, as its branch does: `quest/m0/board` or `quest/m0/epic/README`. */ export function findDoc(board: Board, page: string): Doc | null { - for (const path of [`${page}.md`, `${page}/README.md`]) { - const doc = board.docs.get(path); - if (doc) return doc; - } - return null; + return board.docs.get(branchQuest(page)) ?? null; } /** The first paragraph of a section, as plain words. */ diff --git a/cloudflare/src/github/state.ts b/cloudflare/src/github/state.ts index a0e0e41b..0e977c58 100644 --- a/cloudflare/src/github/state.ts +++ b/cloudflare/src/github/state.ts @@ -1,4 +1,4 @@ -import { parse, ready } from "quest/core"; +import { branchQuest, parse, ready } from "quest/core"; import type { Project, Snapshot } from "../board/project"; import { GitHubError, type GitHubRepository, ProjectChanged } from "./service"; import { positiveId } from "./webhook"; @@ -119,11 +119,13 @@ function next(connection: Connection | undefined, cursor: Cursor) { return connection.pageInfo.endCursor; } -/** A branch convention is evidence of association, never execution authority. */ +/** + * The quest `branch` is named for, as `quest start` names it. A branch + * convention is evidence of association, never execution authority. + */ export function associatedQuest(branch: string, paths: Set) { - const exact = `${branch}.md`; - const epic = `${branch}/README.md`; - return paths.has(exact) ? exact : paths.has(epic) ? epic : null; + const path = branchQuest(branch); + return paths.has(path) ? path : null; } function validate(kind: "issue" | "pull", item: Entity) { diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts index 79c8f26c..69611d06 100644 --- a/cloudflare/src/inbox/model.ts +++ b/cloudflare/src/inbox/model.ts @@ -3,7 +3,7 @@ // are one item, so updates, reconnects, and duplicate webhooks coalesce instead // of adding prompts. Agent text and GitHub data stay untrusted display data. -import { parse } from "quest/core"; +import { parse, questBranch } from "quest/core"; import { encodePath } from "../board/model"; import { GitHubError, type Enrollment, GitHubService, NotMaintainer } from "../github/service"; import { type Activity, type ProjectState, readState } from "../github/state"; @@ -64,9 +64,9 @@ function kindOf(target: string): Kind { return target === "devshell" ? "devshell" : "quest"; } -/** A quest's page on the project's board: its path without `.md`, or its epic's directory. */ +/** A quest's page on the project's board: its branch name, so an epic's ends in `/README`. */ export function questHref(project: Enrollment, path: string) { - return `${projectURL(project.owner, project.name)}/board/${encodePath(path.replace(/(\/README)?\.md$/, ""))}`; + return `${projectURL(project.owner, project.name)}/board/${encodePath(questBranch(path))}`; } export type Entity = Pick; diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts index fecb0308..cd7f2979 100644 --- a/cloudflare/src/jobs/routes.ts +++ b/cloudflare/src/jobs/routes.ts @@ -1,4 +1,5 @@ import { type Context, Hono } from "hono"; +import { questBranch } from "quest/core"; import { createAuth } from "../auth"; import * as push from "../push/notify"; import { GitHubError, GitHubService, refName, type Stopped } from "../github/service"; @@ -192,7 +193,7 @@ function resolve(workflow: unknown, target: unknown, state: ProjectState, failed if (workflow === "start") { if (typeof target !== "string" || !startable(state, target, failed)) throw new JobError("Only ready, unclaimed quests on the default branch can start"); - const branch = target.replace(/(\/README)?\.md$/, ""); + const branch = questBranch(target); if (!refName(branch)) throw new JobError("Rename the quest so its path can be a branch name"); return { workflow, target, revision: commit, branch, trusted: true }; } diff --git a/cloudflare/test/board.test.ts b/cloudflare/test/board.test.ts index fe4a3f3b..74005d40 100644 --- a/cloudflare/test/board.test.ts +++ b/cloudflare/test/board.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { finished } from "../scripts/history"; import { render } from "../src/board/markdown"; -import { type Board, blobHref, findGroup, only, questHref, readBoard, trail } from "../src/board/model"; +import { type Board, blobHref, findDoc, findGroup, only, questHref, readBoard, trail } from "../src/board/model"; import type { Project } from "../src/board/project"; const docs: Record = { @@ -86,11 +86,17 @@ describe("board model", () => { expect(items[0].kind === "epic" && items[0].epic.items.length).toBe(1); }); + it("names an epic's page by its branch, not its directory", () => { + expect(questHref(project(), "quest/m0/epic/README.md")).toBe("/repos/demo/quest/m0/epic/README"); + expect(findDoc(board, "quest/m0/epic/README")?.path).toBe("quest/m0/epic/README.md"); + expect(findDoc(board, "quest/m0/epic")).toBeNull(); + }); + it("encodes each board and forge path segment", () => { const source = project(); const path = "quest/m0/an epic#/name#?%.md"; expect(questHref(source, path)).toBe("/repos/demo/quest/m0/an%20epic%23/name%23%3F%25"); - expect(questHref(source, "quest/m0/an epic#/README.md")).toBe("/repos/demo/quest/m0/an%20epic%23"); + expect(questHref(source, "quest/m0/an epic#/README.md")).toBe("/repos/demo/quest/m0/an%20epic%23/README"); expect(blobHref(source, path)).toBe( `https://example.com/acme/demo/blob/${source.commit}/quest/m0/an%20epic%23/name%23%3F%25.md`, ); diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts index 94f2c255..3d31c0c1 100644 --- a/cloudflare/test/inbox.test.ts +++ b/cloudflare/test/inbox.test.ts @@ -528,7 +528,9 @@ describe("decision inbox", () => { it("encodes quest paths in board links", () => { const enrolled = { owner: "upstream", name: "project" } as Parameters[0]; expect(questHref(enrolled, "quest/a#b?.md")).toBe("/projects/github/upstream/project/board/quest/a%23b%3F"); - expect(questHref(enrolled, "quest/epic/README.md")).toBe("/projects/github/upstream/project/board/quest/epic"); + expect(questHref(enrolled, "quest/epic/README.md")).toBe( + "/projects/github/upstream/project/board/quest/epic/README", + ); }); it("skips an item until it changes", async () => { diff --git a/cloudflare/test/jobs.test.ts b/cloudflare/test/jobs.test.ts index 9a999421..7e8a3da3 100644 --- a/cloudflare/test/jobs.test.ts +++ b/cloudflare/test/jobs.test.ts @@ -953,6 +953,24 @@ describe("job routes", () => { ); }); + it("starts an epic on its README branch while a child's branch is claimed", async () => { + const documents = [ + { path: "quest/README.md", content: "# Project\n\n## Goal\n\nBuild it.\n" }, + { path: "quest/epic/README.md", content: "# [S] Epic\n\n## Goal\n\nThe epic's own work.\n" }, + { path: "quest/epic/child.md", content: "# [S] Child\n\n## Goal\n\nClaimed.\n" }, + ]; + await db + .prepare( + "UPDATE github_project_state SET snapshot=json_set(snapshot,'$.documents',json(?)),scan=json_set(scan,'$.occupancy.claims',json(?))", + ) + .bind(JSON.stringify(documents), JSON.stringify(["quest/epic/child"])) + .run(); + expect((await post("", { workflow: "start", target: "quest/epic/child.md" })).status).toBe(409); + expect((await post("", { workflow: "start", target: "quest/epic/README.md" })).status).toBe(201); + const [job] = await store.listJobs(db, 10); + expect(job).toMatchObject({ workflow: "start", target: "quest/epic/README.md", branch: "quest/epic/README" }); + }); + it("starts work while history is still paging, but not before a refresh reads a new pull request head", async () => { // A long history needs many refreshes; open PRs and claims are already current. await db diff --git a/cloudflare/test/map.test.ts b/cloudflare/test/map.test.ts index b76f7cf1..7514c203 100644 --- a/cloudflare/test/map.test.ts +++ b/cloudflare/test/map.test.ts @@ -41,7 +41,7 @@ describe("quest map", () => { it("draws epics and the milestone's own quests as paths in priority order", () => { expect(region.paths.map((path) => path.title)).toEqual(["An epic", "Main path"]); - expect(region.paths[0].href).toBe("/repos/demo/quest/m0/epic"); + expect(region.paths[0].href).toBe("/repos/demo/quest/m0/epic/README"); }); it("orders each path by what it requires, then by priority", () => { @@ -58,7 +58,7 @@ describe("quest map", () => { it("links to the board without a web address", () => { const [offline] = chart(readBoard({ ...project, web: null })); - expect(offline.paths[0].href).toBe("/repos/demo/quest/m0/epic"); + expect(offline.paths[0].href).toBe("/repos/demo/quest/m0/epic/README"); expect(offline.paths[1].waypoints[0].href).toBe("/repos/demo/quest/m0/solo"); }); }); diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts index eb43b281..4a1d7a0e 100644 --- a/cloudflare/test/projects.test.ts +++ b/cloudflare/test/projects.test.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { app } from "../src/index"; import { readBoard } from "../src/board/model"; -import type { ActivityPage } from "../src/github/state"; +import { type ActivityPage, associatedQuest } from "../src/github/state"; import { GitHubService } from "../src/github/service"; import { privateKey } from "./github-key"; import { identity, oauth, origin } from "./login-fixtures"; @@ -739,6 +739,12 @@ function history(): ActivityPage { const event = { installation: { id: 5 }, repository: { id: 10, default_branch: "trunk" } }; describe("enrolled project observations", () => { + it("maps a branch to the quest it names, an epic by its README branch", () => { + const paths = new Set(["quest/epic/README.md", "quest/epic/child.md"]); + expect(associatedQuest("quest/epic/README", paths)).toBe("quest/epic/README.md"); + expect(associatedQuest("quest/epic/child", paths)).toBe("quest/epic/child.md"); + expect(associatedQuest("quest/epic", paths)).toBeNull(); + }); async function enrolled() { await github().service.setContributions(repo, "enrollment-test", "enabled"); } diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 0438fedb..29027105 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -98,7 +98,6 @@ operator-paid-hosting decisions: - [Deploy kix.quest](/quest/m0/kix/deploy.md) - the operator registers the App and deploys the Worker with GitHub sign-in at kix.quest - [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR - [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID -- [Epic branches](/quest/m0/kix/epic-branches.md) - kix starts and links an epic on its `quest//README` branch - [Inbox accuracy](/quest/m0/kix/inbox-accuracy.md) - draft claims, unknown merge states, and paged, pruned activity - [Job recovery](/quest/m0/kix/recovery.md) - retries keep their answers, and blocked branches can start over - [Runner](/quest/m0/kix/runner.md) - `quest runner` on Linux and macOS with Nix devShells, accounts, and the Claude Code adapter diff --git a/quest/m0/kix/epic-branches.md b/quest/m0/kix/epic-branches.md deleted file mode 100644 index dcb17174..00000000 --- a/quest/m0/kix/epic-branches.md +++ /dev/null @@ -1,26 +0,0 @@ -# [S] Start epics on their README branch - -## Goal - -kix starts, claims, and links an epic on the same branch the CLI uses: -`quest//README`, its README's path without `.md`. An epic with claimed -children starts like any other quest. - -## Plan - -Decided 2026-10-21 as a follow-up of -[kixelated/quest#169](https://github.com/kixelated/quest/pull/169), which made -an epic's branch its README's path without `.md` (user decision on that PR): - -- kix still uses the directory form. The start job in - `cloudflare/src/jobs/routes.ts` strips `/README.md`, `associatedQuest` in - `cloudflare/src/github/state.ts` maps branches back to quests, and - `questHref` in `cloudflare/src/board/model.ts` builds board links. Git - cannot create `quest/m0/foo` while any `quest/m0/foo/` branch exists, - so kix cannot start an epic once a child is claimed. -- Use the CLI's rule (`questBranch` in `src/cli/claims.ts`) rather than a - second copy; if the Worker cannot import it, share the mapping from the core. -- kix has no production data, so stored directory-form branches need no - migration. -- Test starting an epic whose child is claimed, mapping `quest//README` - back to the epic, and a bare directory branch matching nothing. diff --git a/src/cli/claims.ts b/src/cli/claims.ts index b5ff04d2..e75654de 100644 --- a/src/cli/claims.ts +++ b/src/cli/claims.ts @@ -16,11 +16,6 @@ import { /** The heading that hands a draft back to the maintainer; only its presence matters. */ const HAND_BACK = /^#{2}[ \t]+Decisions needed[ \t]*#*[ \t]*$/im; -/** A quest's branch: its path without `.md`. */ -export function questBranch(path: string): string { - return path.replace(/\.md$/, ""); -} - /** An open PR, from any head repository, as the claim checks read it. */ export interface OpenPull { number: number; diff --git a/src/cli/landed.ts b/src/cli/landed.ts index 85c43949..d122ae17 100644 --- a/src/cli/landed.ts +++ b/src/cli/landed.ts @@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process"; -import { type Finding } from "../core"; +import { type Finding, branchQuest } from "../core"; /** * A finding when `branch` (default: the current branch) is a quest's branch @@ -38,13 +38,10 @@ export function landed(root: string, base: string, branch?: string): Finding[] { ]; } -/** - * The quest file `branch` names, if it existed at `commit`: the branch plus - * `.md`, so an epic's branch ends in `/README`. - */ +/** The quest file `branch` names, if it existed at `commit`. */ function questAt(root: string, commit: string, branch: string): string | null { if (!branch.startsWith("quest/") || branch.endsWith("/")) return null; - const path = `${branch}.md`; + const path = branchQuest(branch); return git(root, ["cat-file", "-e", `${commit}:./${path}`]).status === 0 ? path : null; } diff --git a/src/cli/main.ts b/src/cli/main.ts index 44c2a2d9..2507ff54 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -14,11 +14,12 @@ import { formatFinding, label, lookup, + questBranch, renderBlocker, unblocked, } from "../core"; import { AgentInputError, HARNESSES, type Launch, processDependencies, runAgent, stopAgent, stopAll } from "./agent"; -import { claims, questBranch } from "./claims"; +import { claims } from "./claims"; import { gate } from "./gate"; import { gitHubDependencies, GitHubInputError, pullRequest } from "./github"; import { landed } from "./landed"; diff --git a/src/cli/move.ts b/src/cli/move.ts index 150bbf83..559f21ab 100644 --- a/src/cli/move.ts +++ b/src/cli/move.ts @@ -4,8 +4,8 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, statSync, writeFileSync } from "node:fs"; import { join, posix, relative, resolve, sep } from "node:path"; -import { check, formatFinding, isQuest, move, normalize, permanent, relocate, remove } from "../core"; -import { type ClaimDependencies, claims, questBranch } from "./claims"; +import { check, formatFinding, isQuest, move, normalize, permanent, questBranch, relocate, remove } from "../core"; +import { type ClaimDependencies, claims } from "./claims"; import { exists, load, sources, under } from "./tree"; /** What a command changed, and what it left for the caller to look at. */ diff --git a/src/cli/start.ts b/src/cli/start.ts index 9721d449..2afb0c29 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; +import { questBranch } from "../core"; import { type ClaimDependencies, type OpenPull, @@ -13,7 +14,6 @@ import { openPullRequests, pullsFrom, remoteBranches, - questBranch, repositoryPath, upstream, } from "./claims"; diff --git a/src/core/doc.ts b/src/core/doc.ts index 8fcf921a..8684eac1 100644 --- a/src/core/doc.ts +++ b/src/core/doc.ts @@ -112,6 +112,20 @@ export function isQuest(path: string): boolean { return name.endsWith(".md") && !NOT_QUESTS.includes(name); } +/** + * A quest's branch: its path without `.md`, so an epic's is + * `quest//README`. The directory form would collide with its children's + * branches, since git cannot hold `quest/m0/foo` beside `quest/m0/foo/bar`. + */ +export function questBranch(path: string): string { + return path.replace(/\.md$/, ""); +} + +/** The quest file a branch names, whether or not it exists: the branch plus `.md`. */ +export function branchQuest(branch: string): string { + return `${branch}.md`; +} + /** Whether the document has this exact `## ` heading. */ export function has(doc: Doc, heading: string): boolean { return doc.headings.some((h) => h.text === heading); diff --git a/src/core/index.ts b/src/core/index.ts index 005bbbe1..2a9c3a5a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -12,6 +12,7 @@ export { type Heading, type Link, type Position, + branchQuest, children, entries, has, @@ -20,6 +21,7 @@ export { owner, parse, permanent, + questBranch, resolve, rooted, } from "./doc"; From 866f1e3f479322841c42792538d7d86e0136d10a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 23:38:47 -0700 Subject: [PATCH 3/4] test(worker): cover epic pages and starts on README branches Co-Authored-By: Claude Opus 5.5 --- cloudflare/test/board.test.ts | 2 +- cloudflare/test/jobs.test.ts | 26 +++++++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cloudflare/test/board.test.ts b/cloudflare/test/board.test.ts index 74005d40..b5e3f1d4 100644 --- a/cloudflare/test/board.test.ts +++ b/cloudflare/test/board.test.ts @@ -146,7 +146,7 @@ describe("quest markdown", () => { "## Plan\n\n[epic](/quest/m0/epic/README.md#goal) [ready](epic/ready.md) [docs](../../docs/a.md) [web](https://example.com)\n", known, ); - expect(plan.html).toContain('href="/repos/demo/quest/m0/epic#goal"'); + expect(plan.html).toContain('href="/repos/demo/quest/m0/epic/README#goal"'); expect(plan.html).toContain('href="/repos/demo/quest/m0/epic/ready"'); expect(plan.html).toContain(`href="https://example.com/acme/demo/blob/${project().commit}/docs/a.md"`); expect(plan.html).toContain('href="https://example.com"'); diff --git a/cloudflare/test/jobs.test.ts b/cloudflare/test/jobs.test.ts index 7e8a3da3..85743fa2 100644 --- a/cloudflare/test/jobs.test.ts +++ b/cloudflare/test/jobs.test.ts @@ -954,21 +954,33 @@ describe("job routes", () => { }); it("starts an epic on its README branch while a child's branch is claimed", async () => { + // The epic's last listed child merged, leaving its own work; another quest under it is claimed. const documents = [ - { path: "quest/README.md", content: "# Project\n\n## Goal\n\nBuild it.\n" }, - { path: "quest/epic/README.md", content: "# [S] Epic\n\n## Goal\n\nThe epic's own work.\n" }, - { path: "quest/epic/child.md", content: "# [S] Child\n\n## Goal\n\nClaimed.\n" }, + { + path: "quest/README.md", + content: "# Project\n\n## Goal\n\nBuild it.\n\n## Required\n\n- [M0](/quest/m0/README.md)\n", + }, + { + path: "quest/m0/README.md", + content: "# First\n\n## Goal\n\nShip.\n\n## Required\n\n- [Epic](/quest/m0/epic/README.md)\n", + }, + { path: "quest/m0/epic/README.md", content: "# [S] Epic\n\n## Goal\n\nThe epic's own work.\n" }, + { path: "quest/m0/epic/child.md", content: "# [S] Child\n\n## Goal\n\nClaimed.\n" }, ]; await db .prepare( "UPDATE github_project_state SET snapshot=json_set(snapshot,'$.documents',json(?)),scan=json_set(scan,'$.occupancy.claims',json(?))", ) - .bind(JSON.stringify(documents), JSON.stringify(["quest/epic/child"])) + .bind(JSON.stringify(documents), JSON.stringify(["quest/m0/epic/child"])) .run(); - expect((await post("", { workflow: "start", target: "quest/epic/child.md" })).status).toBe(409); - expect((await post("", { workflow: "start", target: "quest/epic/README.md" })).status).toBe(201); + expect((await post("", { workflow: "start", target: "quest/m0/epic/child.md" })).status).toBe(409); + expect((await post("", { workflow: "start", target: "quest/m0/epic/README.md" })).status).toBe(201); const [job] = await store.listJobs(db, 10); - expect(job).toMatchObject({ workflow: "start", target: "quest/epic/README.md", branch: "quest/epic/README" }); + expect(job).toMatchObject({ + workflow: "start", + target: "quest/m0/epic/README.md", + branch: "quest/m0/epic/README", + }); }); it("starts work while history is still paging, but not before a refresh reads a new pull request head", async () => { From ca4d9be59bd38a099122cc02593c811718a41d3b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 09:25:25 -0700 Subject: [PATCH 4/4] fix(check): reject quests whose branch nests another's A quest X.md now collides with any document under X/, matching how git refs nest. This rejects a README directory under an epic, whose child branches would block the epic's .../README branch. Co-Authored-By: Claude Opus 5.5 --- src/core/rules.ts | 21 ++++++++++++++------- test/tree.test.ts | 14 ++++++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/core/rules.ts b/src/core/rules.ts index 8bfff295..3422c2f2 100644 --- a/src/core/rules.ts +++ b/src/core/rules.ts @@ -20,6 +20,7 @@ import { children, owner, permanent, + questBranch, resolve, rooted, withoutFragment, @@ -91,18 +92,24 @@ export function check(docs: Doc[], exists: (path: string) => boolean): Finding[] return found.sort(compareFindings); } -/** A quest file and a directory's README cannot use the same quest path. */ +/** + * A quest's branch cannot be a prefix of another's: git stores refs as files + * and directories, so `quest/m0/foo` cannot exist beside `quest/m0/foo/bar`. + * A quest `X.md` therefore collides with every document under `X/`. That + * includes a directory named `README` under an epic, whose children's branches + * nest under the epic's `.../README` branch. + */ function collisions(found: Finding[], known: Set, docs: Doc[]) { + const paths = [...known].sort(comparePaths); for (const doc of docs) { - // A README already names its directory, including after its last child - // completes. A directory named README therefore has a distinct path. - if (doc.path.endsWith("/README.md")) continue; - const epic = `${doc.path.slice(0, -3)}/README.md`; - if (!known.has(epic)) continue; + const dir = `${questBranch(doc.path)}/`; + const readme = `${dir}README.md`; + const nested = known.has(readme) ? readme : paths.find((path) => path.startsWith(dir)); + if (nested === undefined) continue; found.push({ path: doc.path, line: null, - message: `quest path collides with ${epic}; rename one of these documents`, + message: `quest path collides with ${nested}; rename one of these documents`, }); } } diff --git a/test/tree.test.ts b/test/tree.test.ts index 2e6b4707..614cf8d1 100644 --- a/test/tree.test.ts +++ b/test/tree.test.ts @@ -232,11 +232,21 @@ describe("index", () => { .rejects("quest/m0/epic/one.md: quest path collides with quest/m0/epic/one/README.md"); }); - test("a README-named directory has a distinct path from its parent README", () => { + // Git cannot hold the epic's `quest/m0/epic/README` branch beside a nested + // `quest/m0/epic/README/README` branch, so a README-named directory collides + // with its parent epic like any other nested path. + test("a README-named directory collides with its parent README", () => { tree() .write("quest/m0/epic/README/README.md", ONE) .append("quest/m0/epic/README.md", "- [Nested](/quest/m0/epic/README/README.md)\n") - .accepts(); + .rejects("quest/m0/epic/README.md: quest path collides with quest/m0/epic/README/README.md"); + }); + + test("a quest collides with any document under its path", () => { + tree() + .write("quest/m0/epic/one/README.md", ONE) + .write("quest/m0/epic/one/README/two.md", ONE) + .rejects("quest/m0/epic/one/README.md: quest path collides with quest/m0/epic/one/README/two.md"); }); // A README whose last child merged is the epic's own remaining work: a leaf