Skip to main content

majordomus_cli/
plan.rs

1//! The plan: milestones, issues, the two graphs between them, and everything derived from
2//! them — execution waves, issue status, milestone status, the roadmap order, the active
3//! milestone, the next executable issue and every validation finding.
4//!
5//! **This is a second reader of one set of semantics, not a second set.** `lib/project.awk`
6//! is the incumbent engine: the shell tool, the site generator and the GitHub adapter all
7//! call it, and it has been the only implementation since the model existed. This module
8//! derives the same values in Rust so that the capability registry can answer them — an
9//! agent asking the shared MCP server what to work on next could not be told otherwise —
10//! and the two are held to byte equality by `test/cases/99_plan_capabilities.sh`, which
11//! runs both over every record in this repository and over a fixture built to hit the
12//! edges. Where the two disagree the awk is right; this file is the one that changes.
13//!
14//! The order of derivation below follows `lib/project.awk` step for step, including the
15//! order findings are produced in, because that order is observable: a reader diffing the
16//! two engines diffs a sequence, not a set. Comments name the awk block each step mirrors.
17//!
18//! Nothing here reads a file. Milestones and issues are ordinary declarative objects of the
19//! layer — kinds `milestone` and `issue`, whole-YAML metadata — already discovered, parsed
20//! and schema-validated by the time the index holds them, so building the plan is a walk of
21//! values already in memory. That is what lets it be built on demand, inside the handler of
22//! a plan capability, rather than eagerly when a [`crate::capability::Context`] is composed:
23//! `App::load` has a stated budget and 200 project records have no business inside it.
24
25use std::collections::{BTreeMap, BTreeSet};
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use crate::index::Index;
32
33/// The kind of a milestone record.
34pub const MILESTONE: &str = "milestone";
35/// The kind of an issue record.
36pub const ISSUE: &str = "issue";
37/// The kind of the plan's header record.
38pub const PROJECT: &str = "project";
39
40/// The issue status vocabulary, in derivation order. The same list, in the same order, as
41/// `ISTATUS` in `lib/project.awk`; a status assigned that this does not declare is a
42/// finding, in both engines.
43pub const ISSUE_STATUSES: &[&str] = &["READY", "BLOCKED", "ACTIVE", "VERIFY", "DONE", "CANCELLED"];
44
45/// The milestone status vocabulary, in derivation order (`MSTATUS` in `lib/project.awk`).
46pub const MILESTONE_STATUSES: &[&str] = &[
47    "PLANNED",
48    "BLOCKED",
49    "ACTIVE",
50    "VERIFY",
51    "DONE",
52    "CANCELLED",
53    "SUPERSEDED",
54];
55
56// ---------------------------------------------------------------- scalars out of metadata
57
58/// One YAML scalar as the flattener would have written it: the text an awk comparison sees.
59///
60/// `lib/yaml.sh` flattens every value to a string, so `order: 3` is `"3"`, `cancelled: true`
61/// is `"true"` and a missing key is `""`. The index holds the same records as typed JSON,
62/// so the comparisons this module makes have to be made against the same text the awk
63/// compares — otherwise `parallel_safe: false` would be a bool here and the string `"false"`
64/// there, and only one of them would be right.
65///
66/// ```
67/// use majordomus_cli::plan::scalar;
68/// use serde_json::json;
69/// assert_eq!(scalar(Some(&json!(3))), "3");
70/// assert_eq!(scalar(Some(&json!(true))), "true");
71/// assert_eq!(scalar(Some(&json!("x"))), "x");
72/// assert_eq!(scalar(Some(&json!(null))), "");
73/// assert_eq!(scalar(None), "");
74/// ```
75pub fn scalar(v: Option<&Value>) -> String {
76    match v {
77        None | Some(Value::Null) => String::new(),
78        Some(Value::String(s)) => s.clone(),
79        Some(Value::Bool(b)) => b.to_string(),
80        Some(Value::Number(n)) => n.to_string(),
81        Some(other) => other.to_string(),
82    }
83}
84
85fn field(meta: &Value, key: &str) -> String {
86    scalar(meta.get(key))
87}
88
89/// A list field as the flattener's `key.0`, `key.1` … would have carried it: every item as a
90/// scalar, in the file's order. A scalar where a list was expected is one item, which is
91/// what the flattener produces for `scope: apps/` too.
92fn list(meta: &Value, key: &str) -> Vec<String> {
93    match meta.get(key) {
94        None | Some(Value::Null) => Vec::new(),
95        Some(Value::Array(items)) => items.iter().map(|v| scalar(Some(v))).collect(),
96        Some(other) => vec![scalar(Some(other))],
97    }
98}
99
100/// The `covers` token of every evidence entry that carries one, in file order. The awk
101/// counts the `evidence.N.covers` keys, so an entry without one is not evidence.
102fn evidence_covers(meta: &Value) -> Vec<String> {
103    match meta.get("evidence") {
104        Some(Value::Array(items)) => items
105            .iter()
106            .filter_map(|e| e.get("covers"))
107            .map(|v| scalar(Some(v)))
108            .collect(),
109        _ => Vec::new(),
110    }
111}
112
113/// Tabs out of a value that becomes a column: `clean()` in `lib/project.awk`.
114fn clean(s: &str) -> String {
115    s.replace('\t', " ")
116}
117
118// ---------------------------------------------------------------- the event behind a stamp
119
120/// The first instant a stamp could only have been written with its seal (ADR 0097).
121///
122/// A stamp older than this may be excused by its record's `unsealed_stamps` list, which
123/// names the stamps recorded before the transition wrote a seal. A stamp at or after
124/// it never is: the transition that wrote it wrote the seal in the same write, so a stamp
125/// without one was written by something else. `SEALED_FROM` in `lib/project.awk`.
126pub const SEALED_FROM: &str = "2026-10-01T00:00:00Z";
127
128/// The seal of one transition: the proof, tracked in the record beside the stamp, that the
129/// stamp was written by the transition that appends its event.
130///
131/// The ledger that holds the event is checkout-local and never tracked, so no gate reading
132/// a clone can compare a stamp with it. The seal is the tracked half: the SHA-256 of the
133/// event, the issue and the stamp, written by [`transition`] in the same write as the stamp
134/// and read by [`Plan::build`], which gives a stamp without its seal no effect on status and
135/// reports it as `stamp_without_event`. `mj_pj_seal_rows` in `lib/project.sh` computes the
136/// same digest for the awk engine.
137///
138/// ```
139/// use majordomus_cli::plan::{seal, Transition};
140/// let s = seal(Transition::Done, "I0001", "2026-09-16T12:00:00Z");
141/// assert!(s.starts_with("sha256:") && s.len() == 7 + 64);
142/// // A seal proves one stamp of one move of one issue, and nothing else.
143/// assert_ne!(s, seal(Transition::Start, "I0001", "2026-09-16T12:00:00Z"));
144/// assert_ne!(s, seal(Transition::Done, "I0002", "2026-09-16T12:00:00Z"));
145/// assert_ne!(s, seal(Transition::Done, "I0001", "2026-09-16T12:00:01Z"));
146/// ```
147pub fn seal(transition: Transition, issue: &str, stamp: &str) -> String {
148    format!(
149        "sha256:{}",
150        crate::policy::sha256_hex(&format!(
151            "majordomus.plan-event/v1\n{}\n{issue}\n{stamp}\n",
152            transition.event()
153        ))
154    )
155}
156
157/// The three moves, in the order a record's stamps are checked and reported.
158const MOVES: [Transition; 3] = [Transition::Start, Transition::Verify, Transition::Done];
159
160// ---------------------------------------------------------------- the authored records
161
162/// One issue as its file declares it, with nothing derived.
163#[derive(Debug, Clone)]
164struct PlanIssueRaw {
165    id: String,
166    milestone: String,
167    title: String,
168    slug: String,
169    priority: String,
170    profile: String,
171    parallel_safe: String,
172    cancelled: bool,
173    started_at: String,
174    verified_at: String,
175    completed_at: String,
176    started_event: String,
177    verified_event: String,
178    completed_event: String,
179    unsealed_stamps: Vec<String>,
180    objective: String,
181    depends_on: Vec<String>,
182    scope: Vec<String>,
183    serves: Vec<String>,
184    acceptance_criteria: usize,
185    validation: usize,
186    evidence_required: Vec<String>,
187    evidence_have: Vec<String>,
188}
189
190/// One milestone as its file declares it, with nothing derived.
191#[derive(Debug, Clone)]
192struct PlanMilestoneRaw {
193    id: String,
194    title: String,
195    slug: String,
196    order: String,
197    priority: String,
198    version: String,
199    cancelled: bool,
200    superseded_by: String,
201    outcome: String,
202    depends_on: Vec<String>,
203    claims: Vec<String>,
204    evidence_required: Vec<String>,
205    evidence_have: Vec<String>,
206}
207
208impl PlanIssueRaw {
209    fn of(id: &str, meta: &Value) -> Self {
210        PlanIssueRaw {
211            id: id.to_string(),
212            milestone: field(meta, "milestone"),
213            title: clean(&field(meta, "title")),
214            slug: field(meta, "slug"),
215            priority: field(meta, "priority"),
216            profile: field(meta, "profile"),
217            parallel_safe: field(meta, "parallel_safe"),
218            cancelled: field(meta, "cancelled") == "true",
219            started_at: field(meta, "started_at"),
220            verified_at: field(meta, "verified_at"),
221            completed_at: field(meta, "completed_at"),
222            started_event: field(meta, "started_event"),
223            verified_event: field(meta, "verified_event"),
224            completed_event: field(meta, "completed_event"),
225            unsealed_stamps: list(meta, "unsealed_stamps"),
226            objective: field(meta, "objective"),
227            depends_on: list(meta, "depends_on"),
228            scope: list(meta, "scope"),
229            serves: list(meta, "serves"),
230            acceptance_criteria: list(meta, "acceptance_criteria").len(),
231            validation: list(meta, "validation").len(),
232            evidence_required: list(meta, "evidence_required"),
233            evidence_have: evidence_covers(meta),
234        }
235    }
236
237    /// The stamp a move writes, as the record declares it.
238    fn stamp(&self, t: Transition) -> &str {
239        match t {
240            Transition::Start => &self.started_at,
241            Transition::Verify => &self.verified_at,
242            Transition::Done => &self.completed_at,
243        }
244    }
245
246    /// The seal the record carries beside that stamp.
247    fn sealed_with(&self, t: Transition) -> &str {
248        match t {
249            Transition::Start => &self.started_event,
250            Transition::Verify => &self.verified_event,
251            Transition::Done => &self.completed_event,
252        }
253    }
254
255    /// What a transition writes: the stamp and its seal, together.
256    fn stamp_with_seal(&mut self, t: Transition, now: &str) {
257        let s = seal(t, &self.id, now);
258        let (stamp, sealed) = match t {
259            Transition::Start => (&mut self.started_at, &mut self.started_event),
260            Transition::Verify => (&mut self.verified_at, &mut self.verified_event),
261            Transition::Done => (&mut self.completed_at, &mut self.completed_event),
262        };
263        *stamp = now.to_string();
264        *sealed = s;
265    }
266
267    /// Is the stamp of this move a fact: absent, sealed by its event, or recorded before
268    /// seals existed and named by the record's `unsealed_stamps`? Anything else was written
269    /// by something other than the transition.
270    fn proven(&self, t: Transition) -> bool {
271        let stamp = self.stamp(t);
272        stamp.is_empty()
273            || self.sealed_with(t) == seal(t, &self.id, stamp)
274            || (stamp < SEALED_FROM
275                && self
276                    .unsealed_stamps
277                    .contains(&format!("{} {stamp}", t.field())))
278    }
279}
280
281impl PlanMilestoneRaw {
282    fn of(id: &str, meta: &Value) -> Self {
283        PlanMilestoneRaw {
284            id: id.to_string(),
285            title: clean(&field(meta, "title")),
286            slug: field(meta, "slug"),
287            order: field(meta, "order"),
288            priority: field(meta, "priority"),
289            version: field(meta, "version"),
290            cancelled: field(meta, "cancelled") == "true",
291            superseded_by: field(meta, "superseded_by"),
292            outcome: field(meta, "outcome"),
293            depends_on: list(meta, "depends_on"),
294            claims: list(meta, "claims"),
295            evidence_required: list(meta, "evidence_required"),
296            evidence_have: evidence_covers(meta),
297        }
298    }
299}
300
301// ---------------------------------------------------------------- the derived model
302
303/// The plan's header: what this repository is, plus the one field nobody authors.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305pub struct PlanProject {
306    /// The project's name, from `project.yaml`.
307    pub name: String,
308    /// The repository it belongs to, `owner/name`.
309    pub repository: String,
310    /// The branch the plan is executed on.
311    pub default_branch: String,
312    /// The milestone a worker is executing now: the lowest-ranked unblocked milestone that
313    /// is ACTIVE, else the lowest-ranked unblocked one not finished. Derived on every read,
314    /// stored nowhere, and never authored — the plan cannot nominate a milestone whose
315    /// prerequisites are not real.
316    pub active_milestone: String,
317}
318
319/// The status vocabularies, so a reader never has to know which statuses exist.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
321pub struct PlanVocabulary {
322    /// The issue statuses, in derivation order.
323    pub issue: Vec<String>,
324    /// The milestone statuses, in derivation order.
325    pub milestone: Vec<String>,
326}
327
328/// A milestone's issues counted: the two denominators, then one entry per declared status.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
330pub struct PlanCounts {
331    /// Every issue naming this milestone.
332    pub total: u32,
333    /// The denominator the milestone's own status derivation uses: total less cancelled, so
334    /// no surface prints "n of total" for a milestone the engine calls DONE.
335    pub required: u32,
336    /// One entry per declared issue status, keyed by the vocabulary.
337    pub by_status: BTreeMap<String, u32>,
338}
339
340/// One issue, as its record declares it and as the graph derives it.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342pub struct PlanIssue {
343    /// The identity, which is also the file name.
344    pub id: String,
345    /// The milestone it belongs to.
346    pub milestone: String,
347    /// The derived status. Never stored: an issue records what happened to it and the
348    /// status follows from that and from the state of its dependencies.
349    pub status: String,
350    /// The execution wave: one past the longest path to it through the dependency graph.
351    pub wave: u32,
352    /// `p0` … `p3`.
353    pub priority: String,
354    /// The execution profile the issue is worked under.
355    pub profile: String,
356    /// Whether it may run beside another issue of its wave.
357    pub parallel_safe: bool,
358    /// One line naming the outcome.
359    pub title: String,
360    /// The slug, when the record carries one.
361    pub slug: String,
362    /// Every issue it declares a dependency on, as declared — including one that does not
363    /// exist, which is a finding rather than a silent omission.
364    pub depends_on: Vec<String>,
365    /// The dependencies that are not DONE, plus `milestone:<id>` when the milestone gate
366    /// holds the whole outcome back.
367    pub blocked_by: Vec<String>,
368    /// The issues that depend on this one.
369    pub dependents: Vec<String>,
370    /// The paths it touches; two issues of one wave that share a path are serialised.
371    pub scope: Vec<String>,
372    /// The intent criteria it exists to make true, each `<intent>#<criterion>`, as declared.
373    /// Empty for maintenance work under a milestone no intent names; whether a named
374    /// criterion exists is the intent coverage's question, not the plan's.
375    pub serves: Vec<String>,
376    /// One line: what the issue is for.
377    pub objective: String,
378    /// Evidence entries attached.
379    pub evidence_have: u32,
380    /// Evidence tokens the record requires before it may be DONE.
381    pub evidence_need: u32,
382    /// When execution began, when it did.
383    pub started_at: String,
384    /// When implementation was declared complete.
385    pub verified_at: String,
386    /// When completion was recorded.
387    pub completed_at: String,
388}
389
390/// An issue is shown, everywhere, in the natural order of its identity: `issue-2` before
391/// `issue-10`, which is the order its file names read in.
392impl crate::order::Ordered for PlanIssue {
393    fn order_key(&self) -> crate::order::OrderKey<'_> {
394        crate::order::OrderKey::plain(&self.id, &self.id)
395    }
396}
397
398/// One milestone, as its record declares it and as the two graphs derive it.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
400pub struct PlanMilestone {
401    /// The identity, which is also the file name; a stable slug, never a version.
402    pub id: String,
403    /// The derived status.
404    pub status: String,
405    /// Tie-break inside one rank; the roadmap is ordered by rank first.
406    pub order: i64,
407    /// `p0` … `p3`.
408    pub priority: String,
409    /// One line naming the outcome.
410    pub title: String,
411    /// The slug, when the record carries one.
412    pub slug: String,
413    /// The release the milestone belongs to, when it declares one.
414    pub version: String,
415    /// Its layer in the milestone graph: what orders the roadmap.
416    pub rank: u32,
417    /// The milestones it requires.
418    pub depends_on: Vec<String>,
419    /// Those of them that are not DONE. Non-empty means the gate holds every issue of this
420    /// milestone back, whatever the issue graph says.
421    pub blocked_by: Vec<String>,
422    /// The milestones that require it.
423    pub dependents: Vec<String>,
424    /// The claims of the repository this outcome makes true.
425    pub claims: Vec<String>,
426    /// Its issues, counted by derived status.
427    pub counts: PlanCounts,
428    /// Its issues, in id order.
429    pub issues: Vec<String>,
430    /// One line: what is true once the milestone is real.
431    pub outcome: String,
432}
433
434/// One execution wave: the issues the graph allows to run at the same time.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
436pub struct PlanWave {
437    /// The layer, from zero.
438    pub wave: u32,
439    /// The issues in it, in id order.
440    pub issues: Vec<String>,
441}
442
443/// One dependency edge, `from` before `to`.
444#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
445pub struct PlanEdge {
446    /// The record that must be real first.
447    pub from: String,
448    /// The record that waits for it.
449    pub to: String,
450}
451
452/// One validation finding, in the shape `project.finding-carries-reproduce` asks for.
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
454pub struct PlanFinding {
455    /// `FAIL` or `WARN`. A failure means the model is invalid.
456    pub level: String,
457    /// The stable code a reader greps for (`unknown_dependency`, `scope_conflict`, …).
458    pub code: String,
459    /// The record the finding is about, or `graph` when it is about the whole graph.
460    pub subject: String,
461    /// What is wrong, in one line.
462    pub message: String,
463}
464
465/// The whole derived plan: what every plan capability answers out of.
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
467pub struct Plan {
468    /// The plan's header, with the active milestone derived.
469    pub project: PlanProject,
470    /// The declared status vocabularies.
471    pub statuses: PlanVocabulary,
472    /// Every milestone, in id order.
473    pub milestones: Vec<PlanMilestone>,
474    /// Every issue, in id order.
475    pub issues: Vec<PlanIssue>,
476    /// The execution waves, lowest first.
477    pub waves: Vec<PlanWave>,
478    /// The issue dependency graph, sorted.
479    pub edges: Vec<PlanEdge>,
480    /// The milestone dependency graph, sorted.
481    pub milestone_edges: Vec<PlanEdge>,
482    /// Every finding, in derivation order.
483    pub findings: Vec<PlanFinding>,
484}
485
486impl Plan {
487    /// One issue by id.
488    pub fn issue(&self, id: &str) -> Option<&PlanIssue> {
489        self.issues.iter().find(|i| i.id == id)
490    }
491
492    /// One milestone by id.
493    pub fn milestone(&self, id: &str) -> Option<&PlanMilestone> {
494        self.milestones.iter().find(|m| m.id == id)
495    }
496
497    /// How many findings are failures. A model with one is invalid.
498    pub fn failures(&self) -> usize {
499        self.findings.iter().filter(|f| f.level == "FAIL").count()
500    }
501
502    /// How many findings are warnings.
503    pub fn warnings(&self) -> usize {
504        self.findings.iter().filter(|f| f.level == "WARN").count()
505    }
506
507    /// Whether this repository holds a canonical project model at all.
508    pub fn is_present(&self) -> bool {
509        !self.milestones.is_empty() || !self.issues.is_empty()
510    }
511
512    /// The milestone ids in roadmap order: rank, then order, then id. Derived from the
513    /// graph; no list of versions is maintained anywhere.
514    pub fn roadmap(&self) -> Vec<&PlanMilestone> {
515        let mut out: Vec<&PlanMilestone> = self.milestones.iter().collect();
516        out.sort_by(|a, b| {
517            (a.rank, a.order, &a.id)
518                .partial_cmp(&(b.rank, b.order, &b.id))
519                .unwrap_or(std::cmp::Ordering::Equal)
520        });
521        out
522    }
523
524    /// The one issue a worker should take now: the lowest-wave READY issue of the active
525    /// milestone, highest priority first, then id.
526    ///
527    /// The active milestone can have nothing ready while another one does — a milestone
528    /// waiting on its own acceptance evidence, for instance — so the search widens to the
529    /// whole plan rather than answering "none" and sending a worker away from executable
530    /// work. `mj_pj_next_ready` in `lib/project.sh` does exactly this.
531    pub fn next_ready(&self, milestone: Option<&str>) -> Option<&PlanIssue> {
532        let scope = milestone.unwrap_or(self.project.active_milestone.as_str());
533        self.ready_ranked(Some(scope))
534            .or_else(|| self.ready_ranked(None))
535    }
536
537    fn ready_ranked(&self, milestone: Option<&str>) -> Option<&PlanIssue> {
538        self.issues
539            .iter()
540            .filter(|i| i.status == "READY")
541            .filter(|i| milestone.is_none_or(|m| m.is_empty() || i.milestone == m))
542            .min_by_key(|i| (i.wave, priority_rank(&i.priority), i.id.clone()))
543    }
544
545    /// Build the plan from an index. A walk of values already in memory: no file is opened,
546    /// no subprocess is started, nothing is cached.
547    pub fn build(index: &Index) -> Plan {
548        let (header, milestones, issues) = raws(index);
549        derive(header, milestones, issues)
550    }
551
552    /// The plan as it will read once `transition` has been written to `id`.
553    ///
554    /// The same records, the same [`derive`], one timestamp changed: the status a caller is
555    /// told after a write is computed by the one derivation every reader uses, never
556    /// announced by the writer. This matters because the resulting status is not a function
557    /// of the transition alone — a `done` whose required evidence is absent stays VERIFY,
558    /// and a record marked `cancelled` stays CANCELLED whatever is written to it.
559    ///
560    /// Nothing is read from disk and nothing is written: this is the projection of a write,
561    /// and [`transition`] is what performs one.
562    ///
563    /// ```no_run
564    /// # use majordomus_cli::index::Index;
565    /// use majordomus_cli::plan::{Plan, Transition};
566    /// # fn demo(index: &Index) {
567    /// let before = Plan::build(index);
568    /// let after = Plan::after(index, "I0001", Transition::Done, "2026-09-11T12:00:00Z");
569    /// // The status is derived both times, so an issue whose evidence is incomplete is
570    /// // still VERIFY afterwards and the caller can see that before writing anything.
571    /// assert_eq!(before.issue("I0001").is_some(), after.issue("I0001").is_some());
572    /// # }
573    /// ```
574    pub fn after(index: &Index, id: &str, transition: Transition, now: &str) -> Plan {
575        let (header, milestones, mut issues) = raws(index);
576        if let Some(r) = issues.iter_mut().find(|r| r.id == id) {
577            r.stamp_with_seal(transition, now);
578        }
579        derive(header, milestones, issues)
580    }
581}
582
583/// Every record the plan is derived from, as its file declares it.
584///
585/// Split out of [`Plan::build`] so that [`Plan::after`] derives from the same collection
586/// rather than a second reading of the index.
587fn raws(index: &Index) -> (PlanProject, Vec<PlanMilestoneRaw>, Vec<PlanIssueRaw>) {
588    let mut milestones: Vec<PlanMilestoneRaw> = Vec::new();
589    let mut issues: Vec<PlanIssueRaw> = Vec::new();
590    let mut header = PlanProject {
591        name: String::new(),
592        repository: String::new(),
593        default_branch: String::new(),
594        active_milestone: String::new(),
595    };
596    for o in &index.objects {
597        match o.kind.as_str() {
598            MILESTONE => milestones.push(PlanMilestoneRaw::of(&o.identity, &o.metadata)),
599            ISSUE => issues.push(PlanIssueRaw::of(&o.identity, &o.metadata)),
600            PROJECT if o.provenance.path.ends_with("project/project.yaml") => {
601                header.name = field(&o.metadata, "name");
602                header.repository = field(&o.metadata, "repository");
603                header.default_branch = field(&o.metadata, "default_branch");
604            }
605            _ => {}
606        }
607    }
608    // The awk reads the files in `LC_ALL=C sort` order of their names, and the loader
609    // refuses a record whose id disagrees with its file name, so id order is file order.
610    milestones.sort_by(|a, b| a.id.cmp(&b.id));
611    issues.sort_by(|a, b| a.id.cmp(&b.id));
612    (header, milestones, issues)
613}
614
615fn priority_rank(p: &str) -> u8 {
616    match p {
617        "p0" => 0,
618        "p1" => 1,
619        "p2" => 2,
620        _ => 3,
621    }
622}
623
624// ---------------------------------------------------------------- the derivation
625
626struct PlanFindings(Vec<PlanFinding>);
627
628impl PlanFindings {
629    fn fail(&mut self, code: &str, subject: &str, message: String) {
630        self.0.push(PlanFinding {
631            level: "FAIL".into(),
632            code: code.into(),
633            subject: subject.into(),
634            message,
635        });
636    }
637    fn warn(&mut self, code: &str, subject: &str, message: String) {
638        self.0.push(PlanFinding {
639            level: "WARN".into(),
640            code: code.into(),
641            subject: subject.into(),
642            message,
643        });
644    }
645}
646
647/// Does path `a` contain path `b`, or the reverse? Trailing slashes and `./` are normalised
648/// so that a hand-written scope entry cannot silently miss an overlap. `overlap()` in
649/// `lib/project.awk`.
650///
651/// ```
652/// use majordomus_cli::plan::overlap;
653/// assert!(overlap("apps/cli", "apps/cli/src/main.rs"));
654/// assert!(overlap("./lib/", "lib"));
655/// assert!(!overlap("lib", "libexec"));
656/// ```
657pub fn overlap(a: &str, b: &str) -> bool {
658    let norm = |s: &str| {
659        let s = s.strip_prefix("./").unwrap_or(s);
660        s.trim_end_matches('/').to_string()
661    };
662    let (a, b) = (norm(a), norm(b));
663    a == b || b.starts_with(&format!("{a}/")) || a.starts_with(&format!("{b}/"))
664}
665
666#[allow(clippy::too_many_lines)] // one derivation, in the order lib/project.awk derives it
667fn derive(mut header: PlanProject, mraw: Vec<PlanMilestoneRaw>, iraw: Vec<PlanIssueRaw>) -> Plan {
668    let mut v = PlanFindings(Vec::new());
669    let iids: Vec<String> = iraw.iter().map(|i| i.id.clone()).collect();
670    let mids: Vec<String> = mraw.iter().map(|m| m.id.clone()).collect();
671    let iseen: BTreeSet<&str> = iids.iter().map(String::as_str).collect();
672    let mseen: BTreeSet<&str> = mids.iter().map(String::as_str).collect();
673    let ix = |id: &str| -> Option<usize> { iraw.iter().position(|r| r.id == id) };
674
675    // --- the event behind every stamp. A stamp its transition did not write is not a fact
676    //     about the issue: it moves no status, and it is a failure by name (ADR 0097).
677    let proven: Vec<[bool; 3]> = iraw.iter().map(|r| MOVES.map(|t| r.proven(t))).collect();
678    // `MOVES` lists the moves in declaration order, so a move's discriminant is its column.
679    let stamped =
680        |n: usize, t: Transition| -> bool { !iraw[n].stamp(t).is_empty() && proven[n][t as usize] };
681
682    // --- local facts: is this issue DONE on its own terms?
683    let mut covered = vec![true; iraw.len()];
684    let mut done = vec![false; iraw.len()];
685    for (n, r) in iraw.iter().enumerate() {
686        covered[n] = r
687            .evidence_required
688            .iter()
689            .all(|need| r.evidence_have.contains(need));
690        done[n] = !r.cancelled && stamped(n, Transition::Done) && covered[n];
691    }
692
693    // --- edges, and the errors visible in them
694    let mut edge: BTreeSet<(String, String)> = BTreeSet::new();
695    let mut dependents: Vec<Vec<String>> = vec![Vec::new(); iraw.len()];
696    let mut edges: Vec<PlanEdge> = Vec::new();
697    for (n, r) in iraw.iter().enumerate() {
698        let mut seen_here: BTreeSet<&str> = BTreeSet::new();
699        for d in &r.depends_on {
700            if d == &r.id {
701                v.fail("self_dependency", &r.id, "depends on itself".into());
702                continue;
703            }
704            if !iseen.contains(d.as_str()) {
705                v.fail(
706                    "unknown_dependency",
707                    &r.id,
708                    format!("depends on {d}, which is not an issue"),
709                );
710                continue;
711            }
712            if !seen_here.insert(d.as_str()) {
713                v.warn(
714                    "duplicate_dependency",
715                    &r.id,
716                    format!("names {d} more than once"),
717                );
718                continue;
719            }
720            edge.insert((r.id.clone(), d.clone()));
721            if let Some(dn) = ix(d) {
722                dependents[dn].push(r.id.clone());
723            }
724            edges.push(PlanEdge {
725                from: d.clone(),
726                to: r.id.clone(),
727            });
728        }
729        if r.milestone.is_empty() {
730            v.fail("no_milestone", &r.id, "names no milestone".into());
731        } else if !mseen.contains(r.milestone.as_str()) {
732            v.fail(
733                "unknown_milestone",
734                &r.id,
735                format!("names milestone {}, which does not exist", r.milestone),
736            );
737        }
738        if r.acceptance_criteria == 0 && !r.cancelled {
739            v.fail(
740                "no_acceptance",
741                &r.id,
742                "has no acceptance criteria; an issue without one is a placeholder".into(),
743            );
744        }
745        if r.validation == 0 && !r.cancelled {
746            v.fail("no_validation", &r.id, "names no validation command".into());
747        }
748        if r.evidence_required.is_empty() && !r.cancelled {
749            v.warn(
750                "no_evidence_required",
751                &r.id,
752                "requires no evidence; nothing gates its completion".into(),
753            );
754        }
755        for (k, t) in MOVES.iter().enumerate() {
756            if !proven[n][k] {
757                v.fail(
758                    "stamp_without_event",
759                    &r.id,
760                    format!(
761                        "{} {} has no {} event: {} is absent or does not seal it, and only `majordomus plan {}` writes the stamp",
762                        t.field(),
763                        r.stamp(*t),
764                        t.event(),
765                        t.seal_field(),
766                        t.verb()
767                    ),
768                );
769            }
770        }
771        if stamped(n, Transition::Done) && !covered[n] && !r.cancelled {
772            let miss: Vec<&str> = r
773                .evidence_required
774                .iter()
775                .filter(|need| !r.evidence_have.contains(need))
776                .map(String::as_str)
777                .collect();
778            v.warn(
779                "evidence_missing",
780                &r.id,
781                format!(
782                    "completed_at is set but evidence is missing for {}; status stays VERIFY",
783                    miss.join(",")
784                ),
785            );
786        }
787    }
788
789    // --- waves: Kahn layering. A node enters a wave only once every dependency has left,
790    //     so its wave is one past the longest path to it. Nodes that never leave are in or
791    //     downstream of a cycle, and are reported rather than silently given a wave.
792    let mut left = vec![true; iraw.len()];
793    let mut nleft = iraw.len();
794    let mut wave: Vec<Option<u32>> = vec![None; iraw.len()];
795    let mut wavelines: Vec<PlanWave> = Vec::new();
796    let mut layer: u32 = 0;
797    while nleft > 0 {
798        let mut front: Vec<usize> = Vec::new();
799        for (n, r) in iraw.iter().enumerate() {
800            if !left[n] {
801                continue;
802            }
803            let blocked = r.depends_on.iter().any(|d| {
804                d != &r.id && iseen.contains(d.as_str()) && ix(d).is_some_and(|dn| left[dn])
805            });
806            if !blocked {
807                front.push(n);
808            }
809        }
810        if front.is_empty() {
811            let stuck: Vec<&str> = (0..iraw.len())
812                .filter(|n| left[*n])
813                .map(|n| iids[n].as_str())
814                .collect();
815            v.fail(
816                "cycle",
817                "graph",
818                format!(
819                    "a dependency cycle prevents these issues from ever becoming ready: {}",
820                    stuck.join(" ")
821                ),
822            );
823            break;
824        }
825        let mut line: Vec<String> = Vec::new();
826        for n in front {
827            wave[n] = Some(layer);
828            left[n] = false;
829            nleft -= 1;
830            line.push(iids[n].clone());
831        }
832        wavelines.push(PlanWave {
833            wave: layer,
834            issues: line,
835        });
836        layer += 1;
837    }
838
839    // --- issue status
840    let mut status: Vec<String> = vec![String::new(); iraw.len()];
841    let mut blocked_by: Vec<Vec<String>> = vec![Vec::new(); iraw.len()];
842    for (n, r) in iraw.iter().enumerate() {
843        // Every declared dependency, not every edge: a duplicate is a warning, and the awk
844        // walks the declaration here, so a record naming one twice blocks on it twice.
845        let bb: Vec<String> = r
846            .depends_on
847            .iter()
848            .filter(|d| {
849                *d != &r.id && iseen.contains(d.as_str()) && ix(d).is_some_and(|dn| !done[dn])
850            })
851            .cloned()
852            .collect();
853        let st = if r.cancelled {
854            "CANCELLED"
855        } else if done[n] {
856            "DONE"
857        } else if stamped(n, Transition::Done) || stamped(n, Transition::Verify) {
858            "VERIFY"
859        } else if stamped(n, Transition::Start) {
860            "ACTIVE"
861        } else if !bb.is_empty() {
862            "BLOCKED"
863        } else {
864            "READY"
865        };
866        if !bb.is_empty() && matches!(st, "DONE" | "ACTIVE" | "VERIFY") {
867            v.fail(
868                "premature_execution",
869                &r.id,
870                format!("is {st} while {} is not DONE", bb.join(",")),
871            );
872        }
873        status[n] = st.to_string();
874        blocked_by[n] = bb;
875        if !left[n] && wave[n].is_none() && st != "CANCELLED" {
876            wave[n] = Some(0);
877        }
878    }
879
880    // --- the milestone graph: validated before anything derives from it, under the same
881    //     rules the issue graph lives under, one level up.
882    let mut medge: BTreeSet<(String, String)> = BTreeSet::new();
883    let mut mdependents: Vec<Vec<String>> = vec![Vec::new(); mraw.len()];
884    let mut medges: Vec<PlanEdge> = Vec::new();
885    let mx = |id: &str| -> Option<usize> { mraw.iter().position(|r| r.id == id) };
886    for r in &mraw {
887        for d in &r.depends_on {
888            if d == &r.id {
889                v.fail("milestone_self_dependency", &r.id, "requires itself".into());
890                continue;
891            }
892            if !mseen.contains(d.as_str()) {
893                v.fail(
894                    "milestone_unknown_dependency",
895                    &r.id,
896                    format!("requires {d}, which is not a milestone here"),
897                );
898                continue;
899            }
900            if medge.contains(&(r.id.clone(), d.clone())) {
901                v.warn(
902                    "milestone_duplicate_dependency",
903                    &r.id,
904                    format!("requires {d} more than once"),
905                );
906                continue;
907            }
908            medge.insert((r.id.clone(), d.clone()));
909            if let Some(dn) = mx(d) {
910                mdependents[dn].push(r.id.clone());
911            }
912            medges.push(PlanEdge {
913                from: d.clone(),
914                to: r.id.clone(),
915            });
916        }
917    }
918
919    // --- roadmap rank: Kahn layering over the milestone graph. Rank is what orders the
920    //     roadmap; `order` only breaks ties inside one rank.
921    let mut mleft = vec![true; mraw.len()];
922    let mut mnleft = mraw.len();
923    let mut rank: Vec<u32> = vec![0; mraw.len()];
924    let mut mlayer: u32 = 0;
925    while mnleft > 0 {
926        let mut front: Vec<usize> = Vec::new();
927        for (n, r) in mraw.iter().enumerate() {
928            if !mleft[n] {
929                continue;
930            }
931            let blocked = r.depends_on.iter().any(|d| {
932                medge.contains(&(r.id.clone(), d.clone())) && mx(d).is_some_and(|dn| mleft[dn])
933            });
934            if !blocked {
935                front.push(n);
936            }
937        }
938        if front.is_empty() {
939            break;
940        }
941        for n in front {
942            rank[n] = mlayer;
943            mleft[n] = false;
944            mnleft -= 1;
945        }
946        mlayer += 1;
947    }
948    if mnleft > 0 {
949        let cyc: Vec<&str> = (0..mraw.len())
950            .filter(|n| mleft[*n])
951            .map(|n| mids[n].as_str())
952            .collect();
953        v.fail(
954            "milestone_cycle",
955            &cyc.join(" "),
956            "milestone dependencies form a cycle; no roadmap order exists".into(),
957        );
958        for n in 0..mraw.len() {
959            if mleft[n] {
960                rank[n] = 0;
961            }
962        }
963    }
964
965    // --- milestone status
966    let mut mstatus: Vec<String> = vec![String::new(); mraw.len()];
967    let mut counts: Vec<PlanCounts> = Vec::with_capacity(mraw.len());
968    let mut mempty = vec![false; mraw.len()];
969    for (n, r) in mraw.iter().enumerate() {
970        let c = tally(&iraw, &status, &r.id);
971        let (nd, nr, na, nv) = (
972            *c.by_status.get("DONE").unwrap_or(&0),
973            *c.by_status.get("READY").unwrap_or(&0),
974            *c.by_status.get("ACTIVE").unwrap_or(&0),
975            *c.by_status.get("VERIFY").unwrap_or(&0),
976        );
977        let mcov = r
978            .evidence_required
979            .iter()
980            .all(|need| r.evidence_have.contains(need));
981        let (tot, req) = (c.total, c.required);
982        let ms = if r.cancelled {
983            "CANCELLED"
984        } else if !r.superseded_by.is_empty() {
985            "SUPERSEDED"
986        } else if tot == 0 && !r.evidence_required.is_empty() && mcov {
987            "DONE"
988        } else if tot == 0 {
989            "PLANNED"
990        } else if req > 0 && nd == req && mcov {
991            "DONE"
992        } else if req > 0 && nd == req {
993            "VERIFY"
994        } else if na > 0 || nv > 0 || nd > 0 {
995            "ACTIVE"
996        } else if nr == 0 {
997            "BLOCKED"
998        } else {
999            "PLANNED"
1000        };
1001        mstatus[n] = ms.to_string();
1002        mempty[n] = tot == 0;
1003        if req > 0 && nd == req && !mcov {
1004            let miss: Vec<&str> = r
1005                .evidence_required
1006                .iter()
1007                .filter(|need| !r.evidence_have.contains(need))
1008                .map(String::as_str)
1009                .collect();
1010            v.warn(
1011                "milestone_evidence_missing",
1012                &r.id,
1013                format!(
1014                    "every issue is DONE but milestone evidence is missing for {}; status stays VERIFY",
1015                    miss.join(",")
1016                ),
1017            );
1018        }
1019        counts.push(c);
1020    }
1021
1022    // --- the gate. A milestone whose required outcomes are not yet real cannot be
1023    //     executable, whatever its own issues say. Run in rank order, so a dependency is
1024    //     always decided before the milestone requiring it and the block cascades.
1025    let mut mblocked: Vec<Vec<String>> = vec![Vec::new(); mraw.len()];
1026    for r in 0..=mlayer {
1027        for (n, m) in mraw.iter().enumerate() {
1028            if rank[n] != r {
1029                continue;
1030            }
1031            let block: Vec<String> = m
1032                .depends_on
1033                .iter()
1034                .filter(|d| medge.contains(&(m.id.clone(), (*d).clone())))
1035                .filter(|d| mx(d).is_some_and(|dn| mstatus[dn] != "DONE"))
1036                .cloned()
1037                .collect();
1038            mblocked[n] = block.clone();
1039            if block.is_empty() {
1040                continue;
1041            }
1042            if mstatus[n] == "CANCELLED" || mstatus[n] == "SUPERSEDED" {
1043                continue;
1044            }
1045            if matches!(mstatus[n].as_str(), "DONE" | "VERIFY" | "ACTIVE") {
1046                v.fail(
1047                    "milestone_premature",
1048                    &m.id,
1049                    format!("is {} while {} is not DONE", mstatus[n], block.join(",")),
1050                );
1051            }
1052            mstatus[n] = "BLOCKED".into();
1053        }
1054    }
1055
1056    for (n, m) in mraw.iter().enumerate() {
1057        if !mempty[n] {
1058            continue;
1059        }
1060        if matches!(mstatus[n].as_str(), "DONE" | "CANCELLED" | "SUPERSEDED") {
1061            continue;
1062        }
1063        if !mblocked[n].is_empty() {
1064            continue;
1065        }
1066        v.warn(
1067            "empty_milestone",
1068            &m.id,
1069            "is reachable and has no issues; it is an outcome nobody is executing".into(),
1070        );
1071    }
1072
1073    // --- the gate reaches the work. An issue inside a milestone the gate has blocked is not
1074    //     executable, whatever its own dependencies say: the outcome it belongs to is not
1075    //     reachable yet.
1076    for (n, r) in iraw.iter().enumerate() {
1077        if r.milestone.is_empty() {
1078            continue;
1079        }
1080        let Some(mn) = mx(&r.milestone) else { continue };
1081        if mblocked[mn].is_empty() {
1082            continue;
1083        }
1084        if status[n] == "CANCELLED" || status[n] == "DONE" {
1085            continue;
1086        }
1087        if status[n] == "ACTIVE" || status[n] == "VERIFY" {
1088            v.fail(
1089                "premature_execution",
1090                &r.id,
1091                format!(
1092                    "is {} while its milestone {} waits on {}",
1093                    status[n],
1094                    r.milestone,
1095                    mblocked[mn].join(",")
1096                ),
1097            );
1098        }
1099        status[n] = "BLOCKED".into();
1100        blocked_by[n].push(format!("milestone:{}", r.milestone));
1101    }
1102
1103    // counts follow the statuses, so a blocked milestone cannot report ready work
1104    for (n, m) in mraw.iter().enumerate() {
1105        if mblocked[n].is_empty() {
1106            continue;
1107        }
1108        counts[n] = tally(&iraw, &status, &m.id);
1109    }
1110
1111    // --- scope conflict: two issues that could run together but touch the same paths.
1112    //     Reported conservatively — an uncertain overlap serialises rather than races.
1113    for a in 0..iraw.len() {
1114        for b in (a + 1)..iraw.len() {
1115            if !matches!(status[a].as_str(), "READY" | "ACTIVE") {
1116                continue;
1117            }
1118            if !matches!(status[b].as_str(), "READY" | "ACTIVE") {
1119                continue;
1120            }
1121            if wave[a] != wave[b] {
1122                continue;
1123            }
1124            'pair: for x in &iraw[a].scope {
1125                for y in &iraw[b].scope {
1126                    if overlap(x, y) {
1127                        v.warn(
1128                            "scope_conflict",
1129                            &iraw[a].id,
1130                            format!(
1131                                "shares {x} with {}; they may not run concurrently",
1132                                iraw[b].id
1133                            ),
1134                        );
1135                        break 'pair;
1136                    }
1137                }
1138            }
1139        }
1140    }
1141
1142    // --- the active milestone: the lowest-ranked unblocked milestone that is ACTIVE, else
1143    //     the lowest-ranked unblocked one not finished.
1144    let mut best: Option<usize> = None;
1145    for pass in 1..=2 {
1146        for n in 0..mraw.len() {
1147            if mstatus[n] == "DONE" || mstatus[n] == "CANCELLED" {
1148                continue;
1149            }
1150            if pass == 1 && mstatus[n] != "ACTIVE" {
1151                continue;
1152            }
1153            if !mblocked[n].is_empty() {
1154                continue;
1155            }
1156            let o = i64::from(rank[n]) * 1_000_000 + order_of(&mraw[n].order);
1157            if best.is_none_or(|b| o < i64::from(rank[b]) * 1_000_000 + order_of(&mraw[b].order)) {
1158                best = Some(n);
1159            }
1160        }
1161        if best.is_some() {
1162            break;
1163        }
1164    }
1165    header.active_milestone = best.map(|n| mids[n].clone()).unwrap_or_default();
1166
1167    // --- reconciliation: a status assigned but not declared would reach a surface as a
1168    //     count nobody renders and a label nobody has a colour for.
1169    for (n, r) in iraw.iter().enumerate() {
1170        if !ISSUE_STATUSES.contains(&status[n].as_str()) {
1171            v.fail(
1172                "undeclared_status",
1173                &r.id,
1174                format!(
1175                    "is {}, which the issue status vocabulary does not declare",
1176                    status[n]
1177                ),
1178            );
1179        }
1180    }
1181    for (n, m) in mraw.iter().enumerate() {
1182        if !MILESTONE_STATUSES.contains(&mstatus[n].as_str()) {
1183            v.fail(
1184                "undeclared_status",
1185                &m.id,
1186                format!(
1187                    "is {}, which the milestone status vocabulary does not declare",
1188                    mstatus[n]
1189                ),
1190            );
1191        }
1192    }
1193
1194    // ---------------------------------------------------------------- assemble
1195    edges.sort();
1196    edges.dedup();
1197    medges.sort();
1198    medges.dedup();
1199
1200    let issues: Vec<PlanIssue> = iraw
1201        .iter()
1202        .enumerate()
1203        .map(|(n, r)| PlanIssue {
1204            id: r.id.clone(),
1205            milestone: r.milestone.clone(),
1206            status: status[n].clone(),
1207            wave: wave[n].unwrap_or(0),
1208            priority: r.priority.clone(),
1209            profile: r.profile.clone(),
1210            parallel_safe: r.parallel_safe != "false",
1211            title: r.title.clone(),
1212            slug: r.slug.clone(),
1213            depends_on: r.depends_on.clone(),
1214            blocked_by: blocked_by[n].clone(),
1215            dependents: dependents[n].clone(),
1216            scope: r.scope.clone(),
1217            serves: r.serves.clone(),
1218            objective: r.objective.clone(),
1219            evidence_have: u32::try_from(r.evidence_have.len()).unwrap_or(u32::MAX),
1220            evidence_need: u32::try_from(r.evidence_required.len()).unwrap_or(u32::MAX),
1221            started_at: r.started_at.clone(),
1222            verified_at: r.verified_at.clone(),
1223            completed_at: r.completed_at.clone(),
1224        })
1225        .collect();
1226
1227    let milestones: Vec<PlanMilestone> = mraw
1228        .iter()
1229        .enumerate()
1230        .map(|(n, r)| PlanMilestone {
1231            id: r.id.clone(),
1232            status: mstatus[n].clone(),
1233            order: order_of(&r.order),
1234            priority: r.priority.clone(),
1235            title: r.title.clone(),
1236            slug: r.slug.clone(),
1237            version: r.version.clone(),
1238            rank: rank[n],
1239            depends_on: r
1240                .depends_on
1241                .iter()
1242                .filter(|d| medge.contains(&(r.id.clone(), (*d).clone())))
1243                .cloned()
1244                .collect(),
1245            blocked_by: mblocked[n].clone(),
1246            dependents: mdependents[n].clone(),
1247            claims: r.claims.clone(),
1248            counts: counts[n].clone(),
1249            issues: iraw
1250                .iter()
1251                .filter(|i| i.milestone == r.id)
1252                .map(|i| i.id.clone())
1253                .collect(),
1254            outcome: r.outcome.clone(),
1255        })
1256        .collect();
1257
1258    Plan {
1259        project: header,
1260        statuses: PlanVocabulary {
1261            issue: ISSUE_STATUSES.iter().map(|s| (*s).to_string()).collect(),
1262            milestone: MILESTONE_STATUSES
1263                .iter()
1264                .map(|s| (*s).to_string())
1265                .collect(),
1266        },
1267        milestones,
1268        issues,
1269        waves: wavelines,
1270        edges,
1271        milestone_edges: medges,
1272        findings: v.0,
1273    }
1274}
1275
1276/// `order` as a number: the awk compares it with `+ 0`, which makes anything unparseable
1277/// zero. A milestone with no `order` is not an error, it is order zero.
1278fn order_of(raw: &str) -> i64 {
1279    raw.trim().parse::<i64>().unwrap_or(0)
1280}
1281
1282/// The counts of one milestone: the two denominators, then one entry per declared status.
1283/// `mcounts()` in `lib/project.awk`, and computed the same twice — once from the statuses
1284/// before the gate and once after it, because the gate changes statuses underneath it.
1285fn tally(iraw: &[PlanIssueRaw], status: &[String], milestone: &str) -> PlanCounts {
1286    let mut by: BTreeMap<String, u32> = ISSUE_STATUSES
1287        .iter()
1288        .map(|s| ((*s).to_string(), 0))
1289        .collect();
1290    let mut total = 0u32;
1291    for (n, r) in iraw.iter().enumerate() {
1292        if r.milestone != milestone {
1293            continue;
1294        }
1295        total += 1;
1296        *by.entry(status[n].clone()).or_insert(0) += 1;
1297    }
1298    let cancelled = *by.get("CANCELLED").unwrap_or(&0);
1299    PlanCounts {
1300        total,
1301        required: total - cancelled,
1302        by_status: by,
1303    }
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308    use super::*;
1309    use serde_json::json;
1310
1311    fn issue(id: &str, milestone: &str, deps: &[&str]) -> PlanIssueRaw {
1312        PlanIssueRaw::of(
1313            id,
1314            &json!({
1315                "id": id,
1316                "milestone": milestone,
1317                "title": format!("PlanIssue {id}"),
1318                "priority": "p1",
1319                "profile": "implementation",
1320                "depends_on": deps,
1321                "acceptance_criteria": ["it works"],
1322                "validation": ["true"],
1323                "evidence_required": ["proof"],
1324            }),
1325        )
1326    }
1327
1328    fn milestone(id: &str, deps: &[&str]) -> PlanMilestoneRaw {
1329        PlanMilestoneRaw::of(
1330            id,
1331            &json!({
1332                "id": id,
1333                "title": format!("PlanMilestone {id}"),
1334                "order": 0,
1335                "priority": "p1",
1336                "depends_on": deps,
1337                "evidence_required": ["proof"],
1338            }),
1339        )
1340    }
1341
1342    fn header() -> PlanProject {
1343        PlanProject {
1344            name: "Fixture".into(),
1345            repository: "example/fixture".into(),
1346            default_branch: "master".into(),
1347            active_milestone: String::new(),
1348        }
1349    }
1350
1351    /// A diamond places each issue one layer past its deepest dependency, and the tip waits
1352    /// on both middles. The same graph `test/cases/42_dag_waves.sh` asserts on in the shell.
1353    #[test]
1354    fn a_diamond_layers_into_three_waves() {
1355        let p = derive(
1356            header(),
1357            vec![milestone("M000", &[])],
1358            vec![
1359                issue("I0001", "M000", &[]),
1360                issue("I0002", "M000", &["I0001"]),
1361                issue("I0003", "M000", &["I0001"]),
1362                issue("I0004", "M000", &["I0002", "I0003"]),
1363            ],
1364        );
1365        assert_eq!(p.issue("I0001").unwrap().wave, 0);
1366        assert_eq!(p.issue("I0002").unwrap().wave, 1);
1367        assert_eq!(p.issue("I0003").unwrap().wave, 1);
1368        assert_eq!(p.issue("I0004").unwrap().wave, 2);
1369        assert_eq!(p.issue("I0001").unwrap().status, "READY");
1370        assert_eq!(p.issue("I0004").unwrap().status, "BLOCKED");
1371        assert_eq!(
1372            p.issue("I0004").unwrap().blocked_by,
1373            vec!["I0002".to_string(), "I0003".to_string()]
1374        );
1375        assert_eq!(p.issue("I0001").unwrap().dependents.len(), 2);
1376        assert_eq!(p.waves.len(), 3);
1377        assert_eq!(p.next_ready(None).unwrap().id, "I0001");
1378        assert_eq!(p.failures(), 0);
1379    }
1380
1381    /// Status is derived from what happened to a record, never stored, and evidence is what
1382    /// separates VERIFY from DONE.
1383    #[test]
1384    fn completion_without_evidence_stays_in_verify() {
1385        let mut i = issue("I0001", "M000", &[]);
1386        i.stamp_with_seal(Transition::Done, "2026-01-01T00:00:00Z");
1387        let p = derive(header(), vec![milestone("M000", &[])], vec![i.clone()]);
1388        assert_eq!(p.issue("I0001").unwrap().status, "VERIFY");
1389        assert!(p
1390            .findings
1391            .iter()
1392            .any(|f| f.code == "evidence_missing" && f.subject == "I0001"));
1393
1394        i.evidence_have = vec!["proof".into()];
1395        let p = derive(header(), vec![milestone("M000", &[])], vec![i]);
1396        assert_eq!(p.issue("I0001").unwrap().status, "DONE");
1397        assert_eq!(p.milestone("M000").unwrap().status, "VERIFY");
1398    }
1399
1400    /// A cycle is reported by name and nobody in it is given a wave, rather than the whole
1401    /// graph quietly producing an empty ready set.
1402    #[test]
1403    fn a_cycle_is_a_named_failure() {
1404        let p = derive(
1405            header(),
1406            vec![milestone("M000", &[])],
1407            vec![
1408                issue("I0001", "M000", &["I0002"]),
1409                issue("I0002", "M000", &["I0001"]),
1410            ],
1411        );
1412        let f = p.findings.iter().find(|f| f.code == "cycle").unwrap();
1413        assert_eq!(f.level, "FAIL");
1414        assert_eq!(f.subject, "graph");
1415        assert!(f.message.contains("I0001"));
1416        assert!(f.message.contains("I0002"));
1417        assert!(p.waves.is_empty());
1418    }
1419
1420    /// The milestone gate reaches the work: an issue whose own dependencies are all done is
1421    /// still not executable inside a milestone whose prerequisites are not real.
1422    #[test]
1423    fn the_milestone_gate_blocks_the_issues_underneath_it() {
1424        let p = derive(
1425            header(),
1426            vec![milestone("M000", &[]), milestone("M001", &["M000"])],
1427            vec![issue("I0001", "M000", &[]), issue("I0002", "M001", &[])],
1428        );
1429        assert_eq!(p.milestone("M001").unwrap().status, "BLOCKED");
1430        assert_eq!(p.milestone("M001").unwrap().blocked_by, vec!["M000"]);
1431        assert_eq!(p.issue("I0002").unwrap().status, "BLOCKED");
1432        // the marker names the milestone whose gate holds the issue — its own — not the
1433        // milestone that milestone is waiting on; `plan.milestone(...).blocked_by` says that
1434        assert_eq!(p.issue("I0002").unwrap().blocked_by, vec!["milestone:M001"]);
1435        // and the blocked milestone's counts follow the statuses the gate assigned
1436        assert_eq!(
1437            *p.milestone("M001")
1438                .unwrap()
1439                .counts
1440                .by_status
1441                .get("BLOCKED")
1442                .unwrap(),
1443            1
1444        );
1445        assert_eq!(
1446            *p.milestone("M001")
1447                .unwrap()
1448                .counts
1449                .by_status
1450                .get("READY")
1451                .unwrap(),
1452            0
1453        );
1454        // the roadmap ranks the gated milestone after the one it waits on
1455        assert_eq!(p.milestone("M000").unwrap().rank, 0);
1456        assert_eq!(p.milestone("M001").unwrap().rank, 1);
1457        assert_eq!(p.project.active_milestone, "M000");
1458        assert_eq!(p.next_ready(None).unwrap().id, "I0001");
1459    }
1460
1461    /// Two issues of one wave that touch the same paths are serialised, and said so.
1462    #[test]
1463    fn overlapping_scope_in_one_wave_is_a_warning() {
1464        let mut a = issue("I0001", "M000", &[]);
1465        let mut b = issue("I0002", "M000", &[]);
1466        a.scope = vec!["apps/cli".into()];
1467        b.scope = vec!["apps/cli/src/main.rs".into()];
1468        let p = derive(header(), vec![milestone("M000", &[])], vec![a, b]);
1469        let f = p
1470            .findings
1471            .iter()
1472            .find(|f| f.code == "scope_conflict")
1473            .unwrap();
1474        assert_eq!(f.level, "WARN");
1475        assert_eq!(f.subject, "I0001");
1476        assert!(f.message.contains("I0002"));
1477    }
1478
1479    /// A record naming a dependency that does not exist is refused by name, and the
1480    /// declaration is still reported so a reader sees what the file says.
1481    #[test]
1482    fn an_unknown_dependency_is_refused_by_name() {
1483        let p = derive(
1484            header(),
1485            vec![milestone("M000", &[])],
1486            vec![issue("I0001", "M000", &["I9999"])],
1487        );
1488        let f = p
1489            .findings
1490            .iter()
1491            .find(|f| f.code == "unknown_dependency")
1492            .unwrap();
1493        assert_eq!(f.level, "FAIL");
1494        assert_eq!(f.message, "depends on I9999, which is not an issue");
1495        assert_eq!(p.issue("I0001").unwrap().depends_on, vec!["I9999"]);
1496        assert!(p.issue("I0001").unwrap().blocked_by.is_empty());
1497    }
1498
1499    /// The next issue is the lowest wave, then the highest priority, then the id — and the
1500    /// search widens past the active milestone rather than answering "none".
1501    #[test]
1502    fn the_next_issue_is_ranked_not_picked() {
1503        let mut low = issue("I0002", "M000", &[]);
1504        low.priority = "p0".into();
1505        let p = derive(
1506            header(),
1507            vec![milestone("M000", &[])],
1508            vec![issue("I0001", "M000", &[]), low],
1509        );
1510        assert_eq!(p.next_ready(None).unwrap().id, "I0002");
1511        assert_eq!(p.next_ready(Some("M999")).unwrap().id, "I0002");
1512    }
1513}
1514
1515// ---------------------------------------------------------------- transitions
1516//
1517// The write half of the plan, and the first mutating capability of the development
1518// lifecycle. Everything above this line derives; everything below it changes one field of
1519// one record and records that it did.
1520//
1521// `lib/plan.sh`'s `mj_plan_transition` has been the only implementation of these three
1522// moves. This is the second, and the contract it is held to is byte equality of the file it
1523// writes — `test/cases/133_plan_transition.sh` performs the same transition through both
1524// engines over the same fixture and diffs the record. The guards are stated here in the
1525// same order the shell states them, because the *first* refusal a caller meets is part of
1526// the behaviour: an issue that is both blocked and missing its evidence must be told about
1527// the blocker, which is the thing it can act on.
1528
1529/// One lifecycle move.
1530///
1531/// Three, not four: `evidence` appends a record rather than moving an issue, and it belongs
1532/// with the record it proves rather than with the moves it unblocks.
1533///
1534/// ```
1535/// use majordomus_cli::plan::Transition;
1536///
1537/// // The wire spelling is the one a caller types on the command line.
1538/// let t: Transition = serde_json::from_str("\"done\"").unwrap();
1539/// assert_eq!(t, Transition::Done);
1540/// assert_eq!(serde_json::to_string(&Transition::Start).unwrap(), "\"start\"");
1541///
1542/// // Each move owns the field it stamps and the event it records; nothing else decides
1543/// // either, so a new move cannot be added without saying both.
1544/// for (t, field, event) in [
1545///     (Transition::Start, "started_at", "plan_start"),
1546///     (Transition::Verify, "verified_at", "plan_verify"),
1547///     (Transition::Done, "completed_at", "plan_done"),
1548/// ] {
1549///     assert_eq!(t.field(), field);
1550///     assert_eq!(t.event(), event);
1551/// }
1552/// ```
1553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1554#[serde(rename_all = "lowercase")]
1555// `session::state::Transition` already publishes the bare name, and one schema component
1556// cannot mean two things: the move an issue makes is named for the plan it belongs to.
1557#[schemars(rename = "PlanTransition")]
1558pub enum Transition {
1559    /// Execution began. Refused unless the issue is READY.
1560    Start,
1561    /// Implementation is complete and the evidence is still outstanding.
1562    Verify,
1563    /// Complete. Refused while anything it waits on is unfinished, or while any evidence
1564    /// its own record requires is absent.
1565    Done,
1566}
1567
1568impl Transition {
1569    /// The record field this move stamps, and the only field it writes besides `updated_at`.
1570    ///
1571    /// The status is never stored: an issue records what happened to it and the status
1572    /// follows. So a move owns a timestamp, not a state, and this names which one.
1573    ///
1574    /// ```
1575    /// use majordomus_cli::plan::Transition;
1576    /// assert_eq!(Transition::Start.field(), "started_at");
1577    /// assert_eq!(Transition::Done.field(), "completed_at");
1578    /// ```
1579    pub fn field(self) -> &'static str {
1580        match self {
1581            Self::Start => "started_at",
1582            Self::Verify => "verified_at",
1583            Self::Done => "completed_at",
1584        }
1585    }
1586
1587    /// The event `share/events.yaml` declares for it.
1588    ///
1589    /// The four plan events are spelled with an underscore rather than the dot the rest of
1590    /// the vocabulary uses. They are registered as written, because the ledger of this
1591    /// repository already holds dozens of them and a rename would orphan every one.
1592    ///
1593    /// ```
1594    /// use majordomus_cli::plan::Transition;
1595    /// assert_eq!(Transition::Verify.event(), "plan_verify");
1596    /// assert!(Transition::Done.event().starts_with("plan_"));
1597    /// ```
1598    pub fn event(self) -> &'static str {
1599        match self {
1600            Self::Start => "plan_start",
1601            Self::Verify => "plan_verify",
1602            Self::Done => "plan_done",
1603        }
1604    }
1605
1606    /// The record field that carries this move's [`seal`], beside the stamp it proves.
1607    ///
1608    /// ```
1609    /// use majordomus_cli::plan::Transition;
1610    /// assert_eq!(Transition::Start.seal_field(), "started_event");
1611    /// assert_eq!(Transition::Verify.seal_field(), "verified_event");
1612    /// assert_eq!(Transition::Done.seal_field(), "completed_event");
1613    /// ```
1614    pub fn seal_field(self) -> &'static str {
1615        match self {
1616            Self::Start => "started_event",
1617            Self::Verify => "verified_event",
1618            Self::Done => "completed_event",
1619        }
1620    }
1621
1622    /// The word a caller types for this move: `majordomus plan <verb> <id>`.
1623    ///
1624    /// ```
1625    /// use majordomus_cli::plan::Transition;
1626    /// assert_eq!(Transition::Start.verb(), "start");
1627    /// assert_eq!(Transition::Verify.verb(), "verify");
1628    /// assert_eq!(Transition::Done.verb(), "done");
1629    /// ```
1630    pub fn verb(self) -> &'static str {
1631        match self {
1632            Self::Start => "start",
1633            Self::Verify => "verify",
1634            Self::Done => "done",
1635        }
1636    }
1637}
1638
1639/// Why a transition did not happen.
1640///
1641/// Each variant carries what the caller needs to act, never only that it failed: which
1642/// status the issue is actually in, which dependency holds it, which evidence token is
1643/// missing. The messages are the shell's messages.
1644///
1645/// ```
1646/// use majordomus_cli::plan::TransitionError;
1647///
1648/// // A refusal says what is in the way, because that is the thing the caller can change.
1649/// let refused = TransitionError::Refused("I0001 is DONE, not READY".into());
1650/// assert_eq!(refused.to_string(), "I0001 is DONE, not READY");
1651///
1652/// // A missing issue is a different answer from an illegal move, and callers map them to
1653/// // different statuses: not_found rather than refused.
1654/// let missing = TransitionError::NoSuchIssue("I9999".into());
1655/// assert_eq!(missing.to_string(), "no issue 'I9999'");
1656/// assert_ne!(missing, refused);
1657/// ```
1658#[derive(Debug, Clone, PartialEq, Eq)]
1659pub enum TransitionError {
1660    /// No issue of that id.
1661    NoSuchIssue(String),
1662    /// The move is illegal from the status the issue is in.
1663    Refused(String),
1664    /// The record could not be rewritten.
1665    Write {
1666        /// The record.
1667        path: std::path::PathBuf,
1668        /// What the filesystem said.
1669        reason: String,
1670    },
1671}
1672
1673impl std::fmt::Display for TransitionError {
1674    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1675        match self {
1676            Self::NoSuchIssue(id) => write!(f, "no issue '{id}'"),
1677            Self::Refused(m) => f.write_str(m),
1678            Self::Write { path, reason } => write!(f, "{}: {reason}", path.display()),
1679        }
1680    }
1681}
1682
1683impl std::error::Error for TransitionError {}
1684
1685/// Whether `transition` is legal for `issue` as the plan reads now, and why not when it is
1686/// not.
1687///
1688/// Separate from the write so that a surface can offer or withhold a control without
1689/// performing it: the Cockpit disables a button by asking this, and never by reimplementing
1690/// the rule.
1691///
1692/// ```no_run
1693/// # use majordomus_cli::index::Index;
1694/// use majordomus_cli::plan::{check, Plan, Transition, TransitionError};
1695/// # fn demo(index: &Index) {
1696/// let plan = Plan::build(index);
1697/// let now = "2026-09-11T12:00:00Z";
1698/// // Offer the control only when the model would accept it, and show the reason when not.
1699/// let offered = check(index, &plan, "I0001", Transition::Start, now).is_ok();
1700///
1701/// // An issue the plan does not hold is not found, never merely refused: the two are
1702/// // different answers and a surface maps them to different statuses.
1703/// let missing = check(index, &plan, "no-such-issue", Transition::Start, now);
1704/// assert_eq!(missing, Err(TransitionError::NoSuchIssue("no-such-issue".into())));
1705///
1706/// // Asking twice never changes anything: this is the question, not the move.
1707/// assert_eq!(offered, check(index, &plan, "I0001", Transition::Start, now).is_ok());
1708/// # }
1709/// ```
1710pub fn check(
1711    index: &Index,
1712    plan: &Plan,
1713    id: &str,
1714    transition: Transition,
1715    now: &str,
1716) -> Result<(), TransitionError> {
1717    let issue = plan
1718        .issue(id)
1719        .ok_or_else(|| TransitionError::NoSuchIssue(id.to_string()))?;
1720    match transition {
1721        Transition::Start => {
1722            if issue.status != "READY" {
1723                let waiting = if issue.blocked_by.is_empty() {
1724                    String::new()
1725                } else {
1726                    format!(" (waiting on {})", issue.blocked_by.join(" "))
1727                };
1728                return Err(TransitionError::Refused(format!(
1729                    "{id} is {}, not READY{waiting}",
1730                    issue.status
1731                )));
1732            }
1733            // where the policy requires binding, a transition is held to it as a task is
1734            if let Some(why) = crate::intent_binding::start_refusal(index, plan, id) {
1735                return Err(TransitionError::Refused(why));
1736            }
1737        }
1738        Transition::Verify => {
1739            if !matches!(issue.status.as_str(), "ACTIVE" | "VERIFY") {
1740                return Err(TransitionError::Refused(format!(
1741                    "{id} is {}; only an ACTIVE issue can move to VERIFY",
1742                    issue.status
1743                )));
1744            }
1745        }
1746        Transition::Done => {
1747            // The blocker first: an issue that is both blocked and short of evidence is
1748            // told about the blocker, because that is the one it cannot act on alone.
1749            if !issue.blocked_by.is_empty() {
1750                return Err(TransitionError::Refused(format!(
1751                    "{id} cannot be DONE while {} is not DONE",
1752                    issue.blocked_by.join(" ")
1753                )));
1754            }
1755            // Done ends the lifecycle; it does not replace it. An issue is DONE only after
1756            // it was started, so READY, BLOCKED, CANCELLED and DONE itself are refused: the
1757            // stamp would otherwise claim an execution no `plan_start` ever recorded, and a
1758            // cancelled issue would be given a completion the plan had given up on.
1759            if !matches!(issue.status.as_str(), "ACTIVE" | "VERIFY") {
1760                return Err(TransitionError::Refused(format!(
1761                    "{id} is {}; only an ACTIVE or VERIFY issue can move to DONE",
1762                    issue.status
1763                )));
1764            }
1765            // Whether the evidence is complete is not a count — a record may carry two
1766            // entries covering one token and none covering another — and the rule for it
1767            // already exists inside `derive`. So rather than restate it, ask what the plan
1768            // would say: write the stamp in memory and see whether the issue reaches DONE.
1769            // An issue that stays VERIFY with `completed_at` set is exactly the record
1770            // `derive` reports as "completed_at is set but evidence is missing".
1771            let after = Plan::after(index, id, transition, now);
1772            if after.issue(id).is_some_and(|i| i.status != "DONE") {
1773                return Err(TransitionError::Refused(format!(
1774                    "{id} has evidence for {} of {} requirement(s) (run: majordomus plan evidence {id} --covers <token> ...)",
1775                    issue.evidence_have, issue.evidence_need
1776                )));
1777            }
1778        }
1779    }
1780    Ok(())
1781}
1782
1783/// Perform one transition: check it, stamp the record, append the event.
1784///
1785/// The order is the shell's and is not an implementation detail. The guard runs against the
1786/// plan as it reads now; the record is rewritten; only then is the event appended. A ledger
1787/// line therefore never describes a change that did not reach the file, which is the
1788/// property that lets the record be read as history rather than as intent.
1789///
1790/// `path` is the issue's own file, as the index recorded it. It is passed in rather than
1791/// derived from the id here, because the index already knows where every record lives and a
1792/// second convention for locating one is how two readers come to disagree about which file
1793/// is the record.
1794///
1795/// ```no_run
1796/// # use std::path::Path;
1797/// # use majordomus_cli::index::Index;
1798/// # use majordomus_cli::git::GitState;
1799/// use majordomus_cli::ledger::{self, Vocabulary};
1800/// use majordomus_cli::plan::{transition, Plan, Transition};
1801/// # fn demo(index: &Index, root: &Path, record: &Path, git: &GitState) {
1802/// let plan = Plan::build(index);
1803/// let vocabulary = Vocabulary::load(&root.join("share/events.yaml")).unwrap();
1804/// let at = ledger::now();
1805/// // The guard runs first, the record is rewritten, and only then is the event appended:
1806/// // a ledger line never describes a change that did not reach the file.
1807/// transition(root, record, index, &plan, "I0001", Transition::Start, &at, &vocabulary, git)
1808///     .expect("a READY issue may start");
1809/// # }
1810/// ```
1811#[allow(clippy::too_many_arguments)]
1812pub fn transition(
1813    root: &std::path::Path,
1814    path: &std::path::Path,
1815    index: &Index,
1816    plan: &Plan,
1817    id: &str,
1818    transition: Transition,
1819    now: &str,
1820    vocabulary: &crate::ledger::Vocabulary,
1821    git: &crate::git::GitState,
1822) -> Result<(), TransitionError> {
1823    check(index, plan, id, transition, now)?;
1824    let text = std::fs::read_to_string(path).map_err(|e| TransitionError::Write {
1825        path: path.to_path_buf(),
1826        reason: e.to_string(),
1827    })?;
1828    // The fields in the order the shell writes them: the move's own stamp, its seal, then
1829    // `updated_at`. A record whose `updated_at` preceded its `completed_at` would be a
1830    // record that says it was finished before it was last touched.
1831    // The seal is written with the stamp, in the same write, because it is the only part of
1832    // the event a clone carries: the ledger line below stays in this checkout (ADR 0097).
1833    let text = set_field(&text, transition.field(), now);
1834    let text = set_field(&text, transition.seal_field(), &seal(transition, id, now));
1835    let text = set_field(&text, "updated_at", now);
1836    std::fs::write(path, text).map_err(|e| TransitionError::Write {
1837        path: path.to_path_buf(),
1838        reason: e.to_string(),
1839    })?;
1840    crate::ledger::append(
1841        root,
1842        vocabulary,
1843        git,
1844        now,
1845        transition.event(),
1846        &[("issue", id.to_string())],
1847    )
1848    .map_err(|e| TransitionError::Write {
1849        path: root.join(".ai/local/state/ledger.jsonl"),
1850        reason: e.to_string(),
1851    })?;
1852    Ok(())
1853}
1854
1855/// Set one top-level scalar field of a record, exactly as `mj_pj_set_field` in
1856/// `lib/project.sh` does.
1857///
1858/// The rule is small and every part of it is load-bearing:
1859///
1860/// * a line beginning `key:` is replaced in place, keeping its position in the file, so a
1861///   record's field order survives every write;
1862/// * otherwise the field is inserted immediately *before* `evidence:`, because evidence is
1863///   a block and a scalar appended after it would be read as one of its entries;
1864/// * with no `evidence:` line, the field is appended at the end.
1865///
1866/// Only the first `evidence:` line is an anchor, and matching is on the line's start, which
1867/// is what `grep -qE "^$key:"` and the awk beside it do. A nested key of the same name is
1868/// indented and is therefore not matched — the same blindness, deliberately, because the
1869/// two engines must be blind in the same places.
1870fn set_field(text: &str, key: &str, value: &str) -> String {
1871    let prefix = format!("{key}:");
1872    let line = format!("{key}: {value}");
1873    if text.lines().any(|l| l.starts_with(&prefix)) {
1874        let mut out = String::with_capacity(text.len() + line.len());
1875        for l in text.lines() {
1876            if l.starts_with(&prefix) {
1877                out.push_str(&line);
1878            } else {
1879                out.push_str(l);
1880            }
1881            out.push('\n');
1882        }
1883        return out;
1884    }
1885    let mut out = String::with_capacity(text.len() + line.len() + 1);
1886    let mut inserted = false;
1887    for l in text.lines() {
1888        if !inserted && l.starts_with("evidence:") {
1889            out.push_str(&line);
1890            out.push('\n');
1891            inserted = true;
1892        }
1893        out.push_str(l);
1894        out.push('\n');
1895    }
1896    if !inserted {
1897        out.push_str(&line);
1898        out.push('\n');
1899    }
1900    out
1901}
1902
1903#[cfg(test)]
1904mod transition_tests {
1905    use super::*;
1906
1907    /// Replacing in place keeps the field where the record put it: a write must not reorder
1908    /// a record a person reads.
1909    #[test]
1910    fn an_existing_field_is_replaced_where_it_stands() {
1911        let before = "id: I0001\nstarted_at: \ntitle: one\n";
1912        assert_eq!(
1913            set_field(before, "started_at", "2026-09-11T12:00:00Z"),
1914            "id: I0001\nstarted_at: 2026-09-11T12:00:00Z\ntitle: one\n"
1915        );
1916    }
1917
1918    /// A new scalar goes before `evidence:`, never after: appended at the end it would be
1919    /// indented under nothing and read as part of the evidence block.
1920    #[test]
1921    fn a_new_field_is_inserted_before_the_evidence_block() {
1922        let before = "id: I0001\ntitle: one\nevidence:\n  - covers: t\n";
1923        assert_eq!(
1924            set_field(before, "completed_at", "2026-09-11T12:00:00Z"),
1925            "id: I0001\ntitle: one\ncompleted_at: 2026-09-11T12:00:00Z\nevidence:\n  - covers: t\n"
1926        );
1927    }
1928
1929    /// With no evidence block there is nothing to sit before, and the field is appended.
1930    #[test]
1931    fn a_new_field_is_appended_when_there_is_no_evidence_block() {
1932        let before = "id: I0001\ntitle: one\n";
1933        assert_eq!(
1934            set_field(before, "completed_at", "2026-09-11T12:00:00Z"),
1935            "id: I0001\ntitle: one\ncompleted_at: 2026-09-11T12:00:00Z\n"
1936        );
1937    }
1938
1939    /// An indented key of the same name is not a top-level field and is not touched — the
1940    /// shell's `^key:` is anchored the same way, and the two must be blind together.
1941    #[test]
1942    fn an_indented_key_of_the_same_name_is_not_the_field() {
1943        let before = "id: I0001\nevidence:\n  - started_at: x\n";
1944        assert_eq!(
1945            set_field(before, "started_at", "T"),
1946            "id: I0001\nstarted_at: T\nevidence:\n  - started_at: x\n"
1947        );
1948    }
1949
1950    /// A record with no trailing newline gets one: every writer of this repository's YAML
1951    /// ends the file, and a record that lost its newline would move every later diff.
1952    #[test]
1953    fn the_record_ends_with_a_newline() {
1954        assert_eq!(set_field("id: I0001", "x", "y"), "id: I0001\nx: y\n");
1955    }
1956}
1957
1958#[cfg(test)]
1959mod seal_tests {
1960    //! No stamp without its event (ADR 0097): every branch of the seal, the derivation that
1961    //! reads it, the `done` precondition and the write that produces it.
1962    use super::*;
1963    use crate::git::GitState;
1964    use crate::index::{RepositoryInfo, State};
1965    use crate::model::{Object, Provenance};
1966    use serde_json::json;
1967
1968    const AT: &str = "2026-09-20T10:00:00Z";
1969
1970    fn raw(id: &str, extra: Value) -> PlanIssueRaw {
1971        let mut meta = json!({
1972            "id": id, "milestone": "M000", "title": "t", "priority": "p1",
1973            "acceptance_criteria": ["it works"], "validation": ["true"],
1974            "evidence_required": ["proof"], "evidence": [{"covers": "proof"}],
1975        });
1976        for (k, v) in extra.as_object().unwrap() {
1977            meta[k] = v.clone();
1978        }
1979        PlanIssueRaw::of(id, &meta)
1980    }
1981
1982    fn milestone() -> PlanMilestoneRaw {
1983        PlanMilestoneRaw::of("M000", &json!({"id": "M000", "title": "m", "order": 0}))
1984    }
1985
1986    fn header() -> PlanProject {
1987        PlanProject {
1988            name: "Fixture".into(),
1989            repository: "example/fixture".into(),
1990            default_branch: "master".into(),
1991            active_milestone: String::new(),
1992        }
1993    }
1994
1995    fn unproven(p: &Plan, id: &str) -> Vec<String> {
1996        p.findings
1997            .iter()
1998            .filter(|f| f.code == "stamp_without_event" && f.subject == id)
1999            .map(|f| {
2000                assert_eq!(f.level, "FAIL");
2001                f.message.clone()
2002            })
2003            .collect()
2004    }
2005
2006    /// The payload is fixed: the shell hashes the same bytes, and a change here is a change
2007    /// to every seal already written.
2008    #[test]
2009    fn the_seal_is_the_digest_of_the_event_the_issue_and_the_stamp() {
2010        let payload = format!("majordomus.plan-event/v1\nplan_done\nI0001\n{AT}\n");
2011        assert_eq!(
2012            seal(Transition::Done, "I0001", AT),
2013            format!("sha256:{}", crate::policy::sha256_hex(&payload))
2014        );
2015        assert_eq!(
2016            MOVES.map(Transition::seal_field),
2017            ["started_event", "verified_event", "completed_event"]
2018        );
2019        assert_eq!(MOVES.map(Transition::verb), ["start", "verify", "done"]);
2020    }
2021
2022    /// A completion written by hand, with its start, its verification and its evidence,
2023    /// derives nothing and fails once per stamp, naming the event that never happened.
2024    #[test]
2025    fn a_hand_written_completion_moves_nothing_and_is_named() {
2026        let forged = raw(
2027            "I0002",
2028            json!({"started_at": AT, "verified_at": AT, "completed_at": AT}),
2029        );
2030        let p = derive(header(), vec![milestone()], vec![forged]);
2031        assert_eq!(p.issue("I0002").unwrap().status, "READY");
2032        assert_eq!(
2033            unproven(&p, "I0002"),
2034            vec![
2035                format!("started_at {AT} has no plan_start event: started_event is absent or does not seal it, and only `majordomus plan start` writes the stamp"),
2036                format!("verified_at {AT} has no plan_verify event: verified_event is absent or does not seal it, and only `majordomus plan verify` writes the stamp"),
2037                format!("completed_at {AT} has no plan_done event: completed_event is absent or does not seal it, and only `majordomus plan done` writes the stamp"),
2038            ]
2039        );
2040        assert_eq!(p.failures(), 3);
2041        // the recorded stamps are still shown as recorded; only the status ignores them
2042        assert_eq!(p.issue("I0002").unwrap().completed_at, AT);
2043    }
2044
2045    /// Sealed stamps are facts, in every one of the three moves.
2046    #[test]
2047    fn sealed_stamps_are_facts() {
2048        let mut i = raw("I0001", json!({}));
2049        i.stamp_with_seal(Transition::Start, AT);
2050        let p = derive(header(), vec![milestone()], vec![i.clone()]);
2051        assert_eq!(p.issue("I0001").unwrap().status, "ACTIVE");
2052        i.stamp_with_seal(Transition::Verify, AT);
2053        let p = derive(header(), vec![milestone()], vec![i.clone()]);
2054        assert_eq!(p.issue("I0001").unwrap().status, "VERIFY");
2055        i.stamp_with_seal(Transition::Done, AT);
2056        let p = derive(header(), vec![milestone()], vec![i.clone()]);
2057        assert_eq!(p.issue("I0001").unwrap().status, "DONE");
2058        assert!(
2059            p.findings.iter().all(|f| f.level != "FAIL"),
2060            "{:?}",
2061            p.findings
2062        );
2063        assert_eq!(i.completed_event, seal(Transition::Done, "I0001", AT));
2064    }
2065
2066    /// A seal proves one stamp: borrowed from another issue, or left behind when the stamp
2067    /// was edited, it proves nothing.
2068    #[test]
2069    fn a_seal_of_another_stamp_proves_nothing() {
2070        let borrowed = raw(
2071            "I0002",
2072            json!({"started_at": AT, "started_event": seal(Transition::Start, "I0001", AT)}),
2073        );
2074        let edited = raw(
2075            "I0003",
2076            json!({"started_at": "2026-09-20T11:00:00Z",
2077                   "started_event": seal(Transition::Start, "I0003", AT)}),
2078        );
2079        let p = derive(header(), vec![milestone()], vec![borrowed, edited]);
2080        assert_eq!(unproven(&p, "I0002").len(), 1);
2081        assert_eq!(unproven(&p, "I0003").len(), 1);
2082        assert_eq!(p.issue("I0002").unwrap().status, "READY");
2083        assert_eq!(p.issue("I0003").unwrap().status, "READY");
2084    }
2085
2086    /// The record's list excuses exactly the stamps it names, and only those older than the
2087    /// instant seals began.
2088    #[test]
2089    fn the_list_excuses_only_named_stamps_from_before_seals() {
2090        let old = "2026-09-01T00:00:00Z";
2091        let named = raw(
2092            "I0001",
2093            json!({"started_at": old, "unsealed_stamps": [format!("started_at {old}")]}),
2094        );
2095        let unnamed = raw(
2096            "I0002",
2097            json!({"started_at": old, "unsealed_stamps": ["started_at 2026-08-01T00:00:00Z"]}),
2098        );
2099        let late = raw(
2100            "I0003",
2101            json!({"started_at": SEALED_FROM,
2102                   "unsealed_stamps": [format!("started_at {SEALED_FROM}")]}),
2103        );
2104        let p = derive(header(), vec![milestone()], vec![named, unnamed, late]);
2105        assert_eq!(p.issue("I0001").unwrap().status, "ACTIVE");
2106        assert!(unproven(&p, "I0001").is_empty());
2107        assert_eq!(unproven(&p, "I0002").len(), 1);
2108        assert_eq!(unproven(&p, "I0003").len(), 1);
2109    }
2110
2111    // ------------------------------------------------------------ over an index, and a file
2112
2113    fn object(kind: &str, id: &str, path: &str, metadata: Value) -> Object {
2114        Object {
2115            kind: kind.into(),
2116            identity: id.into(),
2117            uri: format!("majordomus://{kind}/{id}"),
2118            title: None,
2119            description: None,
2120            metadata,
2121            body: String::new(),
2122            content: String::new(),
2123            media_type: "application/yaml",
2124            provenance: Provenance {
2125                path: path.into(),
2126                directory: ".ai/repo/project".into(),
2127                source_class: kind.into(),
2128                section: None,
2129                bytes: 0,
2130                member: None,
2131            },
2132        }
2133    }
2134
2135    fn index(root: &std::path::Path, objects: Vec<Object>) -> Index {
2136        Index {
2137            repository: RepositoryInfo {
2138                root: root.display().to_string(),
2139                layer_schema: "ai-repository/v1".into(),
2140                sections: Default::default(),
2141                git: GitState::Unavailable {
2142                    reason: "unit test".into(),
2143                },
2144                discovery: "filesystem".into(),
2145                source_classes: vec![],
2146                kind_sources: vec![],
2147                scope_origin: crate::scope::Origin::Distribution,
2148                scope_path: String::new(),
2149                observed: Default::default(),
2150            },
2151            objects,
2152            diagnostics: vec![],
2153            state: State::Ok,
2154            fingerprint: String::new(),
2155            scoped: Default::default(),
2156            distribution: None,
2157            providers: Default::default(),
2158            share: None,
2159        }
2160    }
2161
2162    fn issue_meta(id: &str, extra: Value) -> Value {
2163        let mut meta = json!({
2164            "id": id, "milestone": "M000", "title": "t", "priority": "p1",
2165            "acceptance_criteria": ["it works"], "validation": ["true"],
2166            "evidence_required": ["proof"], "evidence": [{"covers": "proof"}],
2167        });
2168        for (k, v) in extra.as_object().unwrap() {
2169            meta[k] = v.clone();
2170        }
2171        meta
2172    }
2173
2174    /// `done` requires a started issue: READY and CANCELLED are refused by status, ACTIVE
2175    /// and VERIFY are not, and a record's list reaches the plan through the index.
2176    #[test]
2177    fn done_requires_a_started_issue() {
2178        let old = "2026-09-01T00:00:00Z";
2179        let ix = index(
2180            std::path::Path::new("/tmp/mj-plan-seal"),
2181            vec![
2182                object(
2183                    MILESTONE,
2184                    "M000",
2185                    ".ai/repo/project/milestones/M000.yaml",
2186                    json!({"id": "M000", "title": "m", "order": 0}),
2187                ),
2188                object(
2189                    ISSUE,
2190                    "I0001",
2191                    ".ai/repo/project/issues/I0001.yaml",
2192                    issue_meta("I0001", json!({})),
2193                ),
2194                object(
2195                    ISSUE,
2196                    "I0002",
2197                    ".ai/repo/project/issues/I0002.yaml",
2198                    issue_meta("I0002", json!({"cancelled": true})),
2199                ),
2200                object(
2201                    ISSUE,
2202                    "I0003",
2203                    ".ai/repo/project/issues/I0003.yaml",
2204                    issue_meta(
2205                        "I0003",
2206                        json!({"started_at": old,
2207                           "unsealed_stamps": [format!("started_at {old}")]}),
2208                    ),
2209                ),
2210                object(
2211                    ISSUE,
2212                    "I0004",
2213                    ".ai/repo/project/issues/I0004.yaml",
2214                    issue_meta(
2215                        "I0004",
2216                        json!({"started_at": AT,
2217                           "started_event": seal(Transition::Start, "I0004", AT),
2218                           "verified_at": AT,
2219                           "verified_event": seal(Transition::Verify, "I0004", AT)}),
2220                    ),
2221                ),
2222            ],
2223        );
2224        let plan = Plan::build(&ix);
2225        assert_eq!(plan.failures(), 0, "{:?}", plan.findings);
2226        let now = "2026-09-20T12:00:00Z";
2227        assert_eq!(
2228            check(&ix, &plan, "I0001", Transition::Done, now),
2229            Err(TransitionError::Refused(
2230                "I0001 is READY; only an ACTIVE or VERIFY issue can move to DONE".into()
2231            ))
2232        );
2233        assert_eq!(
2234            check(&ix, &plan, "I0002", Transition::Done, now),
2235            Err(TransitionError::Refused(
2236                "I0002 is CANCELLED; only an ACTIVE or VERIFY issue can move to DONE".into()
2237            ))
2238        );
2239        assert_eq!(check(&ix, &plan, "I0003", Transition::Done, now), Ok(()));
2240        assert_eq!(check(&ix, &plan, "I0004", Transition::Done, now), Ok(()));
2241        // the projection of the write is sealed, so the issue it describes is DONE
2242        let after = Plan::after(&ix, "I0004", Transition::Done, now);
2243        assert_eq!(after.issue("I0004").unwrap().status, "DONE");
2244        assert_eq!(after.failures(), 0);
2245    }
2246
2247    /// The write: the stamp, its seal and `updated_at` reach the file, then the event the
2248    /// ledger. The record then validates; the same record with the seal removed does not.
2249    #[test]
2250    fn the_transition_writes_the_seal_with_the_stamp() {
2251        let dir = tempfile::tempdir().unwrap();
2252        let root = dir.path();
2253        let rel = ".ai/repo/project/issues/I0001.yaml";
2254        std::fs::create_dir_all(root.join(".ai/repo/project/issues")).unwrap();
2255        std::fs::write(root.join(rel), "id: I0001\ntitle: t\n").unwrap();
2256        std::fs::write(
2257            root.join("events.yaml"),
2258            "version: 1\nevents:\n  - id: plan_start\n    requires: [issue]\n",
2259        )
2260        .unwrap();
2261        let vocabulary = crate::ledger::Vocabulary::load(&root.join("events.yaml")).unwrap();
2262        let ix = index(
2263            root,
2264            vec![
2265                object(
2266                    MILESTONE,
2267                    "M000",
2268                    ".ai/repo/project/milestones/M000.yaml",
2269                    json!({"id": "M000", "title": "m", "order": 0}),
2270                ),
2271                object(ISSUE, "I0001", rel, issue_meta("I0001", json!({}))),
2272            ],
2273        );
2274        let plan = Plan::build(&ix);
2275        transition(
2276            root,
2277            &root.join(rel),
2278            &ix,
2279            &plan,
2280            "I0001",
2281            Transition::Start,
2282            AT,
2283            &vocabulary,
2284            &ix.repository.git,
2285        )
2286        .expect("a READY issue starts");
2287        let written = std::fs::read_to_string(root.join(rel)).unwrap();
2288        assert_eq!(
2289            written,
2290            format!(
2291                "id: I0001\ntitle: t\nstarted_at: {AT}\nstarted_event: {}\nupdated_at: {AT}\n",
2292                seal(Transition::Start, "I0001", AT)
2293            )
2294        );
2295        let (entries, _) = crate::ledger::read(root);
2296        assert_eq!(entries.len(), 1);
2297    }
2298}