diff --git a/assets/AGENTS.md b/assets/AGENTS.md index 800f7f3..c8fe666 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -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. @@ -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. diff --git a/assets/skills/spawn.md b/assets/skills/spawn.md index f336e21..0f58fa9 100644 --- a/assets/skills/spawn.md +++ b/assets/skills/spawn.md @@ -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. diff --git a/src/ready.rs b/src/ready.rs index 723ee63..0374710 100644 --- a/src/ready.rs +++ b/src/ready.rs @@ -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, /// The `Required` bullet as written, whitespace collapsed. pub text: String, @@ -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(), @@ -119,9 +118,8 @@ fn expand(by_path: &BTreeMap<&Path, &Doc>, doc: &Doc, stack: &mut Vec) } fn blocker(by_path: &BTreeMap<&Path, &Doc>, entry: &crate::doc::Entry, stack: &mut Vec) -> 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() diff --git a/src/rules.rs b/src/rules.rs index 2c5a07e..a4d705e 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -73,7 +73,7 @@ pub fn check(root: &Path, docs: &[Doc]) -> Vec { links(&mut found, root, &known, doc); } - index(&mut found, docs); + index(&mut found, &known, docs); cycles(&mut found, docs); found.0.sort(); @@ -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 @@ -245,8 +231,13 @@ 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 = BTreeSet::new(); for doc in docs { @@ -254,9 +245,19 @@ fn index(found: &mut Findings, docs: &[Doc]) { 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, diff --git a/tests/tree.rs b/tests/tree.rs index c294ce1..834ecae 100644 --- a/tests/tree.rs +++ b/tests/tree.rs @@ -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() { @@ -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. @@ -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"); } @@ -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"); @@ -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. @@ -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"]); }