Skip to main content

newt_core/
plan.rs

1//! The canonical agent **plan**: one `serde` [`Plan`] / [`Subtask`] definition
2//! shared by the human-driven collaborative `/plan` surface (#334) and the
3//! swarm scheduler (Workstream C / the future `newt-scheduler`).
4//!
5//! The plain-text plan file is the single source of truth across tiers
6//! (newt/drake *author* → headless wyvern *executes*), so this struct is the
7//! **only** definition both sides deserialize. Landing it once prevents the
8//! "two plan shapes wearing one filename" drift flagged in #334: the `/plan`
9//! S3a author and the C1 scheduler consume the *same* `struct`, or they diverge.
10//!
11//! Three properties are load-bearing and tested (§ `tests`):
12//!
13//! 1. **Fragment-validity.** A bare `[[subtask]]` list (no top-level `goal`)
14//!    deserializes on its own, so a *slice* of a plan can be handed to one
15//!    flight via `wyvern --plan`/stdin (#334 S3f). The handoff is a parse
16//!    invariant, not a hope.
17//! 2. **Default-deny authority.** An omitted [`Subtask::caveat_policy`]
18//!    deserializes to the *narrowest* policy — every capability axis denied —
19//!    never `Caveats::top()` minus a few axes. A model-*proposed* plan must not
20//!    acquire authority by omission; the model names what a subtask needs and
21//!    the harness grants no more. This is the #319/#332 lesson ("the harness
22//!    stamps, the model never asserts") applied at the orchestration layer, and
23//!    it pre-wires Workstream C §7: *the plan requests, the parent grants, and
24//!    `delegate` enforces `⊑`* (attenuation can only narrow, never widen).
25//! 3. **Resumability.** Per-subtask [`Subtask::status`] / [`Subtask::result`]
26//!    make the plan file both the proposal *and* the run-log, so `/plan resume`
27//!    and `/plan status` (#334 S3e) have a real thing to read and aggregation
28//!    has a destination.
29
30use serde::{Deserialize, Serialize};
31
32use crate::role_profile::{ScopeKeyword, ScopeSpec};
33use crate::{Caveats, CountBound, Scope};
34
35/// A complete plan, or a fragment of one.
36///
37/// Serializes to TOML as an optional `goal` + `aggregation` scalar plus a
38/// `[[subtask]]` array-of-tables. `goal` is optional precisely so a bare
39/// `[[subtask]]` fragment parses (fragment-validity, §module docs).
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
41#[serde(deny_unknown_fields)]
42pub struct Plan {
43    /// The overall goal this plan pursues. `None` in a fragment.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub goal: Option<String>,
46    /// How child results are combined back into the plan (default `Concat`).
47    #[serde(default)]
48    pub aggregation: Aggregation,
49    /// The subtasks, as TOML `[[subtask]]` tables. (Rust field `subtasks`,
50    /// TOML key `subtask`.)
51    #[serde(default, rename = "subtask")]
52    pub subtasks: Vec<Subtask>,
53}
54
55impl Plan {
56    /// Parse a plan (or fragment) from its TOML form.
57    ///
58    /// # Errors
59    /// Returns the `toml` deserialization error on malformed input or an
60    /// unknown field (the schema is `deny_unknown_fields` — one canonical shape).
61    pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
62        toml::from_str(s)
63    }
64
65    /// Serialize this plan to its canonical TOML form.
66    ///
67    /// # Errors
68    /// Returns the `toml` serialization error (should not occur for a
69    /// well-formed [`Plan`]).
70    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
71        toml::to_string_pretty(self)
72    }
73
74    /// The subtask with id `id`, if present.
75    #[must_use]
76    pub fn subtask(&self, id: &str) -> Option<&Subtask> {
77        self.subtasks.iter().find(|s| s.id == id)
78    }
79
80    /// Root subtasks — those with no [`Subtask::parent`] (the top of the
81    /// decomposition tree).
82    #[must_use]
83    pub fn roots(&self) -> Vec<&Subtask> {
84        self.subtasks
85            .iter()
86            .filter(|s| s.parent.is_none())
87            .collect()
88    }
89
90    /// Direct children of `id` — subtasks whose `parent` is `id`.
91    #[must_use]
92    pub fn children(&self, id: &str) -> Vec<&Subtask> {
93        self.subtasks
94            .iter()
95            .filter(|s| s.parent.as_deref() == Some(id))
96            .collect()
97    }
98
99    /// Leaves — subtasks that no other subtask names as `parent`. A leaf is the
100    /// dispatch/execute unit (a leaf *is* a `CrewTask`); a non-leaf is a branch
101    /// (grouping / aggregation). In a flat, single-level plan *every* subtask is a
102    /// leaf, so this degrades to "all subtasks" — the pre-tree behaviour, so
103    /// existing flat plans are unaffected.
104    #[must_use]
105    pub fn leaves(&self) -> Vec<&Subtask> {
106        let parented: std::collections::HashSet<&str> = self
107            .subtasks
108            .iter()
109            .filter_map(|s| s.parent.as_deref())
110            .collect();
111        self.subtasks
112            .iter()
113            .filter(|s| !parented.contains(s.id.as_str()))
114            .collect()
115    }
116
117    /// The next **ready leaf** to dispatch — the execution cursor. A [`leaf`] that
118    /// is [`SubtaskStatus::Pending`] and whose every [`dep`] is
119    /// [`SubtaskStatus::Done`]. `None` when nothing is ready (all done, every
120    /// pending leaf is dep-blocked, or work is in flight). A `dep` counts as
121    /// satisfied iff the named subtask exists and is `Done`; an absent (e.g.
122    /// cross-fragment) dep is treated as unsatisfied, so a plan never runs a leaf
123    /// ahead of a prerequisite it cannot see.
124    ///
125    /// [`leaf`]: Plan::leaves
126    /// [`dep`]: Subtask::deps
127    #[must_use]
128    pub fn next_ready_leaf(&self) -> Option<&Subtask> {
129        self.leaves().into_iter().find(|s| {
130            s.status == SubtaskStatus::Pending
131                && s.deps
132                    .iter()
133                    .all(|d| matches!(self.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
134        })
135    }
136
137    /// The next leaf to **dispatch**, as `(id, CrewTask)` — [`next_ready_leaf`]
138    /// projected through [`Subtask::to_crew_task`]. This is the drive loop's read
139    /// step: dispatch the `CrewTask`, then [`mark`](Plan::mark) the `id`
140    /// `Done`/`Failed` and call again. `None` when the plan is complete or stalled
141    /// (every remaining leaf blocked by a non-`Done` dep). The `id` is returned
142    /// because the projected `CrewTask` deliberately drops it (it is the plan's
143    /// bookkeeping, not the child's).
144    ///
145    /// [`next_ready_leaf`]: Plan::next_ready_leaf
146    #[must_use]
147    pub fn next_dispatch(&self, parent: &Caveats) -> Option<(String, CrewTask)> {
148        self.next_ready_leaf()
149            .map(|s| (s.id.clone(), s.to_crew_task(parent)))
150    }
151
152    /// Record a leaf's outcome — set its [`status`](Subtask::status) and, when
153    /// `result` is `Some`, its [`result`](Subtask::result). No-op if `id` is
154    /// absent. The drive loop calls this after each dispatch; marking a leaf
155    /// `Done` may unblock its dependents on the next [`next_dispatch`], and
156    /// marking it `Failed` leaves them blocked (deps require `Done`), so the run
157    /// stops honestly at the first failure without a separate "stop" flag.
158    ///
159    /// [`next_dispatch`]: Plan::next_dispatch
160    pub fn mark(&mut self, id: &str, status: SubtaskStatus, result: Option<String>) {
161        if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
162            s.status = status;
163            if result.is_some() {
164                s.result = result;
165            }
166        }
167    }
168
169    /// Overwrite a subtask's instruction by id — used by failure-driven
170    /// re-grounding (#692) to steer a retried leaf at the symbol's real file.
171    pub fn set_instruction(&mut self, id: &str, instruction: &str) {
172        if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
173            s.instruction = instruction.to_string();
174        }
175    }
176
177    /// Drop a subtask's file scope by id (#812). Used by re-grounding: a
178    /// reground means the leaf's grounding was demonstrably wrong, so a
179    /// derived-from-the-same-grounding fence is stale — the retry runs
180    /// unfenced rather than deterministically re-refusing the corrected edit.
181    pub fn clear_context(&mut self, id: &str) {
182        if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
183            s.context.clear();
184        }
185    }
186
187    /// #1062: bind a node to the commit that realizes it — set
188    /// [`artifact_ref.commit`](ArtifactRef::commit) (and `branch` when given),
189    /// preserving any existing `pr`/`branch`. This is what lets the objective
190    /// evaluator ([`crate::roadmap_eval`]) close a Task from git truth instead of
191    /// a human `mark`. No-op if `id` is absent.
192    pub fn set_artifact_commit(&mut self, id: &str, commit: &str, branch: Option<&str>) {
193        if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
194            let existing = s.artifact_ref.take().unwrap_or_default();
195            s.artifact_ref = Some(ArtifactRef {
196                commit: Some(commit.to_string()),
197                branch: branch.map(str::to_string).or(existing.branch),
198                ..existing
199            });
200        }
201    }
202
203    /// #1083: bind node `id` to the forge issue it realizes, preserving any
204    /// existing branch/commit/pr refs, so the objective evaluator
205    /// ([`crate::roadmap_eval`]) can require the issue CLOSED before Done.
206    /// No-op if `id` is absent (same contract as
207    /// [`set_artifact_commit`](Self::set_artifact_commit)).
208    pub fn set_artifact_issue(&mut self, id: &str, issue: u64) {
209        if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
210            let existing = s.artifact_ref.take().unwrap_or_default();
211            s.artifact_ref = Some(ArtifactRef {
212                issue: Some(issue),
213                ..existing
214            });
215        }
216    }
217
218    /// #1062 auto-capture: the next Task under `plan_id` that still needs a commit
219    /// — the leftmost `Pending` Task in `plan_id`'s subtree whose
220    /// [`artifact_ref.commit`](ArtifactRef::commit) is unset. `None` if `plan_id`
221    /// isn't a Plan, or every Task beneath it is already captured or closed.
222    /// Document order encodes sibling order, so "leftmost" is the first match —
223    /// the same rule the DFS cursor uses — giving the 1-commit-→-1-Task default.
224    #[must_use]
225    pub fn next_uncaptured_task_under(&self, plan_id: &str) -> Option<&Subtask> {
226        if self.subtask(plan_id)?.kind != NodeKind::Plan {
227            return None;
228        }
229        self.subtasks.iter().find(|s| {
230            s.kind == NodeKind::Task
231                && s.status == SubtaskStatus::Pending
232                && s.artifact_ref
233                    .as_ref()
234                    .and_then(|a| a.commit.as_deref())
235                    .is_none()
236                && self.path_to(&s.id).iter().any(|p| p == plan_id)
237        })
238    }
239
240    /// Every leaf is `Done` — the plan finished successfully (branches are
241    /// grouping nodes, so only leaf completion is load-bearing). An empty plan is
242    /// trivially complete.
243    #[must_use]
244    pub fn is_complete(&self) -> bool {
245        self.leaves()
246            .iter()
247            .all(|s| s.status == SubtaskStatus::Done)
248    }
249
250    /// #1030 tree cursor: the next node to act on in depth-first, sibling order
251    /// — the leftmost [`SubtaskStatus::Pending`] subtask whose every [`dep`] is
252    /// `Done` **and** whose direct children are all `Done`. Generalizes
253    /// [`next_ready_leaf`] to interior nodes: a leaf (no children) is ready as
254    /// soon as its deps clear; a branch (Roadmap/Phase/Plan grouping) becomes
255    /// ready only once its subtree has completed — which is exactly when its
256    /// evaluator should run and fold the children's results upward, then the
257    /// cursor returns to the branch's parent. Document order encodes sibling
258    /// order, so "leftmost" is the first match. `None` when nothing is ready
259    /// (all done, or every pending node is blocked by a dep or an open child).
260    ///
261    /// [`next_ready_leaf`]: Plan::next_ready_leaf
262    /// [`dep`]: Subtask::deps
263    #[must_use]
264    pub fn next_ready_node(&self) -> Option<&Subtask> {
265        self.subtasks.iter().find(|s| {
266            s.status == SubtaskStatus::Pending
267                && s.deps
268                    .iter()
269                    .all(|d| matches!(self.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
270                && self
271                    .children(&s.id)
272                    .iter()
273                    .all(|c| c.status == SubtaskStatus::Done)
274        })
275    }
276
277    /// #1030: is node `id` **and its entire subtree** `Done`? A missing id is
278    /// `false` — a node we cannot see is not provably complete (mirroring the
279    /// unsatisfied-absent-`dep` rule). The traversal uses this to decide when a
280    /// branch's children have all finished so it may return up to the parent.
281    #[must_use]
282    pub fn subtree_complete(&self, id: &str) -> bool {
283        match self.subtask(id) {
284            None => false,
285            Some(node) => {
286                node.status == SubtaskStatus::Done
287                    && self
288                        .children(id)
289                        .iter()
290                        .all(|c| self.subtree_complete(&c.id))
291            }
292        }
293    }
294
295    /// #1030: the ancestor chain from the root down to `id` (inclusive) as a vec
296    /// of ids — the breadcrumb a driver shows and a resume uses to descend.
297    /// Empty when `id` is absent. A cycle in the soft [`parent`](Subtask::parent)
298    /// pointers is broken by stopping at the first revisited id.
299    #[must_use]
300    pub fn path_to(&self, id: &str) -> Vec<String> {
301        let mut chain = Vec::new();
302        let mut seen = std::collections::HashSet::new();
303        let mut cur = self.subtask(id);
304        while let Some(node) = cur {
305            if !seen.insert(node.id.as_str()) {
306                break; // cycle guard: a parent pointer looped back
307            }
308            chain.push(node.id.clone());
309            cur = node.parent.as_deref().and_then(|p| self.subtask(p));
310        }
311        chain.reverse();
312        chain
313    }
314
315    /// #1030: all subtasks of a given [`NodeKind`], in document (sibling) order.
316    #[must_use]
317    pub fn nodes_of_kind(&self, kind: NodeKind) -> Vec<&Subtask> {
318        self.subtasks.iter().filter(|s| s.kind == kind).collect()
319    }
320}
321
322/// One unit of work in a [`Plan`] — the serialized form of a single scheduler
323/// dispatch. A fragment-valid `[[subtask]]` table is exactly this.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(deny_unknown_fields)]
326pub struct Subtask {
327    /// Stable identifier, referenced by other subtasks' [`Subtask::deps`].
328    pub id: String,
329    /// What the child agent is asked to do.
330    pub instruction: String,
331    /// Ids of subtasks that must complete before this one may start.
332    #[serde(default)]
333    pub deps: Vec<String>,
334    /// May this subtask run concurrently with its ready siblings?
335    #[serde(default)]
336    pub parallel_ok: bool,
337    /// The leaf's FILE SCOPE — its write lane (#812), not reading material.
338    /// At dispatch this is forwarded as `args["scope"]` and intersected into
339    /// the effective writable set (worktree ∩ fs_write ∩ scope): a meet-only
340    /// convenience fence that can only narrow, never widen. Empty = unfenced
341    /// (pre-#812 behavior). Matching is exact-file or directory-prefix, with
342    /// `./` and trailing-`/` normalized; degenerate entries (`""`, `"."`)
343    /// fail open. Populated by the harness's own def-site grounding, with
344    /// model-declared `files` appended as untrusted augmentation.
345    #[serde(default)]
346    pub context: Vec<String>,
347    /// Optional verify command whose **enforced** result gates the subtask
348    /// (#332 S1). Absent = no per-subtask gate.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub verify: Option<String>,
351    /// Execution status — makes the plan file a resumable run-log.
352    #[serde(default)]
353    pub status: SubtaskStatus,
354    /// Where the child's output lands on completion (aggregation destination).
355    /// `None` until the subtask has run.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub result: Option<String>,
358    /// The id of the subtask this one decomposes — `None` for a root. This is how
359    /// a flat `[[subtask]]` list expresses a task→sub-task **tree** (exactly as
360    /// [`Subtask::deps`] expresses a DAG via id-pointers, not nesting). A subtask
361    /// that no other subtask names as its `parent` is a **leaf** — the unit that
362    /// dispatches/executes (a leaf *is* a `CrewTask`); a subtask that *is* named
363    /// is a **branch** (a grouping / aggregation node). Kept flat on purpose: a
364    /// nested `Vec<Subtask>` would break the fragment handoff (one leaf slice =
365    /// one dispatch). `None` is also fine in a fragment whose parent lives outside
366    /// the slice (the pointer is soft, like a cross-fragment `dep`).
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub parent: Option<String>,
369    /// #1030 tree node kind — this subtask's role in a Roadmap→Phase→Plan→Task
370    /// tree. Defaults to [`NodeKind::Task`] so a legacy flat plan (every subtask
371    /// a work unit) is unchanged: the existing leaf/crew semantics read
372    /// `status`/`parent`/`deps`, never `kind`. A scalar — precedes the tables.
373    #[serde(default)]
374    pub kind: NodeKind,
375    /// #1030: for a [`NodeKind::Plan`] node, the id of the `conversations` row
376    /// that IS this plan's context window ("each plan is a conversational
377    /// context"). `None` for Roadmap/Phase grouping nodes, Task leaves, and
378    /// every legacy subtask — a soft pointer, like [`Subtask::parent`].
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub conversation_id: Option<String>,
381    /// #1030: the objective work artifact an evaluator reads to close this node
382    /// (a branch/commit for a Task or Plan, a PR for a Phase). `None` until the
383    /// node is bound to real work. Serialized as a sub-table, so — like
384    /// [`caveat_policy`](Subtask::caveat_policy) — every scalar field precedes
385    /// it; it is placed before `caveat_policy` and both tables trail the scalars.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub artifact_ref: Option<ArtifactRef>,
388    /// The authority this subtask declares it needs. **Default-deny**: an
389    /// omitted policy denies every capability axis (see [`CaveatPolicy`]).
390    ///
391    /// Serialized **last** so every scalar field precedes this sub-table — TOML
392    /// requires values before tables within a `[[subtask]]` entry.
393    #[serde(default)]
394    pub caveat_policy: CaveatPolicy,
395}
396
397/// A leaf [`Subtask`] projected into the unit a `CrewRunner` dispatches — the
398/// concrete realization of *"a leaf is a CrewTask"*. Produced by
399/// [`Subtask::to_crew_task`]; the runner adds placement (a `workspace_ref`) when
400/// it actually spawns the work, so it isn't carried here. No `id`/`deps`/`status`
401/// either — those are the plan's bookkeeping, not the child's concern.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct CrewTask {
404    /// What the child agent is asked to do (the subtask's instruction).
405    pub goal: String,
406    /// The authority the child runs under — the parent's grant **met** with the
407    /// subtask's declared policy. `meet` is the greatest lower bound, so this can
408    /// only *narrow* the parent (attenuation, never amplify): a model-proposed
409    /// plan can never widen the grant it was handed.
410    pub caveats: Caveats,
411    /// The leaf's file scope — its write lane (#812; see
412    /// [`Subtask::context`]). Forwarded as `args["scope"]` and intersected
413    /// into the effective writable set at apply; empty = unfenced.
414    pub context: Vec<String>,
415    /// The optional verify command that gates this task — forwarded to the crew
416    /// op so the child's work is checked before it is accepted (the #332
417    /// per-subtask gate). `None` = no per-task check.
418    pub verify: Option<String>,
419}
420
421impl Subtask {
422    /// #1030: a tree node with the given `id`, `instruction`, [`NodeKind`], and
423    /// optional `parent`, everything else defaulted — `Pending`, no deps/verify/
424    /// result/artifact/conversation, and the default-deny [`CaveatPolicy`]. The
425    /// convenience constructor for authoring a roadmap tree node by node.
426    #[must_use]
427    pub fn node(
428        id: impl Into<String>,
429        instruction: impl Into<String>,
430        kind: NodeKind,
431        parent: Option<String>,
432    ) -> Self {
433        Self {
434            id: id.into(),
435            instruction: instruction.into(),
436            deps: Vec::new(),
437            parallel_ok: false,
438            context: Vec::new(),
439            verify: None,
440            status: SubtaskStatus::default(),
441            result: None,
442            parent,
443            kind,
444            conversation_id: None,
445            artifact_ref: None,
446            caveat_policy: CaveatPolicy::default(),
447        }
448    }
449
450    /// Project this subtask into the [`CrewTask`] the active topology's
451    /// `CrewRunner` dispatches — the *same* projection for `/mode
452    /// single|crew|mesh|remote`, so a plan authored once lifts across runners
453    /// unchanged. `caveats = parent.meet(self.caveat_policy.to_caveats())`: the
454    /// plan *requests*, the parent *grants*, `meet` *enforces ⊑* (attenuation
455    /// only). Intended for a **leaf** (see [`Plan::leaves`]); a branch is a
456    /// grouping node, not a dispatch unit.
457    #[must_use]
458    pub fn to_crew_task(&self, parent: &Caveats) -> CrewTask {
459        CrewTask {
460            goal: self.instruction.clone(),
461            caveats: parent.meet(&self.caveat_policy.to_caveats()),
462            context: self.context.clone(),
463            verify: self.verify.clone(),
464        }
465    }
466}
467
468/// How child results combine back into the plan.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
470#[serde(rename_all = "lowercase")]
471pub enum Aggregation {
472    /// Concatenate child outputs in subtask order (the default).
473    #[default]
474    Concat,
475    /// Keep only the last completed subtask's output.
476    LastWins,
477    /// Fold children through a reducer (semantics resolved by the scheduler).
478    Reduce,
479    /// A caller-defined strategy, resolved by the scheduler.
480    Custom,
481}
482
483/// Per-subtask execution status; the plan file doubles as a run-log.
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
485#[serde(rename_all = "lowercase")]
486pub enum SubtaskStatus {
487    /// Not yet started (the default).
488    #[default]
489    Pending,
490    /// Currently executing.
491    Running,
492    /// Completed successfully.
493    Done,
494    /// Failed (verify gate or execution error).
495    Failed,
496}
497
498/// #1030 "Plans within Plans": the role of a [`Subtask`] node in a
499/// Roadmap→Phase→Plan→Task tree.
500///
501/// - `Roadmap` / `Phase` are lightweight grouping nodes (no bound conversation,
502///   no turns) — the outline a tiny model never has to hold all at once.
503/// - `Plan` is the pivot: it binds a whole `conversations` row via
504///   [`Subtask::conversation_id`] — that row IS the model's bounded context
505///   window ("each plan is a conversational context").
506/// - `Task` is a commit-granular leaf; git is the source of truth, read from
507///   [`Subtask::artifact_ref`].
508///
509/// Defaults to `Task` so a legacy flat plan (every subtask a work unit)
510/// deserializes unchanged — `kind` is additive working metadata the existing
511/// leaf/crew semantics (`status`/`parent`/`deps`) never read.
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
513#[serde(rename_all = "lowercase")]
514pub enum NodeKind {
515    /// The tree root — the whole body of work. Done when every child Phase is
516    /// merged to main and the pipelines are green.
517    Roadmap,
518    /// A group of Plans that lands as one PR. Done when every child Plan is
519    /// complete and the PR is merged to main.
520    Phase,
521    /// One conversational context (a `conversations` row). Done when every
522    /// child Task is on the branch and the branch's tests pass.
523    Plan,
524    /// A commit-granular leaf. Done when its commit is on the branch and its
525    /// [`verify`](Subtask::verify) gate passes. The default kind.
526    #[default]
527    Task,
528}
529
530/// #1030: the objective work artifact an evaluator resolves to decide whether a
531/// node is done — never the model's self-reported "done". Every field is
532/// optional and skipped when empty; the whole ref is `None` on an unbound node.
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
534#[serde(deny_unknown_fields)]
535pub struct ArtifactRef {
536    /// The branch a Task's commit / a Plan's tasks land on.
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub branch: Option<String>,
539    /// The commit that realizes a Task (its "done" is this commit on `branch`
540    /// with the [`verify`](Subtask::verify) gate passing).
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub commit: Option<String>,
543    /// The pull-request number a Phase merges (its "done" is this PR merged to
544    /// main).
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub pr: Option<u64>,
547    /// The forge issue this node realizes (#1083). When set, the objective
548    /// evaluator additionally requires the issue to be CLOSED before the node
549    /// may be Done — a verdict input, never a direct Done.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub issue: Option<u64>,
552}
553
554/// The authority a [`Subtask`] declares it needs — **default-deny**.
555///
556/// Reuses the same human-friendly axis vocabulary as
557/// [`crate::role_profile::CaveatProfile`] ([`ScopeSpec`] per axis), but with the
558/// **opposite default**: where a role profile omits an axis to mean
559/// *unrestricted* (top of the axis, matching `Caveats::top()`), a plan omits an
560/// axis to mean *denied* (`none`). A model-proposed plan must not gain authority
561/// by leaving a field out.
562///
563/// The `Default` is fully denied; [`to_caveats`](CaveatPolicy::to_caveats)
564/// lowers the declared policy into the canonical [`Caveats`] lattice element the
565/// scheduler attenuates the parent key against. Because attenuation takes the
566/// *meet* with the parent, even an unspecified count bound (`None` → `Unlimited`
567/// request) is clamped down to the parent's finite bound — the request can never
568/// widen the grant.
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570#[serde(deny_unknown_fields)]
571pub struct CaveatPolicy {
572    /// Filesystem read scope (default: `none`).
573    #[serde(default = "denied_axis")]
574    pub fs_read: ScopeSpec,
575    /// Filesystem write scope (default: `none`).
576    #[serde(default = "denied_axis")]
577    pub fs_write: ScopeSpec,
578    /// Command-execution scope (default: `none`).
579    #[serde(default = "denied_axis")]
580    pub exec: ScopeSpec,
581    /// Network scope (default: `none`).
582    #[serde(default = "denied_axis")]
583    pub net: ScopeSpec,
584    /// Tool-call ceiling. `None` = unspecified; the scheduler clamps it to the
585    /// parent's bound (it can never widen it).
586    #[serde(default)]
587    pub max_calls: Option<u64>,
588}
589
590/// A single denied axis (`Scope::none`) — the opposite of
591/// [`ScopeSpec::default`] (which is `all`). The deny default is what makes a
592/// model-proposed plan safe by omission.
593fn denied_axis() -> ScopeSpec {
594    ScopeSpec::Keyword(ScopeKeyword::None)
595}
596
597impl Default for CaveatPolicy {
598    fn default() -> Self {
599        Self {
600            fs_read: denied_axis(),
601            fs_write: denied_axis(),
602            exec: denied_axis(),
603            net: denied_axis(),
604            max_calls: None,
605        }
606    }
607}
608
609impl CaveatPolicy {
610    /// Lower this declared policy into the canonical [`Caveats`] lattice element
611    /// the scheduler attenuates the parent key against. Mirrors
612    /// `CaveatProfile::to_caveats`, but inherits this type's deny defaults.
613    #[must_use]
614    pub fn to_caveats(&self) -> Caveats {
615        Caveats {
616            fs_read: self.fs_read.to_scope(),
617            fs_write: self.fs_write.to_scope(),
618            exec: self.exec.to_scope(),
619            net: self.net.to_scope(),
620            max_calls: match self.max_calls {
621                Some(n) => CountBound::AtMost(n),
622                None => CountBound::Unlimited,
623            },
624            valid_for_generation: Scope::All,
625        }
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    const DENIED: ScopeSpec = ScopeSpec::Keyword(ScopeKeyword::None);
634
635    #[test]
636    fn bare_subtask_fragment_parses_without_a_goal() {
637        // Fragment-validity: a slice handed to `wyvern --plan` must parse alone.
638        let toml = r#"
639[[subtask]]
640id = "s1"
641instruction = "do the first thing"
642
643[[subtask]]
644id = "s2"
645instruction = "do the second thing"
646deps = ["s1"]
647"#;
648        let plan = Plan::from_toml_str(toml).unwrap();
649        assert!(plan.goal.is_none());
650        assert_eq!(plan.subtasks.len(), 2);
651        assert_eq!(plan.subtasks[1].deps, vec!["s1".to_string()]);
652    }
653
654    #[test]
655    fn omitted_caveat_policy_denies_every_axis() {
656        // The load-bearing safety property: no policy => no authority.
657        let toml = r#"
658[[subtask]]
659id = "s1"
660instruction = "untrusted, model-proposed"
661"#;
662        let plan = Plan::from_toml_str(toml).unwrap();
663        let pol = &plan.subtasks[0].caveat_policy;
664        assert_eq!(pol.fs_read, DENIED);
665        assert_eq!(pol.fs_write, DENIED);
666        assert_eq!(pol.exec, DENIED);
667        assert_eq!(pol.net, DENIED);
668        // And the struct default agrees with deserialized-absence.
669        assert_eq!(*pol, CaveatPolicy::default());
670    }
671
672    #[test]
673    fn default_policy_lowers_to_a_fully_denied_caveats() {
674        // Default-deny must hold at the lattice level too: every capability
675        // scope is `none`, never `Scope::All` (top). Compared via to_scope so
676        // the assertion does not depend on Caveats' own equality impl.
677        let cav = CaveatPolicy::default().to_caveats();
678        assert_eq!(cav.fs_read, Scope::none());
679        assert_eq!(cav.fs_write, Scope::none());
680        assert_eq!(cav.exec, Scope::none());
681        assert_eq!(cav.net, Scope::none());
682    }
683
684    #[test]
685    fn explicit_policy_is_honored_and_lowers_correctly() {
686        let toml = r#"
687[[subtask]]
688id = "s1"
689instruction = "scoped"
690
691[subtask.caveat_policy]
692fs_read = "all"
693fs_write = ["src/", "Cargo.toml"]
694exec = ["cargo"]
695net = "none"
696max_calls = 40
697"#;
698        let plan = Plan::from_toml_str(toml).unwrap();
699        let cav = plan.subtasks[0].caveat_policy.to_caveats();
700        assert_eq!(cav.fs_read, Scope::All);
701        assert_eq!(
702            cav.fs_write,
703            Scope::only(["src/".to_string(), "Cargo.toml".to_string()])
704        );
705        assert_eq!(cav.exec, Scope::only(["cargo".to_string()]));
706        assert_eq!(cav.net, Scope::none());
707        assert_eq!(cav.max_calls, CountBound::AtMost(40));
708    }
709
710    #[test]
711    fn status_defaults_to_pending() {
712        let toml = r#"
713[[subtask]]
714id = "s1"
715instruction = "x"
716"#;
717        let plan = Plan::from_toml_str(toml).unwrap();
718        assert_eq!(plan.subtasks[0].status, SubtaskStatus::Pending);
719        assert!(plan.subtasks[0].result.is_none());
720    }
721
722    #[test]
723    fn plan_round_trips_through_toml() {
724        let plan = Plan {
725            goal: Some("ship the thing".to_string()),
726            aggregation: Aggregation::Concat,
727            subtasks: vec![Subtask {
728                id: "s1".to_string(),
729                instruction: "write the module".to_string(),
730                deps: vec![],
731                parallel_ok: true,
732                context: vec!["src/lib.rs".to_string()],
733                verify: Some("cargo test -p x".to_string()),
734                caveat_policy: CaveatPolicy {
735                    fs_write: ScopeSpec::Items(vec!["src/".to_string()]),
736                    ..CaveatPolicy::default()
737                },
738                status: SubtaskStatus::Done,
739                result: Some("done".to_string()),
740                parent: Some("epic".to_string()),
741                kind: NodeKind::Task,
742                conversation_id: None,
743                artifact_ref: None,
744            }],
745        };
746        let text = plan.to_toml_string().unwrap();
747        let back = Plan::from_toml_str(&text).unwrap();
748        assert_eq!(back, plan);
749    }
750
751    #[test]
752    fn full_plan_with_aggregation_parses() {
753        let toml = r#"
754goal = "refactor the parser"
755aggregation = "lastwins"
756
757[[subtask]]
758id = "s1"
759instruction = "x"
760status = "running"
761"#;
762        let plan = Plan::from_toml_str(toml).unwrap();
763        assert_eq!(plan.goal.as_deref(), Some("refactor the parser"));
764        assert_eq!(plan.aggregation, Aggregation::LastWins);
765        assert_eq!(plan.subtasks[0].status, SubtaskStatus::Running);
766    }
767
768    #[test]
769    fn unknown_field_is_rejected() {
770        // deny_unknown_fields: one canonical shape, no silent drift.
771        let toml = r#"
772[[subtask]]
773id = "s1"
774instruction = "x"
775bogus_field = "should fail"
776"#;
777        assert!(Plan::from_toml_str(toml).is_err());
778    }
779
780    #[test]
781    fn empty_input_is_an_empty_plan() {
782        let plan = Plan::from_toml_str("").unwrap();
783        assert!(plan.goal.is_none());
784        assert!(plan.subtasks.is_empty());
785        assert_eq!(plan.aggregation, Aggregation::Concat);
786    }
787
788    #[test]
789    fn parent_pointers_build_a_tree() {
790        // A root branch "epic" decomposes into two leaves; plus a top-level leaf.
791        let toml = r#"
792[[subtask]]
793id = "epic"
794instruction = "the big task"
795
796[[subtask]]
797id = "a"
798instruction = "sub-task a"
799parent = "epic"
800
801[[subtask]]
802id = "b"
803instruction = "sub-task b"
804parent = "epic"
805
806[[subtask]]
807id = "solo"
808instruction = "a top-level leaf"
809"#;
810        let plan = Plan::from_toml_str(toml).unwrap();
811        let ids = |v: Vec<&Subtask>| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
812        assert_eq!(ids(plan.roots()), vec!["epic", "solo"]);
813        assert_eq!(ids(plan.children("epic")), vec!["a", "b"]);
814        // epic is a branch (named as a parent) → not a leaf; a, b, solo are leaves.
815        assert_eq!(ids(plan.leaves()), vec!["a", "b", "solo"]);
816        assert_eq!(plan.subtask("a").unwrap().parent.as_deref(), Some("epic"));
817    }
818
819    #[test]
820    fn flat_plan_has_every_subtask_as_a_leaf() {
821        // Pre-tree behaviour preserved: no parents → every subtask is a leaf.
822        let plan = Plan::from_toml_str(
823            "[[subtask]]\nid=\"s1\"\ninstruction=\"x\"\n[[subtask]]\nid=\"s2\"\ninstruction=\"y\"\n",
824        )
825        .unwrap();
826        assert_eq!(plan.leaves().len(), 2);
827        assert_eq!(plan.roots().len(), 2);
828    }
829
830    #[test]
831    fn next_ready_leaf_is_the_execution_cursor() {
832        // a Done; b Pending with dep a (Done) → b is the ready leaf. c is
833        // dep-blocked (b not Done); epic is a branch, never a dispatch unit.
834        let toml = r#"
835[[subtask]]
836id = "epic"
837instruction = "branch"
838
839[[subtask]]
840id = "a"
841instruction = "first leaf"
842parent = "epic"
843status = "done"
844
845[[subtask]]
846id = "b"
847instruction = "second leaf"
848parent = "epic"
849deps = ["a"]
850
851[[subtask]]
852id = "c"
853instruction = "third leaf"
854parent = "epic"
855deps = ["b"]
856"#;
857        let plan = Plan::from_toml_str(toml).unwrap();
858        assert_eq!(plan.next_ready_leaf().expect("b ready").id, "b");
859    }
860
861    #[test]
862    fn next_ready_leaf_none_when_all_done_or_blocked() {
863        // a Done, b blocked on an absent dep → no ready leaf.
864        let toml = r#"
865[[subtask]]
866id = "a"
867instruction = "done"
868status = "done"
869
870[[subtask]]
871id = "b"
872instruction = "blocked"
873deps = ["never_exists"]
874"#;
875        let plan = Plan::from_toml_str(toml).unwrap();
876        assert!(plan.next_ready_leaf().is_none());
877    }
878
879    #[test]
880    fn parent_defaults_none_and_fragment_stays_valid() {
881        // Fragment-validity: a bare subtask whose parent lives outside the slice
882        // still parses (the pointer is soft); an omitted parent defaults to None.
883        let frag = Plan::from_toml_str(
884            "[[subtask]]\nid=\"leaf\"\ninstruction=\"x\"\nparent=\"outside_the_slice\"\n",
885        )
886        .unwrap();
887        assert_eq!(
888            frag.subtasks[0].parent.as_deref(),
889            Some("outside_the_slice")
890        );
891        let root = Plan::from_toml_str("[[subtask]]\nid=\"r\"\ninstruction=\"y\"\n").unwrap();
892        assert!(root.subtasks[0].parent.is_none());
893        assert_eq!(root.roots().len(), 1);
894    }
895
896    #[test]
897    fn to_crew_task_projects_goal_context_and_attenuated_caveats() {
898        // A leaf declaring fs_write=["src/"] only; the parent grants everything.
899        let toml = r#"
900[[subtask]]
901id = "leaf"
902instruction = "write the module"
903context = ["src/lib.rs"]
904
905[subtask.caveat_policy]
906fs_write = ["src/"]
907"#;
908        let plan = Plan::from_toml_str(toml).unwrap();
909        let task = plan.subtasks[0].to_crew_task(&Caveats::top());
910        assert_eq!(task.goal, "write the module");
911        assert_eq!(task.context, vec!["src/lib.rs".to_string()]);
912        // The child gets exactly what it declared (parent=top allows all):
913        assert_eq!(task.caveats.fs_write, Scope::only(["src/".to_string()]));
914        // Everything else stays DENIED (default-deny leaf) — never widened to top.
915        assert_eq!(task.caveats.fs_read, Scope::none());
916        assert_eq!(task.caveats.exec, Scope::none());
917        assert_eq!(task.caveats.net, Scope::none());
918    }
919
920    #[test]
921    fn to_crew_task_never_widens_past_the_parent() {
922        // The leaf REQUESTS fs_read=all, but the parent only GRANTS fs_read=["a/"];
923        // meet clamps the request to the grant — attenuation, never amplify.
924        let toml = r#"
925[[subtask]]
926id = "leaf"
927instruction = "x"
928
929[subtask.caveat_policy]
930fs_read = "all"
931"#;
932        let plan = Plan::from_toml_str(toml).unwrap();
933        let parent = Caveats {
934            fs_read: Scope::only(["a/".to_string()]),
935            ..Caveats::top()
936        };
937        let task = plan.subtasks[0].to_crew_task(&parent);
938        assert_eq!(task.caveats.fs_read, Scope::only(["a/".to_string()]));
939    }
940
941    #[test]
942    fn plan_is_a_drivable_execution_state_machine() {
943        // An overseer-authored DAG: a → b(deps a) → c(deps b), all under "epic".
944        let toml = r#"
945[[subtask]]
946id = "epic"
947instruction = "branch"
948
949[[subtask]]
950id = "a"
951instruction = "step a"
952parent = "epic"
953
954[[subtask]]
955id = "b"
956instruction = "step b"
957parent = "epic"
958deps = ["a"]
959
960[[subtask]]
961id = "c"
962instruction = "step c"
963parent = "epic"
964deps = ["b"]
965"#;
966        let mut plan = Plan::from_toml_str(toml).unwrap();
967        let top = Caveats::top();
968        // The drive loop a real executor runs: dispatch the next ready leaf,
969        // mark it Done, repeat. (Here the "dispatch" is a no-op; a CrewRunner
970        // would run inference. The state machine is what's under test.)
971        let mut order = Vec::new();
972        while let Some((id, task)) = plan.next_dispatch(&top) {
973            assert!(!task.goal.is_empty());
974            plan.mark(&id, SubtaskStatus::Running, None);
975            plan.mark(&id, SubtaskStatus::Done, Some(format!("ran {id}")));
976            order.push(id);
977        }
978        // Walked a → b → c in dependency order; epic (a branch) never dispatched.
979        assert_eq!(order, vec!["a", "b", "c"]);
980        assert!(plan.is_complete());
981        assert_eq!(plan.subtask("a").unwrap().result.as_deref(), Some("ran a"));
982    }
983
984    #[test]
985    fn a_failed_leaf_blocks_its_dependents_and_stops_the_run() {
986        let toml = r#"
987[[subtask]]
988id = "a"
989instruction = "x"
990
991[[subtask]]
992id = "b"
993instruction = "y"
994deps = ["a"]
995"#;
996        let mut plan = Plan::from_toml_str(toml).unwrap();
997        let top = Caveats::top();
998        let (id, _task) = plan.next_dispatch(&top).expect("a is ready");
999        assert_eq!(id, "a");
1000        plan.mark(&id, SubtaskStatus::Failed, Some("boom".into()));
1001        // b deps on a (now Failed, not Done) → not ready → nothing to dispatch,
1002        // so the run stops honestly at the first failure (no separate stop flag).
1003        assert!(plan.next_dispatch(&top).is_none());
1004        assert!(!plan.is_complete());
1005    }
1006
1007    #[test]
1008    fn mark_is_a_noop_for_an_absent_id_and_empty_plan_is_complete() {
1009        let mut plan = Plan::from_toml_str("[[subtask]]\nid=\"a\"\ninstruction=\"x\"\n").unwrap();
1010        plan.mark("nope", SubtaskStatus::Done, Some("ignored".into()));
1011        assert_eq!(plan.subtask("a").unwrap().status, SubtaskStatus::Pending);
1012        assert!(Plan::from_toml_str("").unwrap().is_complete());
1013    }
1014
1015    // ── #1030 "Plans within Plans": tree node kind, artifact ref, traversal ──
1016
1017    #[test]
1018    fn node_kind_defaults_to_task_on_a_legacy_plan() {
1019        // Adding `kind` must not perturb a pre-#1030 flat plan: every subtask
1020        // that omits `kind` deserializes as a Task work unit, so the existing
1021        // crew/leaf semantics are byte-identical.
1022        let toml = r#"
1023[[subtask]]
1024id = "a"
1025instruction = "x"
1026
1027[[subtask]]
1028id = "b"
1029instruction = "y"
1030deps = ["a"]
1031"#;
1032        let plan = Plan::from_toml_str(toml).unwrap();
1033        assert!(plan.subtasks.iter().all(|s| s.kind == NodeKind::Task));
1034        assert_eq!(NodeKind::default(), NodeKind::Task);
1035    }
1036
1037    #[test]
1038    fn node_kind_and_artifact_ref_round_trip_through_toml() {
1039        // A Plan node bound to a conversation and a Task node bound to a commit
1040        // survive a TOML round-trip unchanged (the roadmaps table will persist
1041        // exactly this serialized shape).
1042        let toml = r#"
1043[[subtask]]
1044id = "plan-1"
1045instruction = "implement the parser"
1046kind = "plan"
1047conversation_id = "1720000000000-abc"
1048
1049[[subtask]]
1050id = "task-1"
1051instruction = "commit the AST"
1052kind = "task"
1053parent = "plan-1"
1054
1055[subtask.artifact_ref]
1056branch = "feat/parser"
1057commit = "deadbeef"
1058"#;
1059        let plan = Plan::from_toml_str(toml).unwrap();
1060        let p = plan.subtask("plan-1").unwrap();
1061        assert_eq!(p.kind, NodeKind::Plan);
1062        assert_eq!(p.conversation_id.as_deref(), Some("1720000000000-abc"));
1063        let t = plan.subtask("task-1").unwrap();
1064        assert_eq!(t.kind, NodeKind::Task);
1065        assert_eq!(
1066            t.artifact_ref.as_ref().unwrap().commit.as_deref(),
1067            Some("deadbeef")
1068        );
1069        // Round-trip: scalars (incl. `kind`, `conversation_id`) precede both the
1070        // `artifact_ref` and `caveat_policy` sub-tables, so TOML re-serializes.
1071        let back = Plan::from_toml_str(&plan.to_toml_string().unwrap()).unwrap();
1072        assert_eq!(back, plan);
1073    }
1074
1075    #[test]
1076    fn next_ready_node_generalizes_the_cursor_to_branches() {
1077        // The cursor now also visits interior nodes: a Phase branch becomes
1078        // "ready" (for its evaluator) only once its child Tasks finish, then the
1079        // traversal returns up to it. Leaves still lead.
1080        let toml = r#"
1081[[subtask]]
1082id = "phase-1"
1083instruction = "phase"
1084kind = "phase"
1085
1086[[subtask]]
1087id = "t1"
1088instruction = "task 1"
1089kind = "task"
1090parent = "phase-1"
1091
1092[[subtask]]
1093id = "t2"
1094instruction = "task 2"
1095kind = "task"
1096parent = "phase-1"
1097deps = ["t1"]
1098"#;
1099        let mut plan = Plan::from_toml_str(toml).unwrap();
1100        assert_eq!(plan.next_ready_node().map(|s| s.id.as_str()), Some("t1"));
1101        plan.mark("t1", SubtaskStatus::Done, None);
1102        assert_eq!(plan.next_ready_node().map(|s| s.id.as_str()), Some("t2"));
1103        plan.mark("t2", SubtaskStatus::Done, None);
1104        // Children all Done → the branch is now the cursor (evaluate + fold up).
1105        assert_eq!(
1106            plan.next_ready_node().map(|s| s.id.as_str()),
1107            Some("phase-1")
1108        );
1109        plan.mark("phase-1", SubtaskStatus::Done, None);
1110        assert_eq!(plan.next_ready_node(), None);
1111    }
1112
1113    #[test]
1114    fn subtree_complete_tracks_the_whole_subtree() {
1115        let toml = r#"
1116[[subtask]]
1117id = "p"
1118instruction = "parent"
1119kind = "phase"
1120
1121[[subtask]]
1122id = "c1"
1123instruction = "child 1"
1124parent = "p"
1125
1126[[subtask]]
1127id = "c2"
1128instruction = "child 2"
1129parent = "p"
1130"#;
1131        let mut plan = Plan::from_toml_str(toml).unwrap();
1132        assert!(!plan.subtree_complete("p"));
1133        assert!(!plan.subtree_complete("absent"));
1134        plan.mark("c1", SubtaskStatus::Done, None);
1135        plan.mark("c2", SubtaskStatus::Done, None);
1136        // Children done, but the branch itself is not yet marked → incomplete.
1137        assert!(!plan.subtree_complete("p"));
1138        plan.mark("p", SubtaskStatus::Done, None);
1139        assert!(plan.subtree_complete("p"));
1140        assert!(plan.subtree_complete("c1"));
1141    }
1142
1143    #[test]
1144    fn path_to_returns_the_root_to_node_breadcrumb() {
1145        let toml = r#"
1146[[subtask]]
1147id = "road"
1148instruction = "roadmap"
1149kind = "roadmap"
1150
1151[[subtask]]
1152id = "ph"
1153instruction = "phase"
1154kind = "phase"
1155parent = "road"
1156
1157[[subtask]]
1158id = "pl"
1159instruction = "plan"
1160kind = "plan"
1161parent = "ph"
1162"#;
1163        let plan = Plan::from_toml_str(toml).unwrap();
1164        assert_eq!(plan.path_to("pl"), ["road", "ph", "pl"]);
1165        assert_eq!(plan.path_to("road"), ["road"]);
1166        assert!(plan.path_to("absent").is_empty());
1167    }
1168
1169    #[test]
1170    fn nodes_of_kind_filters_by_kind() {
1171        let toml = r#"
1172[[subtask]]
1173id = "road"
1174instruction = "roadmap"
1175kind = "roadmap"
1176
1177[[subtask]]
1178id = "pl1"
1179instruction = "plan 1"
1180kind = "plan"
1181parent = "road"
1182
1183[[subtask]]
1184id = "pl2"
1185instruction = "plan 2"
1186kind = "plan"
1187parent = "road"
1188"#;
1189        let plan = Plan::from_toml_str(toml).unwrap();
1190        let plans: Vec<_> = plan
1191            .nodes_of_kind(NodeKind::Plan)
1192            .iter()
1193            .map(|s| s.id.clone())
1194            .collect();
1195        assert_eq!(plans, ["pl1", "pl2"]);
1196        assert_eq!(plan.nodes_of_kind(NodeKind::Roadmap).len(), 1);
1197        assert!(plan.nodes_of_kind(NodeKind::Task).is_empty());
1198    }
1199
1200    #[test]
1201    fn set_artifact_commit_binds_the_commit_preserving_pr_and_branch() {
1202        // #1062: binding a Task's commit is what lets the objective evaluator
1203        // close it from git truth. It must preserve any existing pr, keep the
1204        // branch when none is given, create a fresh ref on a bare node, and no-op
1205        // an absent id.
1206        let toml = r#"
1207[[subtask]]
1208id = "t1"
1209instruction = "task 1"
1210kind = "task"
1211
1212[subtask.artifact_ref]
1213pr = 42
1214branch = "feat/x"
1215
1216[[subtask]]
1217id = "t2"
1218instruction = "task 2"
1219kind = "task"
1220"#;
1221        let mut plan = Plan::from_toml_str(toml).unwrap();
1222
1223        // No explicit branch → existing branch kept, pr preserved.
1224        plan.set_artifact_commit("t1", "b56fefadeadbeef", None);
1225        let a = plan.subtask("t1").unwrap().artifact_ref.as_ref().unwrap();
1226        assert_eq!(a.commit.as_deref(), Some("b56fefadeadbeef"));
1227        assert_eq!(a.branch.as_deref(), Some("feat/x"), "existing branch kept");
1228        assert_eq!(a.pr, Some(42), "existing pr preserved");
1229
1230        // Explicit branch overrides; pr still preserved.
1231        plan.set_artifact_commit("t1", "cafef00d", Some("main"));
1232        let a = plan.subtask("t1").unwrap().artifact_ref.as_ref().unwrap();
1233        assert_eq!(a.commit.as_deref(), Some("cafef00d"));
1234        assert_eq!(a.branch.as_deref(), Some("main"));
1235        assert_eq!(a.pr, Some(42));
1236
1237        // A bare node gets a fresh ref (just the commit + branch).
1238        plan.set_artifact_commit("t2", "abc123", Some("main"));
1239        let a = plan.subtask("t2").unwrap().artifact_ref.as_ref().unwrap();
1240        assert_eq!(a.commit.as_deref(), Some("abc123"));
1241        assert_eq!(a.branch.as_deref(), Some("main"));
1242        assert_eq!(a.pr, None);
1243
1244        // Absent id is a no-op (no panic, no phantom node).
1245        plan.set_artifact_commit("nope", "x", None);
1246        assert!(plan.subtask("nope").is_none());
1247    }
1248
1249    #[test]
1250    fn set_artifact_issue_binds_the_issue_preserving_other_refs() {
1251        // #1083: an issue ref is an ADDITIONAL evaluator gate; binding it must
1252        // not disturb branch/commit/pr, must round-trip through TOML, and must
1253        // no-op an absent id.
1254        let toml = r#"
1255[[subtask]]
1256id = "t1"
1257instruction = "task 1"
1258kind = "task"
1259
1260[subtask.artifact_ref]
1261pr = 42
1262branch = "feat/x"
1263"#;
1264        let mut plan = Plan::from_toml_str(toml).unwrap();
1265        plan.set_artifact_issue("t1", 1083);
1266        let a = plan.subtask("t1").unwrap().artifact_ref.as_ref().unwrap();
1267        assert_eq!(a.issue, Some(1083));
1268        assert_eq!(a.pr, Some(42), "existing pr preserved");
1269        assert_eq!(a.branch.as_deref(), Some("feat/x"), "branch preserved");
1270
1271        // Round-trips through TOML (and old files without the field parse:
1272        // this very fixture had none).
1273        let text = plan.to_toml_string().unwrap();
1274        assert!(text.contains("issue = 1083"), "{text}");
1275        let back = Plan::from_toml_str(&text).unwrap();
1276        assert_eq!(
1277            back.subtask("t1")
1278                .unwrap()
1279                .artifact_ref
1280                .as_ref()
1281                .unwrap()
1282                .issue,
1283            Some(1083)
1284        );
1285
1286        // Absent id is a no-op.
1287        plan.set_artifact_issue("nope", 1);
1288        assert!(plan.subtask("nope").is_none());
1289    }
1290
1291    #[test]
1292    fn next_uncaptured_task_under_walks_the_plan_in_order() {
1293        // #1062: auto-capture picks the leftmost Pending Task under the Plan whose
1294        // commit is unset — the 1-commit-→-1-Task cursor.
1295        let toml = r#"
1296[[subtask]]
1297id = "ph"
1298instruction = "phase"
1299kind = "phase"
1300
1301[[subtask]]
1302id = "pl"
1303instruction = "plan"
1304kind = "plan"
1305parent = "ph"
1306
1307[[subtask]]
1308id = "t1"
1309instruction = "task 1"
1310kind = "task"
1311parent = "pl"
1312
1313[[subtask]]
1314id = "t2"
1315instruction = "task 2"
1316kind = "task"
1317parent = "pl"
1318"#;
1319        let mut plan = Plan::from_toml_str(toml).unwrap();
1320        let id = |o: Option<&Subtask>| o.map(|s| s.id.clone());
1321        assert_eq!(id(plan.next_uncaptured_task_under("pl")), Some("t1".into()));
1322        // Capturing t1 advances the cursor to t2.
1323        plan.set_artifact_commit("t1", "abc", None);
1324        assert_eq!(id(plan.next_uncaptured_task_under("pl")), Some("t2".into()));
1325        // Both captured → nothing left.
1326        plan.set_artifact_commit("t2", "def", None);
1327        assert_eq!(plan.next_uncaptured_task_under("pl"), None);
1328        // A non-Plan target yields None (a phase or a task is not a Plan).
1329        assert!(plan.next_uncaptured_task_under("ph").is_none());
1330        assert!(plan.next_uncaptured_task_under("t1").is_none());
1331        assert!(plan.next_uncaptured_task_under("absent").is_none());
1332    }
1333}