From c9d8f42e62b07bc4d3a7396895c9bf2fffff0cfb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 10:21:31 -0700 Subject: [PATCH 1/2] quest: claim quest/m0/milestone-links.md Co-Authored-By: Claude Opus 5.5 From 630e2abe3fd6696267be16772dd11cfeba424fdc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 10:25:34 -0700 Subject: [PATCH 2/2] feat(check): flag Required links to finished milestones A milestone is permanent, so a Required link to one keeps resolving after the milestone finishes and blocks its quest forever. quest check now flags a Required entry whose target is a milestone README with no '## Required' left; the root's own listing of its milestones is membership and exempt. Readiness stays a grep for the heading. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++-- quest/m0/README.md | 1 - quest/m0/helpers/move.md | 14 ++++----- quest/m0/landed-quests.md | 1 - quest/m0/milestone-links.md | 59 ------------------------------------- src/core/rules.ts | 31 ++++++++++++++++++- test/tree.test.ts | 54 +++++++++++++++++++++++++++++++++ 7 files changed, 94 insertions(+), 72 deletions(-) delete mode 100644 quest/m0/milestone-links.md diff --git a/README.md b/README.md index 70ce515a..9a657f48 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,10 @@ 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. The finished plan stays -in Git history, beside the code that completed it. +on it, so the quests that required it can become ready. 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. Work is taken through a live runner lock or an open change on the forge. kix.quest records each active job in its database and reassigns a job whose diff --git a/quest/m0/README.md b/quest/m0/README.md index 83d87b6e..fe6546c4 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -35,7 +35,6 @@ ownership-manifest design: ## Required -- [Finished milestone links](/quest/m0/milestone-links.md) - `quest check` flags a Required link to a milestone with no Required left, so its dependents get unblocked - [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 diff --git a/quest/m0/helpers/move.md b/quest/m0/helpers/move.md index 429fe893..0cb04eca 100644 --- a/quest/m0/helpers/move.md +++ b/quest/m0/helpers/move.md @@ -21,14 +21,12 @@ commands avoid making them. epic) and deletes a section left empty, as the guide requires for an empty Required. Removing an epic with children requires `-r`, as with `git rm`. - Decided 2026-10-19 (user): `rm` also drops every `Required` link to a - finished milestone, meaning a milestone README with no `## Required` left - ([finished milestone links](/quest/m0/milestone-links.md)). That covers a - milestone this `rm` just emptied and any links that were already stale. As - in that check, the root's own entry for the milestone stays: it is - membership, not a blocker, and dropping it would leave the milestone - unlisted. It repeats until nothing changes, so `rm` never leaves a tree - that fails either check. Whichever of the two quests lands second wires it - up. + finished milestone, meaning a milestone README with no `## Required` left, + which `quest check` already flags. That covers a milestone this `rm` just + emptied and any links that were already stale. As in that check, the + root's own entry for the milestone stays: it is membership, not a blocker, + and dropping it would leave the milestone unlisted. It repeats until + nothing changes, so `rm` never leaves a tree that fails either check. - Both report prose mentions of the path and mentions outside `quest/` without editing them, and run `quest check` on the result. - Neither touches branches or PRs, since renaming an open PR's head branch diff --git a/quest/m0/landed-quests.md b/quest/m0/landed-quests.md index d891be26..66e7dcf9 100644 --- a/quest/m0/landed-quests.md +++ b/quest/m0/landed-quests.md @@ -75,4 +75,3 @@ 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 -- [Finished milestone links](/quest/m0/milestone-links.md) - the same "a finishing PR removes what depended on it" rule for milestones diff --git a/quest/m0/milestone-links.md b/quest/m0/milestone-links.md deleted file mode 100644 index bc8fbdbb..00000000 --- a/quest/m0/milestone-links.md +++ /dev/null @@ -1,59 +0,0 @@ -# [S] Check flags links to finished milestones - -## Goal - -A quest that requires a milestone (`/quest/mN/README.md`) stops being -blocked by it once that milestone is finished, and nobody has to remember to -remove the link by hand. Milestones are permanent, so today the link resolves -forever and the quest never becomes ready -([kixelated/quest#152](https://github.com/kixelated/quest/pull/152#discussion_r4317606198)). -/quest-recurring relies on this: to run at the end of mN, the gate requires -mN and the next run requires the gate. - -Non-goals: ordinary epics and quests still clear by being deleted, and -milestone shifting and numbering are unchanged. - -## Plan - -Decided 2026-10-19 (user): - -- A milestone is finished when its README has no `## Required` heading. - Non-child blockers count, so moq-dev/moq's m1, which lists m0 quests, is not - finished while those remain. A required epic's blockers already expand that - way, and the test is still a grep of one file. -- `quest check` forces the removal; readiness stays syntactic. `ready()` and - `blockers()` are unchanged, so "ready = no `## Required` heading" still holds - for the CLI, the Kix board, the GitHub service, and anything that greps. - Rejected: having `ready()` count the link as cleared, which breaks the grep - and leaves the stale link standing, and doing both, which gives two sources - of truth that disagree until someone tidies up. -- Add a rule in `src/core/rules.ts`: a `Required` entry whose target is a - permanent README other than the root, and whose target has no `## Required`, - is a finding at the entry's line. The message names the finished milestone - and says to remove the link, and the heading with its last entry. Exempt the - structural entry: the root's own listing of its milestones is membership, - not a blocker. The finding cascades on its own: removing the last link - leaves a quest with no heading, which is ready. -- The PR that empties a milestone picks up these edits, the same way a - finished quest's PR already removes the links to it. Two PRs racing (one - adds a link while the other empties the milestone) can leave main failing - check until one of them is fixed; accepted, like other merge conflicts. -- No line in `assets/AGENTS.md`; the check message carries the rule. Update - the README's "When a quest is complete, its PR removes ... the links that - depended on it" to say that a PR finishing a milestone removes the links to - that milestone too. -- moq-dev/moq needs nothing. It pins quest at ea9c25d in `flake.nix` and picks - this up with its next routine bump. Every moq milestone still has a - `Required`, so the bump trips nothing. Four moq quests require - `/quest/m1/README.md` (the release cut, the API-diff check, the API shape - review, and the feature review); the PR that finishes m1 removes those links. -- `quest rm` ([move and delete quests](/quest/m0/helpers/move.md)) drops these - links as well; whichever of the two quests lands second wires that up. - -Tests: a quest requiring a finished milestone is flagged; one requiring a -milestone with only non-child blockers left is not; the root's milestone -listing is not; and a fixed tree passes. - -## Related - -- [Move and delete quests](/quest/m0/helpers/move.md) - `quest rm` drops links to milestones it leaves finished diff --git a/src/core/rules.ts b/src/core/rules.ts index f1c1002d..6469567b 100644 --- a/src/core/rules.ts +++ b/src/core/rules.ts @@ -11,7 +11,7 @@ // entry for a completed quest survived a rebase that produced no conflict at // all. -import { type Doc, entries, has, isEpic, children, owner, resolve, rooted, withoutFragment } from "./doc"; +import { type Doc, entries, has, isEpic, children, owner, permanent, resolve, rooted, withoutFragment } from "./doc"; import { comparePaths, join } from "./path"; /** @@ -73,6 +73,7 @@ export function check(docs: Doc[], exists: (path: string) => boolean): Finding[] collisions(found, known, docs); index(found, known, docs); + milestones(found, docs); cycles(found, docs); return found.sort(compareFindings); @@ -219,6 +220,34 @@ function index(found: Finding[], known: Set, docs: Doc[]) { } } +/** + * A link to a finished milestone is removed. Milestones are permanent, so + * unlike a finished quest, which is deleted, the link would keep resolving and + * block its quest forever. A milestone is finished once its README has no + * `## Required`, which counts blockers outside its own directory too. + * + * Readiness stays a grep for the heading; this rule is what keeps that answer + * true once the milestone empties. The root's own listing of its milestones is + * membership, not a blocker, so it is exempt. + */ +function milestones(found: Finding[], docs: Doc[]) { + const byPath = new Map(docs.map((d) => [d.path, d])); + for (const doc of docs) { + if (doc.path === ROOT) continue; + for (const entry of entries(doc, "Required")) { + const target = entry.target === null ? null : rooted(entry.target); + if (target === null || target === ROOT || !permanent(target)) continue; + const milestone = byPath.get(target); + if (milestone === undefined || has(milestone, "Required")) continue; + found.push({ + path: doc.path, + line: entry.line, + message: `requires ${entry.target}, a finished milestone; remove the link, and the '## Required' heading with its last entry`, + }); + } + } +} + /** * `Required` must be acyclic. A cycle is a set of quests none of which can ever * start, and walking the links to rule one out is exactly the manual step an diff --git a/test/tree.test.ts b/test/tree.test.ts index 6c19111d..a0d7195d 100644 --- a/test/tree.test.ts +++ b/test/tree.test.ts @@ -346,6 +346,60 @@ describe("index", () => { }); }); +// Milestones are permanent, so a link to one keeps resolving after it +// finishes. Check forces its removal; readiness stays a grep for the heading. +describe("finished milestones", () => { + const EMPTY_M0 = "# M0\n\n## Goal\n\nA milestone.\n"; + const GATE = "# [S] Gate\n\n## Goal\n\nRuns once m0 finishes.\n\n## Required\n\n- [M0](/quest/m0/README.md)\n"; + + /** The baseline plus m1, whose gate requires m0. */ + const gated = () => + tree() + .append("quest/README.md", "- [M1](/quest/m1/README.md)\n") + .write( + "quest/m1/README.md", + "# M1\n\n## Goal\n\nThe next milestone.\n\n## Required\n\n- [Gate](/quest/m1/gate.md)\n", + ) + .write("quest/m1/gate.md", GATE); + + test("a link to an open milestone blocks", () => { + const t = gated(); + t.accepts(); + expect(t.blockers("quest/m1/gate.md")[0]).toBe("quest/m0/README.md"); + }); + + test("a link to a finished milestone", () => { + const t = gated().write("quest/m0/README.md", EMPTY_M0).remove("quest/m0/epic"); + expect(t.findings()).toEqual([ + "quest/m1/gate.md:9: requires /quest/m0/README.md, a finished milestone; remove the link, and the '## Required' heading with its last entry", + ]); + expect(t.ready()).toEqual([]); + }); + + // moq-dev/moq's m1 lists m0 quests: those blockers keep it open. + test("a milestone with only non-child blockers left is open", () => { + gated() + .write("quest/m0/README.md", `${EMPTY_M0}\n## Required\n\n- [Other](/quest/m1/other.md)\n`) + .remove("quest/m0/epic") + .write("quest/m1/other.md", "# [S] Other\n\n## Goal\n\nOpen work m0 waits on.\n") + .append("quest/m1/README.md", "- [Other](/quest/m1/other.md)\n") + .accepts(); + }); + + test("the root's listing of a finished milestone is membership", () => { + tree().write("quest/m0/README.md", EMPTY_M0).remove("quest/m0/epic").accepts(); + }); + + test("removing the link readies the quest", () => { + const t = gated() + .write("quest/m0/README.md", EMPTY_M0) + .remove("quest/m0/epic") + .write("quest/m1/gate.md", "# [S] Gate\n\n## Goal\n\nRuns once m0 finishes.\n"); + t.accepts(); + expect(t.ready()).toEqual(["quest/m1/gate.md"]); + }); +}); + describe("Required", () => { test("a direct cycle", () => { tree()