From 257b888890ec9f740d22a39a08d8410ea6176a94 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 07:25:40 -0700 Subject: [PATCH 1/2] feat: add quest gates to list plain-text Required conditions Prints every plain-text Required bullet, a condition outside the repository, indented under the quest it blocks, in tree order and across line branches on the remote. The CLI stays offline: judging whether a gate cleared stays with the agent. The guide and quest-audit point at it. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++- assets/AGENTS.md | 2 +- assets/skills/audit.md | 1 + src/main.rs | 30 ++++++++++++++++ src/ready.rs | 79 +++++++++++++++++++++++++++++++----------- tests/tree.rs | 70 +++++++++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 996a2f4..4c3d398 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,11 @@ the quest's branch, then each branch it merges through, nearest first and ending at `main`. A milestone has no branch, so its quests merge straight into `main`. -`ready` reads each questline from its branch on `origin` (fetch first; +`gates` lists every plain-text `Required` bullet, a condition outside the +repository, indented under the quest it blocks, so a periodic check can see +them all at once. The CLI never judges whether one has cleared. + +`ready` and `gates` read each questline from its branch on `origin` (fetch first; `--local` skips this). It does not look for claims or PRs, so check those before starting a quest someone else may already be working on. diff --git a/assets/AGENTS.md b/assets/AGENTS.md index 800f7f3..3dc5c2e 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -61,7 +61,7 @@ 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. +Periodically check if it has cleared; `quest gates` lists every one. `quest check` enforces this structure. Run it after creating or updating quests. diff --git a/assets/skills/audit.md b/assets/skills/audit.md index ac0fafd..47d80b8 100644 --- a/assets/skills/audit.md +++ b/assets/skills/audit.md @@ -12,6 +12,7 @@ Look for: - Conflicts: quests that would change the same code or interface incompatibly, or that must land in order without a `Required` link. - Misaligned priorities: a quest ranked or placed in a milestone ahead of work it depends on, or behind work it blocks. - Stale plans: work already done, blockers that have cleared, or references to code that no longer exists. + Check every condition `quest gates` lists against the outside world. The audit is read-only. Split the reading across parallel sub-agents, each given a slice of the scope and returning its findings with evidence (a path, line, or commit). diff --git a/src/main.rs b/src/main.rs index 27e6178..6415e9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,26 @@ enum Command { local: bool, }, + /// List every plain-text `Required` bullet beside the quest it blocks. + /// + /// A plain-text bullet is a condition outside the repository, which nothing + /// in the tree can clear. Each blocked quest prints on its own line with its + /// conditions indented beneath it, in tree order; whether one has cleared is + /// for whoever reads the list to check. + Gates { + /// Quest or questline to list. Omit to list the whole tree. + path: Option, + + /// Read each questline from its branch on this remote when that branch + /// exists. Fetch first; this reads remote-tracking refs. + #[arg(long, default_value = "origin")] + remote: String, + + /// Read only the working tree, ignoring line branches on the remote. + #[arg(long, conflicts_with = "remote")] + local: bool, + }, + /// Print the branch carrying a quest, then every branch it merges through. /// /// One per line, nearest first, ending at `main`: a quest merges into its @@ -123,6 +143,16 @@ fn main() -> Result { } Ok(ExitCode::SUCCESS) } + Command::Gates { path, remote, local } => { + let remote = (!local).then_some(remote.as_str()); + for (quest, gates) in quest::ready::gates(&cli.root, path.as_deref(), remote)? { + println!("{}", quest.display()); + for gate in gates { + println!(" {gate}"); + } + } + Ok(ExitCode::SUCCESS) + } Command::Branch { path } => { for branch in quest::branch::chain(&cli.root, &path)? { println!("{branch}"); diff --git a/src/ready.rs b/src/ready.rs index 723ee63..6a7b98e 100644 --- a/src/ready.rs +++ b/src/ready.rs @@ -78,25 +78,58 @@ pub fn blockers(root: &Path, path: &Path, remote: Option<&str>) -> Result) -> Result> { let docs = crate::load_from(root, remote)?; - let mut remaining: BTreeMap = docs.iter().map(|doc| (doc.path.clone(), doc)).collect(); - let mut pending = vec![PathBuf::from("quest/README.md")]; - let mut ready = Vec::new(); + let by_path: BTreeMap<&Path, &Doc> = docs.iter().map(|d| (d.path.as_path(), d)).collect(); + Ok(order(&by_path, None) + .into_iter() + .filter(|doc| !doc.is_questline() && !doc.has("Required")) + .map(|doc| doc.path.clone()) + .collect()) +} + +/// Every plain-text `Required` bullet, grouped under the document it blocks, in +/// tree order: the conditions outside the repository that only someone looking +/// can clear. Whether one has cleared is not a question the tree can answer, so +/// this only lists them. +/// +/// `path` limits the listing to that quest, or to a questline and everything +/// under it. `remote` is as for [`blockers`]. +pub fn gates(root: &Path, path: Option<&Path>, remote: Option<&str>) -> Result)>> { + let docs = crate::load_from(root, remote)?; + let by_path: BTreeMap<&Path, &Doc> = docs.iter().map(|d| (d.path.as_path(), d)).collect(); + let start = path.map(|path| locate(root, path, &by_path)).transpose()?; + Ok(order(&by_path, start.as_deref()) + .into_iter() + .filter_map(|doc| { + let gates: Vec = doc + .entries("Required") + .filter(|entry| target(&by_path, entry).is_none()) + .map(|entry| entry.text.clone()) + .collect(); + (!gates.is_empty()).then(|| (doc.path.clone(), gates)) + }) + .collect()) +} + +/// Documents in tree order: depth first through each questline's children from +/// `start`, or from the root followed by anything no questline lists. +fn order<'a>(by_path: &BTreeMap<&Path, &'a Doc>, start: Option<&Path>) -> Vec<&'a Doc> { + let mut remaining = by_path.clone(); + let mut pending = vec![start.unwrap_or(Path::new("quest/README.md")).to_path_buf()]; + let mut out = Vec::new(); while let Some(path) = pending.pop() { - let Some(doc) = remaining.remove(&path) else { continue }; + let Some(doc) = remaining.remove(path.as_path()) else { + continue; + }; if doc.is_questline() { let children: Vec<_> = doc.children().collect(); pending.extend(children.into_iter().rev()); - } else if !doc.has("Required") { - ready.push(path); } + out.push(doc); + } + if start.is_none() { + out.extend(remaining.into_values()); } - ready.extend( - remaining - .into_iter() - .filter(|(_, doc)| !doc.is_questline() && !doc.has("Required")) - .map(|(path, _)| path), - ); - Ok(ready) + out } /// The blockers of one document: its `Required` entries, which for a questline @@ -119,14 +152,7 @@ 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. - let path = entry - .target - .as_deref() - .and_then(rules::rooted) - .filter(|path| by_path.contains_key(path.as_path())); + let path = target(by_path, entry); // Only a questline expands. A required QUEST is the blocker itself, and its // own chain is the answer to running this on that quest instead; printing @@ -150,6 +176,17 @@ fn blocker(by_path: &BTreeMap<&Path, &Doc>, entry: &crate::doc::Entry, stack: &m } } +/// The document a `Required` entry names. 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. +fn target(by_path: &BTreeMap<&Path, &Doc>, entry: &crate::doc::Entry) -> Option { + entry + .target + .as_deref() + .and_then(rules::rooted) + .filter(|path| by_path.contains_key(path.as_path())) +} + /// Resolve a quest path the way a caller is likely to have it to the /// repository-relative one the tree is keyed on. pub(crate) fn locate(root: &Path, path: &Path, by_path: &BTreeMap<&Path, &Doc>) -> Result { diff --git a/tests/tree.rs b/tests/tree.rs index c294ce1..85abff1 100644 --- a/tests/tree.rs +++ b/tests/tree.rs @@ -149,6 +149,19 @@ impl Tree { .collect() } + /// Each gated document with its conditions, as `path: condition`. + fn gates(&self, path: Option<&str>, remote: Option<&str>) -> Vec { + quest::ready::gates(self.path(), path.map(Path::new), remote) + .expect("gates") + .into_iter() + .flat_map(|(quest, gates)| { + gates + .into_iter() + .map(move |gate| format!("{}: {gate}", quest.display())) + }) + .collect() + } + fn ready(&self) -> Vec { self.ready_on(None) } @@ -935,6 +948,63 @@ fn branch_chain_of_a_nested_line() { ); } +// Gates: plain-text `Required` bullets, the conditions outside the repository. + +/// Every document can carry one, a milestone included, and they list in tree +/// order. A link into the tree is a blocker the tree tracks, not a gate; a +/// link out of it is a gate like any other plain text. +#[test] +fn gates_list_plain_text_bullets_in_tree_order() { + let tree = Tree::new(); + tree.append("quest/m0/README.md", "- Budget approved\n") + .append( + "quest/m0/line/two.md", + "- [#12](https://example.invalid/12) - merged upstream\n", + ) + .append("quest/m0/line/two.md", "- A release ships\n") + .write( + "quest/m0/line/one.md", + &format!("{ONE}\n## Required\n\n- Vendor replies\n"), + ); + tree.accepts(); + assert_eq!( + tree.gates(None, None), + [ + "quest/m0/README.md: Budget approved", + "quest/m0/line/one.md: Vendor replies", + "quest/m0/line/two.md: #12 - merged upstream", + "quest/m0/line/two.md: A release ships", + ] + ); + assert_eq!( + tree.gates(Some("quest/m0/line/README.md"), None), + [ + "quest/m0/line/one.md: Vendor replies", + "quest/m0/line/two.md: #12 - merged upstream", + "quest/m0/line/two.md: A release ships", + ] + ); + assert_eq!( + tree.gates(Some("/quest/m0/line/one.md"), None), + ["quest/m0/line/one.md: Vendor replies"] + ); +} + +/// A gate added on a line's branch has not reached `main` yet, and the audit +/// that re-checks gates has to see it anyway. +#[test] +fn gates_read_line_branches() { + let tree = Tree::new(); + tree.init_git().push_line("quest/m0/line/README", |t| { + t.append("quest/m0/line/two.md", "- A release ships\n"); + }); + assert!(tree.gates(None, None).is_empty()); + assert_eq!( + tree.gates(None, Some("origin")), + ["quest/m0/line/two.md: A release ships"] + ); +} + // Line branches: `--remote` reads each line from its branch, where its // children merge before the line reaches `main`. From 67caf08ac1b99ce9c95c3101f7cdb85258456e5b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 11:06:29 -0700 Subject: [PATCH 2/2] feat: require quests in Required and add deprioritize to quest-spawn An outside condition written as a plain-text Required bullet hides the quest it blocks from every ready listing, so it is forgotten. It is now its own quest, which stays ready and resurfaces every triage. Co-Authored-By: Claude Opus 5.5 --- assets/AGENTS.md | 9 ++++---- assets/skills/spawn.md | 3 ++- src/ready.rs | 12 +++++----- src/rules.rs | 49 +++++++++++++++++++++-------------------- tests/tree.rs | 50 +++++++++++++++++++----------------------- 5 files changed, 60 insertions(+), 63 deletions(-) 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"]); }