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}