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
9 changes: 5 additions & 4 deletions assets/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,11 @@ The `Goal` section is required; everything else is optional. Use these exact hea
Size the title `[XS]` to `[XL]` for implementation, verification, and landing.
A questline carries no size until its last child is removed; its README is then the line's remaining work.

`Required` lists what must finish before the work starts.
`Required` lists the quests that must finish before the work starts.
The list is ordered by priority, inserted at rank.
When the section is empty, delete it; the quest is now unblocked.
`Required` must be acyclic.

A plain-text bullet names a condition outside the repository.
Periodically check if it has cleared.

`quest check` enforces this structure.
Run it after creating or updating quests.

Expand All @@ -76,6 +73,10 @@ Group them in a questline only when they should ship together.

A release or pin bump that unblocks work is its own quest.

So is anything waiting on an outside party or a human action (an upstream fix, a customer, a decision).
Its Goal names the condition and how to check or advance it; it is deleted once the condition clears.
Unlike a blocked quest it stays ready, so it resurfaces every time ready work is triaged instead of stalling in the backlog.

## Execution

Start only ready quests.
Expand Down
3 changes: 2 additions & 1 deletion assets/skills/spawn.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ Before you begin, run `quest guide` and read its output completely.
Your goal is to execute many quests in parallel.
The scope consists of all ready quests that are not claimed.
Use the argument (if provided) to filter to specific quests/questlines.
Inspect any blocked quests and determine if they can be unblocked.
For a quest waiting on an outside condition, check whether it has cleared before recommending.

Interactively prompt the user with one question per quest, never grouping quests into one question (a prompt may hold a few questions), with your recommendation:
- `/quest-start`: If the quest is well planned with no blockers.
- `/quest-plan`: If the quest has significant design issues.
- skip: If the quest should not be started yet.
- deprioritize: If the quest should wait behind other work; `/quest-plan` moves it down.
- `/quest-delete`: If the quest should be deleted.

Include a brief summary of each quest.
Expand Down
12 changes: 5 additions & 7 deletions src/ready.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ use crate::rules;
/// One thing standing between a quest and being started.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Blocker {
/// The quest or questline that has to finish first, when the blocker is one
/// of ours. `None` is a plain-text condition, which nothing in the tree can
/// ever clear.
/// The quest or questline that has to finish first. `None` is an entry that
/// is not a quest, which `quest check` rejects and nothing here can clear.
pub path: Option<PathBuf>,
/// The `Required` bullet as written, whitespace collapsed.
pub text: String,
Expand All @@ -32,7 +31,7 @@ pub struct Blocker {
}

impl Blocker {
/// What names the blocker: the document, or the condition's own words.
/// What names the blocker: the document, or the entry's own words.
pub fn label(&self) -> String {
match &self.path {
Some(path) => path.display().to_string(),
Expand Down Expand Up @@ -119,9 +118,8 @@ fn expand(by_path: &BTreeMap<&Path, &Doc>, doc: &Doc, stack: &mut Vec<PathBuf>)
}

fn blocker(by_path: &BTreeMap<&Path, &Doc>, entry: &crate::doc::Entry, stack: &mut Vec<PathBuf>) -> Blocker {
// A bullet that does not open with a link into the tree is a plain-text
// condition: an issue, a release, a customer. Nothing here can clear it, so
// it is a blocker with nothing under it.
// A bullet that does not open with a link into the tree is invalid, and
// nothing here can clear it, so it is a blocker with nothing under it.
let path = entry
.target
.as_deref()
Expand Down
49 changes: 25 additions & 24 deletions src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ pub fn check(root: &Path, docs: &[Doc]) -> Vec<Finding> {
links(&mut found, root, &known, doc);
}

index(&mut found, docs);
index(&mut found, &known, docs);
cycles(&mut found, docs);

found.0.sort();
Expand Down Expand Up @@ -210,24 +210,10 @@ fn links(found: &mut Findings, root: &Path, known: &BTreeSet<&Path>, doc: &Doc)
);
}

// An AGENTS.md under quest/ is a file, but not a quest anything can finish.
if link.section.as_deref() == Some("Required")
&& link.position == Position::Entry
&& path.starts_with("quest")
&& !known.contains(path.as_path())
{
found.at(
&doc.path,
link.line,
format!("requires {}, which is not a quest document", link.target),
);
}

// A `Required` bullet is either a dependency edge (the link opens it) or
// a plain-text external condition (no quest link at all).
// moq-dev/moq.pro#1170 shipped the third shape: a customer-gate sentence
// mentioning a questline mid-line, which reads as context but IS a
// blocker, and so silently required all of m2.
// A `Required` entry opens with its quest link. moq-dev/moq.pro#1170
// shipped a customer-gate sentence mentioning a questline mid-line,
// which reads as context but IS a blocker, and so silently required all
// of m2.
if link.section.as_deref() == Some("Required")
&& known.contains(path.as_path())
&& link.position != Position::Entry
Expand All @@ -245,18 +231,33 @@ fn links(found: &mut Findings, root: &Path, known: &BTreeSet<&Path>, doc: &Doc)
}

/// Every document is listed by the questline it sits under, as a child in that
/// README's `Required`, and nothing is required twice by the same document.
fn index(found: &mut Findings, docs: &[Doc]) {
/// README's `Required`, and every `Required` entry is a quest, required once.
///
/// A condition outside the repository (a release, a customer, a person) is a
/// quest of its own rather than a plain-text bullet: a blocked quest drops out
/// of every ready listing, so the condition would be forgotten, while its own
/// quest keeps surfacing as ready until someone clears it.
fn index(found: &mut Findings, known: &BTreeSet<&Path>, docs: &[Doc]) {
let mut listed: BTreeSet<PathBuf> = BTreeSet::new();

for doc in docs {
listed.extend(doc.children());

let mut seen = BTreeSet::new();
for entry in doc.entries("Required") {
if let Some(target) = entry.target.as_deref().and_then(rooted)
&& !seen.insert(target)
{
let target = entry.target.as_deref().and_then(rooted);
let Some(target) = target.filter(|t| known.contains(t.as_path())) else {
found.at(
&doc.path,
entry.line,
format!(
"requires {}, which is not a quest document; make an outside condition its own quest",
entry.target.as_deref().unwrap_or(&entry.text)
),
);
continue;
};
if !seen.insert(target) {
found.at(
&doc.path,
entry.line,
Expand Down
50 changes: 23 additions & 27 deletions tests/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ fn cycle_through_a_reference_style_link() {
tree.rejects("Required cycle:");
}

/// moq-dev/moq.pro#1170: a plain-text external condition that happens to link a
/// moq-dev/moq.pro#1170: a customer-gate sentence that happens to link a
/// questline mid-sentence reads as context but IS a dependency edge.
#[test]
fn required_link_mid_sentence() {
Expand All @@ -535,16 +535,28 @@ fn required_link_on_a_wrapped_bullet() {
tree.rejects("mid-sentence");
}

/// The other half of that rule: an external condition with no link at all is the
/// shape AGENTS.md prescribes, and must stay legal.
/// An outside condition is its own quest, so it keeps surfacing as ready
/// instead of hiding the quest it blocks from every ready listing. The bullet
/// wraps here because that is what such bullets in trees actually looked like.
#[test]
fn required_external_condition() {
let tree = Tree::new();
tree.append(
"quest/m0/line/one.md",
"\n## Required\n\n- A customer who justifies the work.\n",
"\n## Required\n\n- A `moq-video` release that carries\n the encoder\n",
);
tree.accepts();
tree.rejects("requires A moq-video release that carries the encoder, which is not a quest document");
}

/// An issue or release link is outside the tree too; only a quest can clear.
#[test]
fn required_external_link() {
let tree = Tree::new();
tree.append(
"quest/m0/line/one.md",
"\n## Required\n\n- [#1](https://github.com/OWNER/REPO/issues/1) - upstream fix\n",
);
tree.rejects("requires https://github.com/OWNER/REPO/issues/1, which is not a quest document");
}

/// A LOOSE list - blank lines between entries - wraps every item in a paragraph.
Expand All @@ -566,9 +578,9 @@ fn loose_required_list() {
let tree = Tree::new();
tree.append(
"quest/m0/line/one.md",
"\n## Required\n\n- [Two](/quest/m0/line/two.md) - must finish first\n\n- A customer who justifies the work.\n",
"\n## Required\n\n- [Two](/quest/m0/line/two.md) - must finish first\n\n- [Line](/quest/m0/line/README.md) - the whole line\n",
);
// The edge registered (hence the cycle) without reading as prose.
// Both edges registered (hence the cycle) without reading as prose.
tree.rejects("Required cycle:");
tree.without("mid-sentence");
}
Expand Down Expand Up @@ -672,14 +684,14 @@ fn escaped_link_resolving_beside_the_root() {
tree.rejects(&format!("link does not resolve: ../../../../{name}/AGENTS.md"));
}

/// A plain-text bullet is a blocker, not a child, so a README holding only one
/// is a quest and needs a size like any other.
/// A blocker outside the line's directory is not a child, so a README holding
/// only one is a quest and needs a size like any other.
#[test]
fn questline_listing_no_quest() {
let tree = Tree::new();
tree.write(
"quest/m0/husk/README.md",
"# Husk\n\n## Goal\n\nIts last quest was completed.\n\n## Required\n\n- TBD\n",
"# Husk\n\n## Goal\n\nIts last quest was completed.\n\n## Required\n\n- [One](/quest/m0/line/one.md)\n",
);
tree.append("quest/m0/README.md", "- [Husk](/quest/m0/husk/README.md)\n");
tree.rejects("quest title must be");
Expand Down Expand Up @@ -741,22 +753,6 @@ fn blocked_by_a_quest() {
assert_eq!(tree.blockers("quest/m0/line/two.md"), ["quest/m0/line/one.md"]);
}

/// A plain-text bullet names a condition outside the repository, so nothing in
/// the tree can ever clear it: it is a blocker, printed as written. Wrapped
/// here because that is what the bullets in the tree actually look like.
#[test]
fn blocked_by_plain_text() {
let tree = Tree::new();
tree.append(
"quest/m0/line/one.md",
"\n## Required\n\n- A `moq-video` release that carries\n the encoder\n",
);
assert_eq!(
tree.blockers("quest/m0/line/one.md"),
["A moq-video release that carries the encoder"]
);
}

/// A questline blocker clears only when the whole line is complete, so the
/// useful answer is which of its quests are still open - all of them, since a
/// completed quest is deleted.
Expand Down Expand Up @@ -854,7 +850,7 @@ fn ready_listing_appends_unindexed_quests() {
tree.write("quest/m0/aaa.md", "# [S] Unindexed\n\n## Goal\n\nDiscover me.\n");
tree.write(
"quest/m0/blocked.md",
"# [S] Blocked\n\n## Goal\n\nWait.\n\n## Required\n\n- External condition\n",
"# [S] Blocked\n\n## Goal\n\nWait.\n\n## Required\n\n- [One](/quest/m0/line/one.md)\n",
);
assert_eq!(tree.ready(), ["quest/m0/line/one.md", "quest/m0/aaa.md"]);
}
Expand Down
Loading