Skip to main content

newt_core/
roadmap_eval.rs

1//! #1030 node evaluators: decide whether a Roadmap→Phase→Plan→Task node is DONE
2//! from OBJECTIVE state (git / forge / CI), never the model's self-report.
3//!
4//! Fact-gathering is behind trait seams bundled in [`Facts`] so the unit tier is
5//! fully mocked (no real git, subprocess, `gh`, or CI). The completion rules are
6//! a pure reducer ([`evaluate_node`]).
7//!
8//! Completion rules (#1030):
9//! - **Task** done = its commit is on the branch AND its `verify` gate passes.
10//! - **Plan** done = every child Task is Done AND the plan's branch `verify` passes.
11//! - **Phase** done = every child Plan is Done AND its PR is merged to main.
12//! - **Roadmap** done = every child Phase is Done AND the pipelines are green.
13//!
14//! When a required remote fact is unavailable (no `gh`, no CI), the verdict is
15//! [`NodeVerdict::Unsupported`] — a node is never *falsely* Done (CLAUDE.md).
16
17use crate::plan::{NodeKind, Plan, Subtask, SubtaskStatus};
18
19/// Whether a node satisfies its #1030 completion rule.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum NodeVerdict {
22    /// The completion rule is satisfied — the caller may mark the node Done.
23    Done,
24    /// Not yet complete, with a human-readable reason (missing commit, failing
25    /// verify, children not done, PR not merged, CI red).
26    NotYet(String),
27    /// Cannot be evaluated — a required objective fact is unavailable (no `gh`,
28    /// no CI, no artifact reference). Never falsely Done.
29    Unsupported(String),
30}
31
32/// The objective facts an evaluator reduces, gathered via the [`Facts`] seams.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct NodeFacts {
35    /// The node's `artifact_ref.commit` is present on its branch (Task/Plan).
36    pub commit_present: bool,
37    /// The node's `verify` command result — `None` when it has no verify command.
38    pub verify: Option<bool>,
39    /// The node has children and every one is Done (Plan/Phase/Roadmap).
40    pub children_all_done: bool,
41    /// The node's `artifact_ref.pr` merge state (Phase): `Some(true)` merged,
42    /// `Some(false)` open/unmerged, `None` = no PR reference or no forge access.
43    pub pr_merged: Option<bool>,
44    /// The pipelines' green state (Roadmap): `Some(true)` green, `Some(false)`
45    /// red, `None` = no CI access.
46    pub ci_green: Option<bool>,
47    /// The referenced forge issue's state (#1083, any node kind): `None` = no
48    /// `artifact_ref.issue` reference — the issue gate does not apply.
49    pub issue: Option<IssueState>,
50}
51
52/// The state of a node's referenced forge issue (#1083). An additional gate on
53/// the node's completion rule: an [`Open`](IssueState::Open) issue blocks Done,
54/// an [`Unknown`](IssueState::Unknown) one is Unsupported — a verdict input,
55/// never a direct Done.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum IssueState {
58    /// The forge reports the issue CLOSED.
59    Closed,
60    /// The forge reports the issue still open.
61    Open,
62    /// The issue is referenced but the forge cannot answer (no `gh`, no remote).
63    Unknown,
64}
65
66/// Read objective git state. Mocked in the unit tier; the real impl (newt-tui)
67/// reads the repo via `newt-git`.
68pub trait GitFacts {
69    /// Is `commit` present on `branch` (or the current branch when `None`)?
70    fn commit_present(&self, commit: &str, branch: Option<&str>) -> bool;
71}
72
73/// Run a node's verify command, returning pass/fail. Mocked in the unit tier;
74/// the real impl (newt-tui) runs a subprocess.
75pub trait VerifyRunner {
76    fn run(&self, cmd: &str) -> bool;
77}
78
79/// Read forge (pull-request) state. Mocked in the unit tier; the real impl
80/// (newt-tui) shells out to `gh`. `None` when the forge cannot be reached.
81pub trait ForgeFacts {
82    /// Is pull-request `pr` merged? `None` when it cannot be determined.
83    fn pr_merged(&self, pr: u64) -> Option<bool>;
84    /// Is forge issue `issue` closed (#1083)? `None` when it cannot be
85    /// determined. Default `None` keeps the trait additive: an impl that
86    /// doesn't override degrades to Unsupported, never a false answer.
87    fn issue_closed(&self, issue: u64) -> Option<bool> {
88        let _ = issue;
89        None
90    }
91}
92
93/// Read CI/pipeline state. Mocked in the unit tier; the real impl (newt-tui)
94/// shells out to `gh`. `None` when CI cannot be reached.
95pub trait CiFacts {
96    /// Are the pipelines green? `None` when it cannot be determined.
97    fn pipelines_green(&self) -> Option<bool>;
98}
99
100/// The injected fact-gathering seams for evaluation, bundled so the evaluator
101/// signature stays small as objective sources (git → forge → CI) are added.
102pub struct Facts<'a> {
103    pub git: &'a dyn GitFacts,
104    pub verify: &'a dyn VerifyRunner,
105    pub forge: &'a dyn ForgeFacts,
106    pub ci: &'a dyn CiFacts,
107}
108
109/// Gather the [`NodeFacts`] for `node` in `tree` via the injected seams.
110#[must_use]
111pub fn gather_facts(node: &Subtask, tree: &Plan, facts: &Facts) -> NodeFacts {
112    let artifact = node.artifact_ref.as_ref();
113    let commit_present = artifact
114        .and_then(|a| a.commit.as_deref())
115        .map(|c| {
116            facts
117                .git
118                .commit_present(c, artifact.and_then(|a| a.branch.as_deref()))
119        })
120        .unwrap_or(false);
121    let verify = node.verify.as_deref().map(|cmd| facts.verify.run(cmd));
122    // A node with NO children is NOT "done by vacuity": an empty Plan/Phase has
123    // accomplished nothing. The Task rule ignores this field.
124    let kids = tree.children(&node.id);
125    let children_all_done =
126        !kids.is_empty() && kids.iter().all(|c| c.status == SubtaskStatus::Done);
127    // Remote facts are only gathered where they gate: a PR ref for a Phase, CI
128    // for a Roadmap. `None` = the source could not answer (no ref / no access).
129    let pr_merged = artifact
130        .and_then(|a| a.pr)
131        .and_then(|pr| facts.forge.pr_merged(pr));
132    let ci_green = if node.kind == NodeKind::Roadmap {
133        facts.ci.pipelines_green()
134    } else {
135        None
136    };
137    // #1083: the issue gate applies to any node that carries a reference —
138    // "no answer from the forge" is distinct from "no reference".
139    let issue = artifact
140        .and_then(|a| a.issue)
141        .map(|n| match facts.forge.issue_closed(n) {
142            Some(true) => IssueState::Closed,
143            Some(false) => IssueState::Open,
144            None => IssueState::Unknown,
145        });
146    NodeFacts {
147        commit_present,
148        verify,
149        children_all_done,
150        pr_merged,
151        ci_green,
152        issue,
153    }
154}
155
156/// Apply `node`'s #1030 completion rule to its [`NodeFacts`] (a pure reducer).
157#[must_use]
158pub fn evaluate_node(node: &Subtask, facts: &NodeFacts) -> NodeVerdict {
159    // #1083: the issue gate is common to every kind — a referenced issue must
160    // be CLOSED before any other rule may conclude Done. It only ever blocks;
161    // a closed issue proves nothing by itself.
162    match facts.issue {
163        Some(IssueState::Open) => {
164            return NodeVerdict::NotYet("the referenced issue is still open".into());
165        }
166        Some(IssueState::Unknown) => {
167            return NodeVerdict::Unsupported(
168                "the node references an issue but its state is unavailable — ensure `gh` \
169                 can reach the forge"
170                    .into(),
171            );
172        }
173        Some(IssueState::Closed) | None => {}
174    }
175    // A verify command that ran and FAILED blocks; absent (`None`) or passing is fine.
176    let verify_ok = facts.verify != Some(false);
177    match node.kind {
178        NodeKind::Task => {
179            if !facts.commit_present {
180                NodeVerdict::NotYet("no commit on the branch yet (set artifact_ref.commit)".into())
181            } else if !verify_ok {
182                NodeVerdict::NotYet("the task's verify command is failing".into())
183            } else {
184                NodeVerdict::Done
185            }
186        }
187        NodeKind::Plan => {
188            if !facts.children_all_done {
189                NodeVerdict::NotYet("not all child tasks are done".into())
190            } else if !verify_ok {
191                NodeVerdict::NotYet("the plan's branch verify is failing".into())
192            } else {
193                NodeVerdict::Done
194            }
195        }
196        NodeKind::Phase => {
197            if !facts.children_all_done {
198                NodeVerdict::NotYet("not all child plans are done".into())
199            } else {
200                match facts.pr_merged {
201                    Some(true) => NodeVerdict::Done,
202                    Some(false) => NodeVerdict::NotYet("the phase's PR is not merged yet".into()),
203                    None => NodeVerdict::Unsupported(
204                        "phase completion needs a merged PR — set artifact_ref.pr and ensure `gh` is available".into(),
205                    ),
206                }
207            }
208        }
209        NodeKind::Roadmap => {
210            if !facts.children_all_done {
211                NodeVerdict::NotYet("not all child phases are done".into())
212            } else {
213                match facts.ci_green {
214                    Some(true) => NodeVerdict::Done,
215                    Some(false) => NodeVerdict::NotYet("the pipelines are not green".into()),
216                    None => NodeVerdict::Unsupported(
217                        "roadmap completion needs green CI — no pipeline status available".into(),
218                    ),
219                }
220            }
221        }
222    }
223}
224
225/// Gather facts for `node` and apply its completion rule.
226#[must_use]
227pub fn evaluate(node: &Subtask, tree: &Plan, facts: &Facts) -> NodeVerdict {
228    evaluate_node(node, &gather_facts(node, tree, facts))
229}
230
231/// What one headless traversal step did at the cursor node.
232///
233/// The driver only *closes* nodes whose OBJECTIVE evaluator already passes — it
234/// never runs a model. A [`Blocked`](DriveStep::Blocked) step is exactly where a
235/// real driver (interactive or wyvern) would dispatch work on the node before
236/// trying again; [`Complete`](DriveStep::Complete) means the tree is finished
237/// (or every remaining node is blocked by a dep / open child).
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum DriveStep {
240    /// The cursor node evaluated [`NodeVerdict::Done`] and was marked Done.
241    Advanced { node: String },
242    /// The cursor node is not (yet) completable — traversal halts honestly here.
243    /// `reason` carries the evaluator's `NotYet`/`Unsupported` message.
244    Blocked { node: String, reason: String },
245    /// No node is ready — the tree is complete, or nothing more can advance.
246    Complete,
247}
248
249/// The #1030 headless traversal cursor: the leftmost **not-yet-closed** node
250/// (`Pending` OR `Running`) whose deps AND children are all `Done` — the next
251/// node `drive` can evaluate to close.
252///
253/// Unlike the interactive [`roadmap_cursor`](../../newt_tui) this is deliberately
254/// NOT "Running-first" (#1080): a bound Plan is `Running`, and Running-first made
255/// `drive` evaluate the *Plan* (→ "children not done" → Blocked) and never
256/// descend to its ready child Task — so a bound Plan's Tasks (even with commits)
257/// never closed. Descending instead evaluates the ready Task first (closing it
258/// from git), and — because a `Running` node is *also* eligible here (whereas
259/// [`Plan::next_ready_node`] only returns `Pending`) — the bound Plan itself
260/// closes once its children are `Done`. `Failed` nodes are excluded: they block
261/// honestly (their dependents' `Done` deps stay unmet). The interactive cursor
262/// keeps Running-first — staying on the bound node is right for `/roadmap next`.
263#[must_use]
264pub fn drive_cursor(tree: &Plan) -> Option<&Subtask> {
265    tree.subtasks.iter().find(|s| {
266        matches!(s.status, SubtaskStatus::Pending | SubtaskStatus::Running)
267            && s.deps
268                .iter()
269                .all(|d| matches!(tree.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
270            && tree
271                .children(&s.id)
272                .iter()
273                .all(|c| c.status == SubtaskStatus::Done)
274    })
275}
276
277/// Evaluate the cursor node once and, if it is [`NodeVerdict::Done`], mark it —
278/// the headless analogue of `/roadmap eval` on the cursor. This is the pure,
279/// fully-mockable core of the wyvern tree driver: it does NOT run a model or
280/// touch the network directly; every objective fact arrives through [`Facts`].
281///
282/// - cursor Done → [`DriveStep::Advanced`] (node marked Done, so the next call
283///   sees the next-ready node and completion ripples up the tree).
284/// - cursor `NotYet`/`Unsupported` → [`DriveStep::Blocked`] (the reason names
285///   what a real driver must make true — a commit, a passing verify, a merged
286///   PR, green CI).
287/// - no cursor → [`DriveStep::Complete`].
288pub fn drive_once(tree: &mut Plan, facts: &Facts) -> DriveStep {
289    let Some(node_id) = drive_cursor(tree).map(|s| s.id.clone()) else {
290        return DriveStep::Complete;
291    };
292    // The cursor was just read from `tree`, so the node exists.
293    let node = tree
294        .subtask(&node_id)
295        .cloned()
296        .expect("cursor node exists in the tree");
297    match evaluate(&node, tree, facts) {
298        NodeVerdict::Done => {
299            tree.mark(&node_id, SubtaskStatus::Done, None);
300            DriveStep::Advanced { node: node_id }
301        }
302        NodeVerdict::NotYet(reason) | NodeVerdict::Unsupported(reason) => DriveStep::Blocked {
303            node: node_id,
304            reason,
305        },
306    }
307}
308
309/// Drive the tree to a fixpoint: repeatedly [`drive_once`] until it stops
310/// advancing. Because [`drive_once`] closes the leftmost-ready node and marking
311/// it Done can make its parent ready, one call ripples completion as far *up*
312/// the tree as the objective facts allow, halting honestly at the first node
313/// that still needs work (`Blocked`) or when the tree is finished (`Complete`).
314///
315/// The loop is bounded by `subtasks.len() + 1` — each `Advanced` closes a
316/// distinct node, so no run can advance more times than there are nodes; the
317/// bound is a hard backstop against a pathological cursor that never converges.
318/// The returned log ends in exactly one non-`Advanced` step (`Blocked` or
319/// `Complete`) on any well-formed tree.
320pub fn drive_to_fixpoint(tree: &mut Plan, facts: &Facts) -> Vec<DriveStep> {
321    let bound = tree.subtasks.len() + 1;
322    let mut steps = Vec::new();
323    for _ in 0..bound {
324        let step = drive_once(tree, facts);
325        let advanced = matches!(step, DriveStep::Advanced { .. });
326        steps.push(step);
327        if !advanced {
328            break;
329        }
330    }
331    steps
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::plan::{ArtifactRef, Subtask};
338
339    struct FakeGit(&'static [&'static str]);
340    impl GitFacts for FakeGit {
341        fn commit_present(&self, commit: &str, _branch: Option<&str>) -> bool {
342            self.0.contains(&commit)
343        }
344    }
345    struct FakeVerify(bool);
346    impl VerifyRunner for FakeVerify {
347        fn run(&self, _cmd: &str) -> bool {
348            self.0
349        }
350    }
351    /// Forge fake: a PR merge-state map (pr -> merged); absent pr -> None.
352    struct FakeForge(Option<bool>);
353    impl ForgeFacts for FakeForge {
354        fn pr_merged(&self, _pr: u64) -> Option<bool> {
355            self.0
356        }
357    }
358    struct FakeCi(Option<bool>);
359    impl CiFacts for FakeCi {
360        fn pipelines_green(&self) -> Option<bool> {
361            self.0
362        }
363    }
364
365    /// Build a Facts bundle from the fakes.
366    fn facts<'a>(
367        git: &'a FakeGit,
368        verify: &'a FakeVerify,
369        forge: &'a FakeForge,
370        ci: &'a FakeCi,
371    ) -> Facts<'a> {
372        Facts {
373            git,
374            verify,
375            forge,
376            ci,
377        }
378    }
379
380    fn task_with(commit: Option<&str>, verify: Option<&str>) -> Subtask {
381        let mut t = Subtask::node("t", "the task", NodeKind::Task, None);
382        t.artifact_ref = commit.map(|c| ArtifactRef {
383            branch: Some("feat/x".into()),
384            commit: Some(c.into()),
385            pr: None,
386            issue: None,
387        });
388        t.verify = verify.map(str::to_string);
389        t
390    }
391
392    #[test]
393    fn task_is_done_only_with_a_present_commit_and_passing_verify() {
394        let tree = Plan::default();
395        let (git, pass, fail, forge, ci) = (
396            FakeGit(&["deadbeef"]),
397            FakeVerify(true),
398            FakeVerify(false),
399            FakeForge(None),
400            FakeCi(None),
401        );
402        let ok = facts(&git, &pass, &forge, &ci);
403        let bad = facts(&git, &fail, &forge, &ci);
404
405        assert_eq!(
406            evaluate(&task_with(Some("deadbeef"), Some("cargo test")), &tree, &ok),
407            NodeVerdict::Done
408        );
409        assert_eq!(
410            evaluate(&task_with(Some("deadbeef"), None), &tree, &ok),
411            NodeVerdict::Done
412        );
413        assert!(matches!(
414            evaluate(
415                &task_with(Some("deadbeef"), Some("cargo test")),
416                &tree,
417                &bad
418            ),
419            NodeVerdict::NotYet(_)
420        ));
421        assert!(matches!(
422            evaluate(&task_with(Some("nope"), None), &tree, &ok),
423            NodeVerdict::NotYet(_)
424        ));
425        assert!(matches!(
426            evaluate(&task_with(None, None), &tree, &ok),
427            NodeVerdict::NotYet(_)
428        ));
429    }
430
431    // ── #1083: the issue gate ────────────────────────────────────────────────
432
433    /// Forge fake answering BOTH seams: `.0` = pr_merged, `.1` = issue_closed.
434    struct FakeIssueForge(Option<bool>, Option<bool>);
435    impl ForgeFacts for FakeIssueForge {
436        fn pr_merged(&self, _pr: u64) -> Option<bool> {
437            self.0
438        }
439        fn issue_closed(&self, _issue: u64) -> Option<bool> {
440            self.1
441        }
442    }
443
444    /// An otherwise-Done Task (commit present, no verify) referencing `issue`.
445    fn done_task_with_issue(issue: u64) -> Subtask {
446        let mut t = task_with(Some("deadbeef"), None);
447        t.artifact_ref.as_mut().unwrap().issue = Some(issue);
448        t
449    }
450
451    #[test]
452    fn issue_gate_blocks_done_while_the_referenced_issue_is_open() {
453        let tree = Plan::default();
454        let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
455        let open = FakeIssueForge(None, Some(false));
456        let closed = FakeIssueForge(None, Some(true));
457        let t = done_task_with_issue(1083);
458
459        let verdict = evaluate(
460            &t,
461            &tree,
462            &Facts {
463                git: &git,
464                verify: &verify,
465                forge: &open,
466                ci: &ci,
467            },
468        );
469        assert!(
470            matches!(&verdict, NodeVerdict::NotYet(r) if r.contains("issue")),
471            "open issue must block: {verdict:?}"
472        );
473        assert_eq!(
474            evaluate(
475                &t,
476                &tree,
477                &Facts {
478                    git: &git,
479                    verify: &verify,
480                    forge: &closed,
481                    ci: &ci,
482                }
483            ),
484            NodeVerdict::Done
485        );
486    }
487
488    #[test]
489    fn issue_gate_unknown_state_is_unsupported_never_done() {
490        // The default trait method answers None — an impl that never learned
491        // about issues degrades to Unsupported, not a false Done (#1083).
492        let tree = Plan::default();
493        let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
494        let legacy = FakeForge(None); // no issue_closed override
495        let verdict = evaluate(
496            &done_task_with_issue(1083),
497            &tree,
498            &Facts {
499                git: &git,
500                verify: &verify,
501                forge: &legacy,
502                ci: &ci,
503            },
504        );
505        assert!(
506            matches!(&verdict, NodeVerdict::Unsupported(r) if r.contains("issue")),
507            "unknown issue state must be Unsupported: {verdict:?}"
508        );
509    }
510
511    #[test]
512    fn nodes_without_an_issue_ref_ignore_the_issue_gate() {
513        // A forge screaming "open!" must not gate a node with no reference.
514        let tree = Plan::default();
515        let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
516        let open = FakeIssueForge(None, Some(false));
517        assert_eq!(
518            evaluate(
519                &task_with(Some("deadbeef"), None),
520                &tree,
521                &Facts {
522                    git: &git,
523                    verify: &verify,
524                    forge: &open,
525                    ci: &ci,
526                }
527            ),
528            NodeVerdict::Done
529        );
530    }
531
532    #[test]
533    fn plan_is_done_when_all_child_tasks_are_done_and_verify_passes() {
534        let toml = r#"
535[[subtask]]
536id = "plan-1"
537instruction = "the plan"
538kind = "plan"
539
540[[subtask]]
541id = "t1"
542instruction = "task 1"
543kind = "task"
544parent = "plan-1"
545
546[[subtask]]
547id = "t2"
548instruction = "task 2"
549kind = "task"
550parent = "plan-1"
551"#;
552        let mut tree = Plan::from_toml_str(toml).unwrap();
553        let (git, pass, forge, ci) = (
554            FakeGit(&[]),
555            FakeVerify(true),
556            FakeForge(None),
557            FakeCi(None),
558        );
559        let ok = facts(&git, &pass, &forge, &ci);
560        let plan = tree.subtask("plan-1").unwrap().clone();
561
562        assert!(matches!(
563            evaluate(&plan, &tree, &ok),
564            NodeVerdict::NotYet(_)
565        ));
566        tree.mark("t1", SubtaskStatus::Done, None);
567        tree.mark("t2", SubtaskStatus::Done, None);
568        assert_eq!(evaluate(&plan, &tree, &ok), NodeVerdict::Done);
569    }
570
571    #[test]
572    fn phase_is_done_when_children_done_and_pr_merged() {
573        // ph with one Plan child; the phase carries a PR reference.
574        let toml = r#"
575[[subtask]]
576id = "ph"
577instruction = "phase one"
578kind = "phase"
579
580[[subtask]]
581id = "plan-1"
582instruction = "a plan"
583kind = "plan"
584parent = "ph"
585
586[subtask.artifact_ref]
587pr = 42
588"#;
589        // ArtifactRef attaches to the LAST scalar-preceding subtask (plan-1), not
590        // ph — so build the phase with an explicit artifact_ref instead.
591        let mut tree = Plan::from_toml_str(toml).unwrap();
592        // Give the phase (not the plan) the PR ref.
593        if let Some(phase) = tree.subtasks.iter_mut().find(|s| s.id == "ph") {
594            phase.artifact_ref = Some(ArtifactRef {
595                branch: None,
596                commit: None,
597                pr: Some(42),
598                issue: None,
599            });
600        }
601        let git = FakeGit(&[]);
602        let verify = FakeVerify(true);
603        let ci = FakeCi(None);
604        let phase = tree.subtask("ph").unwrap().clone();
605
606        // child plan pending → NotYet regardless of PR.
607        let merged = facts(&git, &verify, &FakeForge(Some(true)), &ci);
608        assert!(matches!(
609            evaluate(&phase, &tree, &merged),
610            NodeVerdict::NotYet(_)
611        ));
612        // child done + PR merged → Done.
613        tree.mark("plan-1", SubtaskStatus::Done, None);
614        assert_eq!(evaluate(&phase, &tree, &merged), NodeVerdict::Done);
615        // child done + PR NOT merged → NotYet.
616        let open = facts(&git, &verify, &FakeForge(Some(false)), &ci);
617        assert!(matches!(
618            evaluate(&phase, &tree, &open),
619            NodeVerdict::NotYet(_)
620        ));
621        // child done but no forge access (or no PR ref) → Unsupported.
622        let no_forge = facts(&git, &verify, &FakeForge(None), &ci);
623        assert!(matches!(
624            evaluate(&phase, &tree, &no_forge),
625            NodeVerdict::Unsupported(_)
626        ));
627    }
628
629    #[test]
630    fn roadmap_is_done_when_children_done_and_ci_green() {
631        let toml = r#"
632[[subtask]]
633id = "rd"
634instruction = "the roadmap"
635kind = "roadmap"
636
637[[subtask]]
638id = "ph"
639instruction = "a phase"
640kind = "phase"
641parent = "rd"
642"#;
643        let mut tree = Plan::from_toml_str(toml).unwrap();
644        let (git, verify, forge) = (FakeGit(&[]), FakeVerify(true), FakeForge(None));
645        let road = tree.subtask("rd").unwrap().clone();
646
647        // child phase pending → NotYet.
648        let green = facts(&git, &verify, &forge, &FakeCi(Some(true)));
649        assert!(matches!(
650            evaluate(&road, &tree, &green),
651            NodeVerdict::NotYet(_)
652        ));
653        // child done + CI green → Done.
654        tree.mark("ph", SubtaskStatus::Done, None);
655        assert_eq!(evaluate(&road, &tree, &green), NodeVerdict::Done);
656        // child done + CI red → NotYet.
657        let red = facts(&git, &verify, &forge, &FakeCi(Some(false)));
658        assert!(matches!(
659            evaluate(&road, &tree, &red),
660            NodeVerdict::NotYet(_)
661        ));
662        // child done + no CI access → Unsupported.
663        let no_ci = facts(&git, &verify, &forge, &FakeCi(None));
664        assert!(matches!(
665            evaluate(&road, &tree, &no_ci),
666            NodeVerdict::Unsupported(_)
667        ));
668    }
669
670    #[test]
671    fn empty_plan_is_not_done_by_vacuity() {
672        let plan = Subtask::node("p", "empty plan", NodeKind::Plan, None);
673        let tree = Plan {
674            subtasks: vec![plan.clone()],
675            ..Plan::default()
676        };
677        let (git, verify, forge, ci) = (
678            FakeGit(&[]),
679            FakeVerify(true),
680            FakeForge(None),
681            FakeCi(None),
682        );
683        assert!(matches!(
684            evaluate(&plan, &tree, &facts(&git, &verify, &forge, &ci)),
685            NodeVerdict::NotYet(_)
686        ));
687    }
688
689    /// A Phase→Plan→{Task,Task} tree whose commits, verify, and PR are all
690    /// objectively satisfied drives to completion in one fixpoint call: the two
691    /// Tasks close, which makes the Plan ready (children done + verify), which
692    /// makes the Phase ready (children done + PR merged) — completion ripples
693    /// all the way up, then `Complete`.
694    fn cascade_tree() -> Plan {
695        let toml = r#"
696[[subtask]]
697id = "ph"
698instruction = "phase one"
699kind = "phase"
700
701[[subtask]]
702id = "plan-1"
703instruction = "a plan"
704kind = "plan"
705parent = "ph"
706
707[[subtask]]
708id = "t1"
709instruction = "task 1"
710kind = "task"
711parent = "plan-1"
712
713[[subtask]]
714id = "t2"
715instruction = "task 2"
716kind = "task"
717parent = "plan-1"
718"#;
719        let mut tree = Plan::from_toml_str(toml).unwrap();
720        for (id, commit) in [("t1", "c1"), ("t2", "c2")] {
721            if let Some(t) = tree.subtasks.iter_mut().find(|s| s.id == id) {
722                t.artifact_ref = Some(ArtifactRef {
723                    branch: Some("feat/x".into()),
724                    commit: Some(commit.into()),
725                    pr: None,
726                    issue: None,
727                });
728            }
729        }
730        if let Some(ph) = tree.subtasks.iter_mut().find(|s| s.id == "ph") {
731            ph.artifact_ref = Some(ArtifactRef {
732                branch: None,
733                commit: None,
734                pr: Some(42),
735                issue: None,
736            });
737        }
738        tree
739    }
740
741    #[test]
742    fn drive_once_advances_a_completable_task_and_marks_it_done() {
743        let mut tree = cascade_tree();
744        let (git, verify, forge, ci) = (
745            FakeGit(&["c1", "c2"]),
746            FakeVerify(true),
747            FakeForge(Some(true)),
748            FakeCi(None),
749        );
750        // The cursor is the leftmost ready node — task t1 (a leaf whose deps and
751        // children are clear).
752        let step = drive_once(&mut tree, &facts(&git, &verify, &forge, &ci));
753        assert_eq!(
754            step,
755            DriveStep::Advanced {
756                node: "t1".to_string()
757            }
758        );
759        assert_eq!(tree.subtask("t1").unwrap().status, SubtaskStatus::Done);
760    }
761
762    #[test]
763    fn drive_once_blocks_on_a_task_without_a_commit() {
764        let mut tree = cascade_tree();
765        // No commits present → the cursor task cannot close.
766        let (git, verify, forge, ci) = (
767            FakeGit(&[]),
768            FakeVerify(true),
769            FakeForge(None),
770            FakeCi(None),
771        );
772        let step = drive_once(&mut tree, &facts(&git, &verify, &forge, &ci));
773        match step {
774            DriveStep::Blocked { node, reason } => {
775                assert_eq!(node, "t1");
776                assert!(reason.contains("commit"), "reason was: {reason}");
777            }
778            other => panic!("expected Blocked, got {other:?}"),
779        }
780        // Blocked never mutates status.
781        assert_eq!(tree.subtask("t1").unwrap().status, SubtaskStatus::Pending);
782    }
783
784    #[test]
785    fn drive_to_fixpoint_cascades_completion_up_the_tree() {
786        let mut tree = cascade_tree();
787        let (git, verify, forge, ci) = (
788            FakeGit(&["c1", "c2"]),
789            FakeVerify(true),
790            FakeForge(Some(true)),
791            FakeCi(None),
792        );
793        let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
794        // t1, t2, plan-1, ph advance in DFS order, then Complete.
795        assert_eq!(
796            steps,
797            vec![
798                DriveStep::Advanced { node: "t1".into() },
799                DriveStep::Advanced { node: "t2".into() },
800                DriveStep::Advanced {
801                    node: "plan-1".into()
802                },
803                DriveStep::Advanced { node: "ph".into() },
804                DriveStep::Complete,
805            ]
806        );
807        assert!(tree
808            .subtasks
809            .iter()
810            .all(|s| s.status == SubtaskStatus::Done));
811    }
812
813    #[test]
814    fn drive_to_fixpoint_halts_at_the_first_blocked_node() {
815        let mut tree = cascade_tree();
816        // t1's commit is present but t2's is not: t1 closes, then t2 blocks and
817        // the run stops honestly (the Plan/Phase above never falsely close).
818        let (git, verify, forge, ci) = (
819            FakeGit(&["c1"]),
820            FakeVerify(true),
821            FakeForge(Some(true)),
822            FakeCi(None),
823        );
824        let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
825        assert_eq!(steps.len(), 2);
826        assert_eq!(steps[0], DriveStep::Advanced { node: "t1".into() });
827        assert!(matches!(&steps[1], DriveStep::Blocked { node, .. } if node == "t2"));
828        assert_eq!(
829            tree.subtask("plan-1").unwrap().status,
830            SubtaskStatus::Pending
831        );
832        assert_eq!(tree.subtask("ph").unwrap().status, SubtaskStatus::Pending);
833    }
834
835    #[test]
836    fn drive_to_fixpoint_on_an_empty_tree_is_a_single_complete_step() {
837        let mut tree = Plan::default();
838        let (git, verify, forge, ci) = (
839            FakeGit(&[]),
840            FakeVerify(true),
841            FakeForge(None),
842            FakeCi(None),
843        );
844        let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
845        assert_eq!(steps, vec![DriveStep::Complete]);
846    }
847
848    #[test]
849    fn drive_cursor_descends_past_a_running_plan_then_closes_it() {
850        // #1080: a bound Plan is Running. The headless cursor must DESCEND to its
851        // ready Task (not block on the Plan) — and once the Tasks are Done, the
852        // Running Plan itself becomes the cursor so drive can close it (a Running
853        // node is eligible here, unlike next_ready_node which returns only Pending).
854        let mut tree = cascade_tree(); // ph → plan-1 → {t1, t2}
855        tree.mark("plan-1", SubtaskStatus::Running, None); // bind the Plan
856        assert_eq!(
857            drive_cursor(&tree).map(|s| s.id.as_str()),
858            Some("t1"),
859            "descend past the Running plan-1 to the leftmost ready Task"
860        );
861        // With both Tasks Done, the Running Plan becomes the cursor.
862        tree.mark("t1", SubtaskStatus::Done, None);
863        tree.mark("t2", SubtaskStatus::Done, None);
864        assert_eq!(
865            drive_cursor(&tree).map(|s| s.id.as_str()),
866            Some("plan-1"),
867            "a Running Plan with Done children is the cursor so drive closes it"
868        );
869    }
870
871    #[test]
872    fn drive_to_fixpoint_closes_a_bound_running_plan_and_its_tasks() {
873        // #1080 end-to-end regression: bind the Plan (Running), its Tasks carry
874        // commits + verify passes + the Phase PR is merged → drive closes
875        // Task→Task→Plan→Phase unattended (the old Running-first cursor stalled at
876        // the Plan and drove 0 nodes).
877        let mut tree = cascade_tree();
878        tree.mark("plan-1", SubtaskStatus::Running, None); // the bound Plan
879        let (git, verify, forge, ci) = (
880            FakeGit(&["c1", "c2"]),
881            FakeVerify(true),
882            FakeForge(Some(true)),
883            FakeCi(None),
884        );
885        let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
886        assert_eq!(
887            steps,
888            vec![
889                DriveStep::Advanced { node: "t1".into() },
890                DriveStep::Advanced { node: "t2".into() },
891                DriveStep::Advanced {
892                    node: "plan-1".into()
893                },
894                DriveStep::Advanced { node: "ph".into() },
895                DriveStep::Complete,
896            ],
897            "the bound Running Plan and its Tasks close from git truth, unattended"
898        );
899        assert!(tree
900            .subtasks
901            .iter()
902            .all(|s| s.status == SubtaskStatus::Done));
903    }
904}