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    /// Every leaf is `Done` — the plan finished successfully (branches are
188    /// grouping nodes, so only leaf completion is load-bearing). An empty plan is
189    /// trivially complete.
190    #[must_use]
191    pub fn is_complete(&self) -> bool {
192        self.leaves()
193            .iter()
194            .all(|s| s.status == SubtaskStatus::Done)
195    }
196}
197
198/// One unit of work in a [`Plan`] — the serialized form of a single scheduler
199/// dispatch. A fragment-valid `[[subtask]]` table is exactly this.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct Subtask {
203    /// Stable identifier, referenced by other subtasks' [`Subtask::deps`].
204    pub id: String,
205    /// What the child agent is asked to do.
206    pub instruction: String,
207    /// Ids of subtasks that must complete before this one may start.
208    #[serde(default)]
209    pub deps: Vec<String>,
210    /// May this subtask run concurrently with its ready siblings?
211    #[serde(default)]
212    pub parallel_ok: bool,
213    /// The leaf's FILE SCOPE — its write lane (#812), not reading material.
214    /// At dispatch this is forwarded as `args["scope"]` and intersected into
215    /// the effective writable set (worktree ∩ fs_write ∩ scope): a meet-only
216    /// convenience fence that can only narrow, never widen. Empty = unfenced
217    /// (pre-#812 behavior). Matching is exact-file or directory-prefix, with
218    /// `./` and trailing-`/` normalized; degenerate entries (`""`, `"."`)
219    /// fail open. Populated by the harness's own def-site grounding, with
220    /// model-declared `files` appended as untrusted augmentation.
221    #[serde(default)]
222    pub context: Vec<String>,
223    /// Optional verify command whose **enforced** result gates the subtask
224    /// (#332 S1). Absent = no per-subtask gate.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub verify: Option<String>,
227    /// Execution status — makes the plan file a resumable run-log.
228    #[serde(default)]
229    pub status: SubtaskStatus,
230    /// Where the child's output lands on completion (aggregation destination).
231    /// `None` until the subtask has run.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub result: Option<String>,
234    /// The id of the subtask this one decomposes — `None` for a root. This is how
235    /// a flat `[[subtask]]` list expresses a task→sub-task **tree** (exactly as
236    /// [`Subtask::deps`] expresses a DAG via id-pointers, not nesting). A subtask
237    /// that no other subtask names as its `parent` is a **leaf** — the unit that
238    /// dispatches/executes (a leaf *is* a `CrewTask`); a subtask that *is* named
239    /// is a **branch** (a grouping / aggregation node). Kept flat on purpose: a
240    /// nested `Vec<Subtask>` would break the fragment handoff (one leaf slice =
241    /// one dispatch). `None` is also fine in a fragment whose parent lives outside
242    /// the slice (the pointer is soft, like a cross-fragment `dep`).
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub parent: Option<String>,
245    /// The authority this subtask declares it needs. **Default-deny**: an
246    /// omitted policy denies every capability axis (see [`CaveatPolicy`]).
247    ///
248    /// Serialized **last** so every scalar field precedes this sub-table — TOML
249    /// requires values before tables within a `[[subtask]]` entry.
250    #[serde(default)]
251    pub caveat_policy: CaveatPolicy,
252}
253
254/// A leaf [`Subtask`] projected into the unit a `CrewRunner` dispatches — the
255/// concrete realization of *"a leaf is a CrewTask"*. Produced by
256/// [`Subtask::to_crew_task`]; the runner adds placement (a `workspace_ref`) when
257/// it actually spawns the work, so it isn't carried here. No `id`/`deps`/`status`
258/// either — those are the plan's bookkeeping, not the child's concern.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct CrewTask {
261    /// What the child agent is asked to do (the subtask's instruction).
262    pub goal: String,
263    /// The authority the child runs under — the parent's grant **met** with the
264    /// subtask's declared policy. `meet` is the greatest lower bound, so this can
265    /// only *narrow* the parent (attenuation, never amplify): a model-proposed
266    /// plan can never widen the grant it was handed.
267    pub caveats: Caveats,
268    /// The leaf's file scope — its write lane (#812; see
269    /// [`Subtask::context`]). Forwarded as `args["scope"]` and intersected
270    /// into the effective writable set at apply; empty = unfenced.
271    pub context: Vec<String>,
272    /// The optional verify command that gates this task — forwarded to the crew
273    /// op so the child's work is checked before it is accepted (the #332
274    /// per-subtask gate). `None` = no per-task check.
275    pub verify: Option<String>,
276}
277
278impl Subtask {
279    /// Project this subtask into the [`CrewTask`] the active topology's
280    /// `CrewRunner` dispatches — the *same* projection for `/mode
281    /// single|crew|mesh|remote`, so a plan authored once lifts across runners
282    /// unchanged. `caveats = parent.meet(self.caveat_policy.to_caveats())`: the
283    /// plan *requests*, the parent *grants*, `meet` *enforces ⊑* (attenuation
284    /// only). Intended for a **leaf** (see [`Plan::leaves`]); a branch is a
285    /// grouping node, not a dispatch unit.
286    #[must_use]
287    pub fn to_crew_task(&self, parent: &Caveats) -> CrewTask {
288        CrewTask {
289            goal: self.instruction.clone(),
290            caveats: parent.meet(&self.caveat_policy.to_caveats()),
291            context: self.context.clone(),
292            verify: self.verify.clone(),
293        }
294    }
295}
296
297/// How child results combine back into the plan.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
299#[serde(rename_all = "lowercase")]
300pub enum Aggregation {
301    /// Concatenate child outputs in subtask order (the default).
302    #[default]
303    Concat,
304    /// Keep only the last completed subtask's output.
305    LastWins,
306    /// Fold children through a reducer (semantics resolved by the scheduler).
307    Reduce,
308    /// A caller-defined strategy, resolved by the scheduler.
309    Custom,
310}
311
312/// Per-subtask execution status; the plan file doubles as a run-log.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
314#[serde(rename_all = "lowercase")]
315pub enum SubtaskStatus {
316    /// Not yet started (the default).
317    #[default]
318    Pending,
319    /// Currently executing.
320    Running,
321    /// Completed successfully.
322    Done,
323    /// Failed (verify gate or execution error).
324    Failed,
325}
326
327/// The authority a [`Subtask`] declares it needs — **default-deny**.
328///
329/// Reuses the same human-friendly axis vocabulary as
330/// [`crate::role_profile::CaveatProfile`] ([`ScopeSpec`] per axis), but with the
331/// **opposite default**: where a role profile omits an axis to mean
332/// *unrestricted* (top of the axis, matching `Caveats::top()`), a plan omits an
333/// axis to mean *denied* (`none`). A model-proposed plan must not gain authority
334/// by leaving a field out.
335///
336/// The `Default` is fully denied; [`to_caveats`](CaveatPolicy::to_caveats)
337/// lowers the declared policy into the canonical [`Caveats`] lattice element the
338/// scheduler attenuates the parent key against. Because attenuation takes the
339/// *meet* with the parent, even an unspecified count bound (`None` → `Unlimited`
340/// request) is clamped down to the parent's finite bound — the request can never
341/// widen the grant.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct CaveatPolicy {
345    /// Filesystem read scope (default: `none`).
346    #[serde(default = "denied_axis")]
347    pub fs_read: ScopeSpec,
348    /// Filesystem write scope (default: `none`).
349    #[serde(default = "denied_axis")]
350    pub fs_write: ScopeSpec,
351    /// Command-execution scope (default: `none`).
352    #[serde(default = "denied_axis")]
353    pub exec: ScopeSpec,
354    /// Network scope (default: `none`).
355    #[serde(default = "denied_axis")]
356    pub net: ScopeSpec,
357    /// Tool-call ceiling. `None` = unspecified; the scheduler clamps it to the
358    /// parent's bound (it can never widen it).
359    #[serde(default)]
360    pub max_calls: Option<u64>,
361}
362
363/// A single denied axis (`Scope::none`) — the opposite of
364/// [`ScopeSpec::default`] (which is `all`). The deny default is what makes a
365/// model-proposed plan safe by omission.
366fn denied_axis() -> ScopeSpec {
367    ScopeSpec::Keyword(ScopeKeyword::None)
368}
369
370impl Default for CaveatPolicy {
371    fn default() -> Self {
372        Self {
373            fs_read: denied_axis(),
374            fs_write: denied_axis(),
375            exec: denied_axis(),
376            net: denied_axis(),
377            max_calls: None,
378        }
379    }
380}
381
382impl CaveatPolicy {
383    /// Lower this declared policy into the canonical [`Caveats`] lattice element
384    /// the scheduler attenuates the parent key against. Mirrors
385    /// `CaveatProfile::to_caveats`, but inherits this type's deny defaults.
386    #[must_use]
387    pub fn to_caveats(&self) -> Caveats {
388        Caveats {
389            fs_read: self.fs_read.to_scope(),
390            fs_write: self.fs_write.to_scope(),
391            exec: self.exec.to_scope(),
392            net: self.net.to_scope(),
393            max_calls: match self.max_calls {
394                Some(n) => CountBound::AtMost(n),
395                None => CountBound::Unlimited,
396            },
397            valid_for_generation: Scope::All,
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    const DENIED: ScopeSpec = ScopeSpec::Keyword(ScopeKeyword::None);
407
408    #[test]
409    fn bare_subtask_fragment_parses_without_a_goal() {
410        // Fragment-validity: a slice handed to `wyvern --plan` must parse alone.
411        let toml = r#"
412[[subtask]]
413id = "s1"
414instruction = "do the first thing"
415
416[[subtask]]
417id = "s2"
418instruction = "do the second thing"
419deps = ["s1"]
420"#;
421        let plan = Plan::from_toml_str(toml).unwrap();
422        assert!(plan.goal.is_none());
423        assert_eq!(plan.subtasks.len(), 2);
424        assert_eq!(plan.subtasks[1].deps, vec!["s1".to_string()]);
425    }
426
427    #[test]
428    fn omitted_caveat_policy_denies_every_axis() {
429        // The load-bearing safety property: no policy => no authority.
430        let toml = r#"
431[[subtask]]
432id = "s1"
433instruction = "untrusted, model-proposed"
434"#;
435        let plan = Plan::from_toml_str(toml).unwrap();
436        let pol = &plan.subtasks[0].caveat_policy;
437        assert_eq!(pol.fs_read, DENIED);
438        assert_eq!(pol.fs_write, DENIED);
439        assert_eq!(pol.exec, DENIED);
440        assert_eq!(pol.net, DENIED);
441        // And the struct default agrees with deserialized-absence.
442        assert_eq!(*pol, CaveatPolicy::default());
443    }
444
445    #[test]
446    fn default_policy_lowers_to_a_fully_denied_caveats() {
447        // Default-deny must hold at the lattice level too: every capability
448        // scope is `none`, never `Scope::All` (top). Compared via to_scope so
449        // the assertion does not depend on Caveats' own equality impl.
450        let cav = CaveatPolicy::default().to_caveats();
451        assert_eq!(cav.fs_read, Scope::none());
452        assert_eq!(cav.fs_write, Scope::none());
453        assert_eq!(cav.exec, Scope::none());
454        assert_eq!(cav.net, Scope::none());
455    }
456
457    #[test]
458    fn explicit_policy_is_honored_and_lowers_correctly() {
459        let toml = r#"
460[[subtask]]
461id = "s1"
462instruction = "scoped"
463
464[subtask.caveat_policy]
465fs_read = "all"
466fs_write = ["src/", "Cargo.toml"]
467exec = ["cargo"]
468net = "none"
469max_calls = 40
470"#;
471        let plan = Plan::from_toml_str(toml).unwrap();
472        let cav = plan.subtasks[0].caveat_policy.to_caveats();
473        assert_eq!(cav.fs_read, Scope::All);
474        assert_eq!(
475            cav.fs_write,
476            Scope::only(["src/".to_string(), "Cargo.toml".to_string()])
477        );
478        assert_eq!(cav.exec, Scope::only(["cargo".to_string()]));
479        assert_eq!(cav.net, Scope::none());
480        assert_eq!(cav.max_calls, CountBound::AtMost(40));
481    }
482
483    #[test]
484    fn status_defaults_to_pending() {
485        let toml = r#"
486[[subtask]]
487id = "s1"
488instruction = "x"
489"#;
490        let plan = Plan::from_toml_str(toml).unwrap();
491        assert_eq!(plan.subtasks[0].status, SubtaskStatus::Pending);
492        assert!(plan.subtasks[0].result.is_none());
493    }
494
495    #[test]
496    fn plan_round_trips_through_toml() {
497        let plan = Plan {
498            goal: Some("ship the thing".to_string()),
499            aggregation: Aggregation::Concat,
500            subtasks: vec![Subtask {
501                id: "s1".to_string(),
502                instruction: "write the module".to_string(),
503                deps: vec![],
504                parallel_ok: true,
505                context: vec!["src/lib.rs".to_string()],
506                verify: Some("cargo test -p x".to_string()),
507                caveat_policy: CaveatPolicy {
508                    fs_write: ScopeSpec::Items(vec!["src/".to_string()]),
509                    ..CaveatPolicy::default()
510                },
511                status: SubtaskStatus::Done,
512                result: Some("done".to_string()),
513                parent: Some("epic".to_string()),
514            }],
515        };
516        let text = plan.to_toml_string().unwrap();
517        let back = Plan::from_toml_str(&text).unwrap();
518        assert_eq!(back, plan);
519    }
520
521    #[test]
522    fn full_plan_with_aggregation_parses() {
523        let toml = r#"
524goal = "refactor the parser"
525aggregation = "lastwins"
526
527[[subtask]]
528id = "s1"
529instruction = "x"
530status = "running"
531"#;
532        let plan = Plan::from_toml_str(toml).unwrap();
533        assert_eq!(plan.goal.as_deref(), Some("refactor the parser"));
534        assert_eq!(plan.aggregation, Aggregation::LastWins);
535        assert_eq!(plan.subtasks[0].status, SubtaskStatus::Running);
536    }
537
538    #[test]
539    fn unknown_field_is_rejected() {
540        // deny_unknown_fields: one canonical shape, no silent drift.
541        let toml = r#"
542[[subtask]]
543id = "s1"
544instruction = "x"
545bogus_field = "should fail"
546"#;
547        assert!(Plan::from_toml_str(toml).is_err());
548    }
549
550    #[test]
551    fn empty_input_is_an_empty_plan() {
552        let plan = Plan::from_toml_str("").unwrap();
553        assert!(plan.goal.is_none());
554        assert!(plan.subtasks.is_empty());
555        assert_eq!(plan.aggregation, Aggregation::Concat);
556    }
557
558    #[test]
559    fn parent_pointers_build_a_tree() {
560        // A root branch "epic" decomposes into two leaves; plus a top-level leaf.
561        let toml = r#"
562[[subtask]]
563id = "epic"
564instruction = "the big task"
565
566[[subtask]]
567id = "a"
568instruction = "sub-task a"
569parent = "epic"
570
571[[subtask]]
572id = "b"
573instruction = "sub-task b"
574parent = "epic"
575
576[[subtask]]
577id = "solo"
578instruction = "a top-level leaf"
579"#;
580        let plan = Plan::from_toml_str(toml).unwrap();
581        let ids = |v: Vec<&Subtask>| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
582        assert_eq!(ids(plan.roots()), vec!["epic", "solo"]);
583        assert_eq!(ids(plan.children("epic")), vec!["a", "b"]);
584        // epic is a branch (named as a parent) → not a leaf; a, b, solo are leaves.
585        assert_eq!(ids(plan.leaves()), vec!["a", "b", "solo"]);
586        assert_eq!(plan.subtask("a").unwrap().parent.as_deref(), Some("epic"));
587    }
588
589    #[test]
590    fn flat_plan_has_every_subtask_as_a_leaf() {
591        // Pre-tree behaviour preserved: no parents → every subtask is a leaf.
592        let plan = Plan::from_toml_str(
593            "[[subtask]]\nid=\"s1\"\ninstruction=\"x\"\n[[subtask]]\nid=\"s2\"\ninstruction=\"y\"\n",
594        )
595        .unwrap();
596        assert_eq!(plan.leaves().len(), 2);
597        assert_eq!(plan.roots().len(), 2);
598    }
599
600    #[test]
601    fn next_ready_leaf_is_the_execution_cursor() {
602        // a Done; b Pending with dep a (Done) → b is the ready leaf. c is
603        // dep-blocked (b not Done); epic is a branch, never a dispatch unit.
604        let toml = r#"
605[[subtask]]
606id = "epic"
607instruction = "branch"
608
609[[subtask]]
610id = "a"
611instruction = "first leaf"
612parent = "epic"
613status = "done"
614
615[[subtask]]
616id = "b"
617instruction = "second leaf"
618parent = "epic"
619deps = ["a"]
620
621[[subtask]]
622id = "c"
623instruction = "third leaf"
624parent = "epic"
625deps = ["b"]
626"#;
627        let plan = Plan::from_toml_str(toml).unwrap();
628        assert_eq!(plan.next_ready_leaf().expect("b ready").id, "b");
629    }
630
631    #[test]
632    fn next_ready_leaf_none_when_all_done_or_blocked() {
633        // a Done, b blocked on an absent dep → no ready leaf.
634        let toml = r#"
635[[subtask]]
636id = "a"
637instruction = "done"
638status = "done"
639
640[[subtask]]
641id = "b"
642instruction = "blocked"
643deps = ["never_exists"]
644"#;
645        let plan = Plan::from_toml_str(toml).unwrap();
646        assert!(plan.next_ready_leaf().is_none());
647    }
648
649    #[test]
650    fn parent_defaults_none_and_fragment_stays_valid() {
651        // Fragment-validity: a bare subtask whose parent lives outside the slice
652        // still parses (the pointer is soft); an omitted parent defaults to None.
653        let frag = Plan::from_toml_str(
654            "[[subtask]]\nid=\"leaf\"\ninstruction=\"x\"\nparent=\"outside_the_slice\"\n",
655        )
656        .unwrap();
657        assert_eq!(
658            frag.subtasks[0].parent.as_deref(),
659            Some("outside_the_slice")
660        );
661        let root = Plan::from_toml_str("[[subtask]]\nid=\"r\"\ninstruction=\"y\"\n").unwrap();
662        assert!(root.subtasks[0].parent.is_none());
663        assert_eq!(root.roots().len(), 1);
664    }
665
666    #[test]
667    fn to_crew_task_projects_goal_context_and_attenuated_caveats() {
668        // A leaf declaring fs_write=["src/"] only; the parent grants everything.
669        let toml = r#"
670[[subtask]]
671id = "leaf"
672instruction = "write the module"
673context = ["src/lib.rs"]
674
675[subtask.caveat_policy]
676fs_write = ["src/"]
677"#;
678        let plan = Plan::from_toml_str(toml).unwrap();
679        let task = plan.subtasks[0].to_crew_task(&Caveats::top());
680        assert_eq!(task.goal, "write the module");
681        assert_eq!(task.context, vec!["src/lib.rs".to_string()]);
682        // The child gets exactly what it declared (parent=top allows all):
683        assert_eq!(task.caveats.fs_write, Scope::only(["src/".to_string()]));
684        // Everything else stays DENIED (default-deny leaf) — never widened to top.
685        assert_eq!(task.caveats.fs_read, Scope::none());
686        assert_eq!(task.caveats.exec, Scope::none());
687        assert_eq!(task.caveats.net, Scope::none());
688    }
689
690    #[test]
691    fn to_crew_task_never_widens_past_the_parent() {
692        // The leaf REQUESTS fs_read=all, but the parent only GRANTS fs_read=["a/"];
693        // meet clamps the request to the grant — attenuation, never amplify.
694        let toml = r#"
695[[subtask]]
696id = "leaf"
697instruction = "x"
698
699[subtask.caveat_policy]
700fs_read = "all"
701"#;
702        let plan = Plan::from_toml_str(toml).unwrap();
703        let parent = Caveats {
704            fs_read: Scope::only(["a/".to_string()]),
705            ..Caveats::top()
706        };
707        let task = plan.subtasks[0].to_crew_task(&parent);
708        assert_eq!(task.caveats.fs_read, Scope::only(["a/".to_string()]));
709    }
710
711    #[test]
712    fn plan_is_a_drivable_execution_state_machine() {
713        // An overseer-authored DAG: a → b(deps a) → c(deps b), all under "epic".
714        let toml = r#"
715[[subtask]]
716id = "epic"
717instruction = "branch"
718
719[[subtask]]
720id = "a"
721instruction = "step a"
722parent = "epic"
723
724[[subtask]]
725id = "b"
726instruction = "step b"
727parent = "epic"
728deps = ["a"]
729
730[[subtask]]
731id = "c"
732instruction = "step c"
733parent = "epic"
734deps = ["b"]
735"#;
736        let mut plan = Plan::from_toml_str(toml).unwrap();
737        let top = Caveats::top();
738        // The drive loop a real executor runs: dispatch the next ready leaf,
739        // mark it Done, repeat. (Here the "dispatch" is a no-op; a CrewRunner
740        // would run inference. The state machine is what's under test.)
741        let mut order = Vec::new();
742        while let Some((id, task)) = plan.next_dispatch(&top) {
743            assert!(!task.goal.is_empty());
744            plan.mark(&id, SubtaskStatus::Running, None);
745            plan.mark(&id, SubtaskStatus::Done, Some(format!("ran {id}")));
746            order.push(id);
747        }
748        // Walked a → b → c in dependency order; epic (a branch) never dispatched.
749        assert_eq!(order, vec!["a", "b", "c"]);
750        assert!(plan.is_complete());
751        assert_eq!(plan.subtask("a").unwrap().result.as_deref(), Some("ran a"));
752    }
753
754    #[test]
755    fn a_failed_leaf_blocks_its_dependents_and_stops_the_run() {
756        let toml = r#"
757[[subtask]]
758id = "a"
759instruction = "x"
760
761[[subtask]]
762id = "b"
763instruction = "y"
764deps = ["a"]
765"#;
766        let mut plan = Plan::from_toml_str(toml).unwrap();
767        let top = Caveats::top();
768        let (id, _task) = plan.next_dispatch(&top).expect("a is ready");
769        assert_eq!(id, "a");
770        plan.mark(&id, SubtaskStatus::Failed, Some("boom".into()));
771        // b deps on a (now Failed, not Done) → not ready → nothing to dispatch,
772        // so the run stops honestly at the first failure (no separate stop flag).
773        assert!(plan.next_dispatch(&top).is_none());
774        assert!(!plan.is_complete());
775    }
776
777    #[test]
778    fn mark_is_a_noop_for_an_absent_id_and_empty_plan_is_complete() {
779        let mut plan = Plan::from_toml_str("[[subtask]]\nid=\"a\"\ninstruction=\"x\"\n").unwrap();
780        plan.mark("nope", SubtaskStatus::Done, Some("ignored".into()));
781        assert_eq!(plan.subtask("a").unwrap().status, SubtaskStatus::Pending);
782        assert!(Plan::from_toml_str("").unwrap().is_complete());
783    }
784}