1use std::collections::{BTreeMap, BTreeSet};
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use crate::index::Index;
32
33pub const MILESTONE: &str = "milestone";
35pub const ISSUE: &str = "issue";
37pub const PROJECT: &str = "project";
39
40pub const ISSUE_STATUSES: &[&str] = &["READY", "BLOCKED", "ACTIVE", "VERIFY", "DONE", "CANCELLED"];
44
45pub const MILESTONE_STATUSES: &[&str] = &[
47 "PLANNED",
48 "BLOCKED",
49 "ACTIVE",
50 "VERIFY",
51 "DONE",
52 "CANCELLED",
53 "SUPERSEDED",
54];
55
56pub 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
89fn 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
100fn 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
113fn clean(s: &str) -> String {
115 s.replace('\t', " ")
116}
117
118pub const SEALED_FROM: &str = "2026-10-01T00:00:00Z";
127
128pub 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
157const MOVES: [Transition; 3] = [Transition::Start, Transition::Verify, Transition::Done];
159
160#[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#[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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305pub struct PlanProject {
306 pub name: String,
308 pub repository: String,
310 pub default_branch: String,
312 pub active_milestone: String,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
321pub struct PlanVocabulary {
322 pub issue: Vec<String>,
324 pub milestone: Vec<String>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
330pub struct PlanCounts {
331 pub total: u32,
333 pub required: u32,
336 pub by_status: BTreeMap<String, u32>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342pub struct PlanIssue {
343 pub id: String,
345 pub milestone: String,
347 pub status: String,
350 pub wave: u32,
352 pub priority: String,
354 pub profile: String,
356 pub parallel_safe: bool,
358 pub title: String,
360 pub slug: String,
362 pub depends_on: Vec<String>,
365 pub blocked_by: Vec<String>,
368 pub dependents: Vec<String>,
370 pub scope: Vec<String>,
372 pub serves: Vec<String>,
376 pub objective: String,
378 pub evidence_have: u32,
380 pub evidence_need: u32,
382 pub started_at: String,
384 pub verified_at: String,
386 pub completed_at: String,
388}
389
390impl 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
400pub struct PlanMilestone {
401 pub id: String,
403 pub status: String,
405 pub order: i64,
407 pub priority: String,
409 pub title: String,
411 pub slug: String,
413 pub version: String,
415 pub rank: u32,
417 pub depends_on: Vec<String>,
419 pub blocked_by: Vec<String>,
422 pub dependents: Vec<String>,
424 pub claims: Vec<String>,
426 pub counts: PlanCounts,
428 pub issues: Vec<String>,
430 pub outcome: String,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
436pub struct PlanWave {
437 pub wave: u32,
439 pub issues: Vec<String>,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
445pub struct PlanEdge {
446 pub from: String,
448 pub to: String,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
454pub struct PlanFinding {
455 pub level: String,
457 pub code: String,
459 pub subject: String,
461 pub message: String,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
467pub struct Plan {
468 pub project: PlanProject,
470 pub statuses: PlanVocabulary,
472 pub milestones: Vec<PlanMilestone>,
474 pub issues: Vec<PlanIssue>,
476 pub waves: Vec<PlanWave>,
478 pub edges: Vec<PlanEdge>,
480 pub milestone_edges: Vec<PlanEdge>,
482 pub findings: Vec<PlanFinding>,
484}
485
486impl Plan {
487 pub fn issue(&self, id: &str) -> Option<&PlanIssue> {
489 self.issues.iter().find(|i| i.id == id)
490 }
491
492 pub fn milestone(&self, id: &str) -> Option<&PlanMilestone> {
494 self.milestones.iter().find(|m| m.id == id)
495 }
496
497 pub fn failures(&self) -> usize {
499 self.findings.iter().filter(|f| f.level == "FAIL").count()
500 }
501
502 pub fn warnings(&self) -> usize {
504 self.findings.iter().filter(|f| f.level == "WARN").count()
505 }
506
507 pub fn is_present(&self) -> bool {
509 !self.milestones.is_empty() || !self.issues.is_empty()
510 }
511
512 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 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 pub fn build(index: &Index) -> Plan {
548 let (header, milestones, issues) = raws(index);
549 derive(header, milestones, issues)
550 }
551
552 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
583fn 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 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
624struct 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
647pub 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)] fn 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 let proven: Vec<[bool; 3]> = iraw.iter().map(|r| MOVES.map(|t| r.proven(t))).collect();
678 let stamped =
680 |n: usize, t: Transition| -> bool { !iraw[n].stamp(t).is_empty() && proven[n][t as usize] };
681
682 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1276fn order_of(raw: &str) -> i64 {
1279 raw.trim().parse::<i64>().unwrap_or(0)
1280}
1281
1282fn 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 #[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 #[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 #[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 #[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 assert_eq!(p.issue("I0002").unwrap().blocked_by, vec!["milestone:M001"]);
1435 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 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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1554#[serde(rename_all = "lowercase")]
1555#[schemars(rename = "PlanTransition")]
1558pub enum Transition {
1559 Start,
1561 Verify,
1563 Done,
1566}
1567
1568impl Transition {
1569 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
1659pub enum TransitionError {
1660 NoSuchIssue(String),
1662 Refused(String),
1664 Write {
1666 path: std::path::PathBuf,
1668 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
1685pub 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 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 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 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 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#[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 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
1855fn 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 assert_eq!(p.issue("I0002").unwrap().completed_at, AT);
2043 }
2044
2045 #[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 #[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 #[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 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 #[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 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 #[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}