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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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

- [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
Expand Down
14 changes: 6 additions & 8 deletions quest/m0/helpers/move.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion quest/m0/landed-quests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 0 additions & 59 deletions quest/m0/milestone-links.md

This file was deleted.

31 changes: 30 additions & 1 deletion src/core/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -219,6 +220,34 @@ function index(found: Finding[], known: Set<string>, 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
Expand Down
54 changes: 54 additions & 0 deletions test/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading