1use std::collections::{BTreeMap, BTreeSet};
38use std::path::Path;
39
40use schemars::JsonSchema;
41use serde::{Deserialize, Serialize};
42
43use crate::capability::builtin::continuity::{document, read_task};
44use crate::index::Index;
45use crate::intent::{
46 IntentEvidenceState, IntentFinding, IntentStage, IntentVerdictState, IntentView, Intents, WARN,
47};
48use crate::intent_plan::{CoverageStrength, CriterionCoverage, IntentCoverage};
49use crate::ledger::Entry;
50use crate::order::{canonical, OrderKey, Ordered};
51use crate::peers::Peer;
52use crate::plan::{overlap, Plan};
53use crate::worktree::state::issue_of;
54
55pub const REPRODUCE: &str = "majordomus intent realization";
57
58const STATE_DIR: &str = ".ai/local/state";
60
61#[derive(
74 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
75)]
76#[serde(rename_all = "snake_case")]
77pub enum IntentLinkProvenance {
78 Declared,
80 Observed,
82 Derived,
84 Inferred,
86}
87
88impl IntentLinkProvenance {
89 pub fn as_str(self) -> &'static str {
96 match self {
97 IntentLinkProvenance::Declared => "declared",
98 IntentLinkProvenance::Observed => "observed",
99 IntentLinkProvenance::Derived => "derived",
100 IntentLinkProvenance::Inferred => "inferred",
101 }
102 }
103}
104
105#[derive(
112 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
113)]
114#[serde(rename_all = "snake_case")]
115pub enum IntentWorkKind {
116 Task,
119 PeerClaim,
121 SessionRecord,
124}
125
126impl IntentWorkKind {
127 pub fn as_str(self) -> &'static str {
134 match self {
135 IntentWorkKind::Task => "task",
136 IntentWorkKind::PeerClaim => "peer_claim",
137 IntentWorkKind::SessionRecord => "session_record",
138 }
139 }
140}
141
142#[derive(
150 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
151)]
152#[serde(rename_all = "snake_case")]
153pub enum IntentLinkVia {
154 NamedIssue,
156 MovedIssue,
158 BranchIssue,
160 ScopeOverlap,
162}
163
164impl IntentLinkVia {
165 pub fn as_str(self) -> &'static str {
172 match self {
173 IntentLinkVia::NamedIssue => "named_issue",
174 IntentLinkVia::MovedIssue => "moved_issue",
175 IntentLinkVia::BranchIssue => "branch_issue",
176 IntentLinkVia::ScopeOverlap => "scope_overlap",
177 }
178 }
179
180 pub fn provenance(self) -> IntentLinkProvenance {
188 match self {
189 IntentLinkVia::NamedIssue => IntentLinkProvenance::Declared,
190 IntentLinkVia::MovedIssue => IntentLinkProvenance::Observed,
191 IntentLinkVia::BranchIssue => IntentLinkProvenance::Derived,
192 IntentLinkVia::ScopeOverlap => IntentLinkProvenance::Inferred,
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
212pub struct IntentEpisode {
213 #[serde(default, skip_serializing_if = "String::is_empty")]
215 pub session: String,
216 #[serde(default, skip_serializing_if = "String::is_empty")]
218 pub provider: String,
219 #[serde(default, skip_serializing_if = "String::is_empty")]
221 pub provider_session: String,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
233pub struct IntentWorkUnit {
234 pub kind: IntentWorkKind,
236 pub id: String,
238 #[serde(default, skip_serializing_if = "String::is_empty")]
240 pub title: String,
241 #[serde(default, skip_serializing_if = "String::is_empty")]
243 pub outcome: String,
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub branches: Vec<String>,
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub scope: Vec<String>,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub episodes: Vec<IntentEpisode>,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub handovers: Vec<String>,
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub named_issues: Vec<String>,
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 pub moved_issues: Vec<String>,
262}
263
264impl IntentWorkUnit {
265 pub fn new(kind: IntentWorkKind, id: &str) -> IntentWorkUnit {
273 IntentWorkUnit {
274 kind,
275 id: id.to_string(),
276 title: String::new(),
277 outcome: String::new(),
278 branches: Vec::new(),
279 scope: Vec::new(),
280 episodes: Vec::new(),
281 handovers: Vec::new(),
282 named_issues: Vec::new(),
283 moved_issues: Vec::new(),
284 }
285 }
286
287 pub fn providers(&self) -> Vec<String> {
299 let mut out: Vec<String> = Vec::new();
300 for e in &self.episodes {
301 if !e.provider.is_empty() && !out.contains(&e.provider) {
302 out.push(e.provider.clone());
303 }
304 }
305 out
306 }
307}
308
309fn push_once(list: &mut Vec<String>, value: &str) {
310 if !value.is_empty() && !list.iter().any(|v| v == value) {
311 list.push(value.to_string());
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
332pub struct IntentLink {
333 pub intent: String,
335 pub stage: IntentStage,
337 pub milestone: String,
339 pub issue: String,
341 #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 pub criteria: Vec<String>,
344 pub via: IntentLinkVia,
346 pub provenance: IntentLinkProvenance,
348}
349
350impl Ordered for IntentLink {
352 fn order_key(&self) -> OrderKey<'_> {
353 OrderKey::plain(&self.intent, &self.issue).ranked(rank(self.provenance, None))
354 }
355}
356
357fn rank(provenance: IntentLinkProvenance, kind: Option<IntentWorkKind>) -> i64 {
360 let kind = kind.map_or(0, |k| k as i64 + 1);
361 provenance as i64 * 8 + kind
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
376pub struct IntentRealizedWork {
377 pub work: IntentWorkUnit,
379 pub links: Vec<IntentLink>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub unlinked: Option<String>,
384}
385
386pub fn issue_tokens(text: &str, ids: &[String]) -> Vec<String> {
394 let mut out = Vec::new();
395 for word in text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) {
396 if ids.iter().any(|i| i == word) {
397 push_once(&mut out, word);
398 }
399 }
400 out
401}
402
403pub fn link(unit: IntentWorkUnit, intents: &Intents, plan: &Plan) -> IntentRealizedWork {
426 let ids: Vec<String> = plan.issues.iter().map(|i| i.id.clone()).collect();
427 let mut best: BTreeMap<String, IntentLinkVia> = BTreeMap::new();
428 let mut offer = |issue: &str, via: IntentLinkVia| {
429 if !ids.iter().any(|i| i == issue) {
430 return;
431 }
432 let slot = best.entry(issue.to_string()).or_insert(via);
434 *slot = std::cmp::min_by_key(*slot, via, |v| v.provenance());
435 };
436 for i in &unit.named_issues {
437 offer(i, IntentLinkVia::NamedIssue);
438 }
439 for i in &unit.moved_issues {
440 offer(i, IntentLinkVia::MovedIssue);
441 }
442 for b in &unit.branches {
443 if let Some(i) = issue_of(b, &ids) {
444 offer(&i, IntentLinkVia::BranchIssue);
445 }
446 }
447 if best.is_empty() {
448 for i in &plan.issues {
449 if matches!(i.status.as_str(), "DONE" | "CANCELLED" | "SUPERSEDED") {
450 continue;
451 }
452 if i.scope
453 .iter()
454 .any(|s| unit.scope.iter().any(|p| overlap(s, p)))
455 {
456 best.insert(i.id.clone(), IntentLinkVia::ScopeOverlap);
457 }
458 }
459 }
460
461 let mut links = Vec::new();
462 let mut unserved: BTreeMap<String, Vec<String>> = BTreeMap::new();
463 for (pi, via) in best
465 .iter()
466 .filter_map(|(id, via)| plan.issue(id).map(|pi| (pi, via)))
467 {
468 let issue = &pi.id;
469 let serving = intents.serving(&pi.milestone);
470 if serving.is_empty() {
471 unserved
472 .entry(pi.milestone.clone())
473 .or_default()
474 .push(issue.clone());
475 }
476 for intent in serving {
477 let prefix = format!("{}#", intent.id);
478 links.push(IntentLink {
479 intent: intent.id.clone(),
480 stage: intent.stage,
481 milestone: pi.milestone.clone(),
482 issue: issue.clone(),
483 criteria: pi
484 .serves
485 .iter()
486 .filter_map(|s| s.strip_prefix(&prefix).map(str::to_string))
487 .collect(),
488 via: *via,
489 provenance: via.provenance(),
490 });
491 }
492 }
493 canonical(&mut links);
494
495 let unlinked = if !links.is_empty() {
496 None
497 } else if best.is_empty() {
498 let branches = if unit.branches.is_empty() {
499 "it ran on no recorded branch".to_string()
500 } else {
501 format!("its branch {} names none", unit.branches.join(", "))
502 };
503 let scope = if unit.scope.is_empty() {
504 "it claims no scope".to_string()
505 } else {
506 format!("no open issue's scope covers {}", unit.scope.join(", "))
507 };
508 Some(format!(
509 "it names no issue, no recorded event shows it moving one, {branches}, and {scope}"
510 ))
511 } else {
512 let named: Vec<String> = unserved
513 .iter()
514 .map(|(m, issues)| format!("{m} ({})", issues.join(", ")))
515 .collect();
516 Some(format!(
517 "its issues belong to milestones no intent names: {}",
518 named.join("; ")
519 ))
520 };
521 IntentRealizedWork {
522 work: unit,
523 links,
524 unlinked,
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
543pub struct IntentUnmetCriterion {
544 pub id: String,
546 pub criterion: String,
548 pub evidence: String,
550 pub state: IntentEvidenceState,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub reproduce: Option<String>,
555 #[serde(default, skip_serializing_if = "Vec::is_empty")]
557 pub issues: Vec<String>,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
572pub struct IntentWorkRef {
573 pub kind: IntentWorkKind,
575 pub id: String,
577 #[serde(default, skip_serializing_if = "String::is_empty")]
579 pub outcome: String,
580 pub provenance: IntentLinkProvenance,
582 #[serde(default, skip_serializing_if = "Vec::is_empty")]
584 pub providers: Vec<String>,
585 pub handovers: usize,
587}
588
589impl Ordered for IntentWorkRef {
592 fn order_key(&self) -> OrderKey<'_> {
593 OrderKey::plain(&self.id, &self.id).ranked(rank(self.provenance, Some(self.kind)))
594 }
595}
596
597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610pub struct IntentRealizationView {
611 pub intent: String,
613 pub title: String,
615 pub stage: IntentStage,
617 pub criteria: usize,
619 pub met: usize,
621 pub unmet: Vec<IntentUnmetCriterion>,
623 pub work: Vec<IntentWorkRef>,
625 pub providers: Vec<String>,
627 pub findings: Vec<IntentFinding>,
629}
630
631fn warn(out: &mut Vec<IntentFinding>, code: &str, subject: &str, message: String) {
632 out.push(IntentFinding {
633 level: WARN.into(),
634 code: code.into(),
635 subject: subject.into(),
636 message,
637 reproduce: REPRODUCE.into(),
638 });
639}
640
641fn state_words(state: IntentEvidenceState) -> &'static str {
642 match state {
643 IntentEvidenceState::Current => "current evidence",
644 IntentEvidenceState::Stale => {
645 "a pass that is no longer current evidence: something it names changed since, it \
646 names no code under test and something changed since, or it ran on a tree that was \
647 not its commit"
648 }
649 IntentEvidenceState::Failing => "a latest recorded run that did not pass",
650 IntentEvidenceState::NotRun => "no recorded run",
651 IntentEvidenceState::NotDerivable => {
652 "evidence of a kind the ledger cannot derive, so it is never met by itself"
653 }
654 IntentEvidenceState::Unresolved => "a reference that names nothing this repository holds",
655 }
656}
657
658pub fn drift(view: &IntentView, plan: &Plan) -> Vec<IntentFinding> {
702 let mut out = Vec::new();
703 for c in view.satisfaction.iter().filter(|c| !c.met) {
704 let key = format!("{}#{}", view.id, c.id);
705 let serving: Vec<&crate::plan::PlanIssue> = plan
706 .issues
707 .iter()
708 .filter(|i| i.serves.contains(&key))
709 .collect();
710 match view.stage {
711 IntentStage::Verifying
712 if matches!(
713 c.state,
714 IntentEvidenceState::Stale | IntentEvidenceState::Failing
715 ) =>
716 {
717 warn(
718 &mut out,
719 "closed_work_contradicted",
720 &view.id,
721 format!(
722 "every milestone is DONE and criterion `{}` has {}: the work closed, and \
723 the evidence says the intent is not true",
724 c.id,
725 state_words(c.state)
726 ),
727 );
728 }
729 IntentStage::Verifying => warn(
730 &mut out,
731 "closed_work_unproven",
732 &view.id,
733 format!(
734 "every milestone is DONE and criterion `{}` has {}: closing the work did not \
735 prove the intent",
736 c.id,
737 state_words(c.state)
738 ),
739 ),
740 IntentStage::Planned | IntentStage::Executing
741 if !serving.is_empty() && serving.iter().all(|i| i.status == "DONE") =>
742 {
743 let ids: Vec<&str> = serving.iter().map(|i| i.id.as_str()).collect();
744 warn(
745 &mut out,
746 "criterion_closed_unmet",
747 &view.id,
748 format!(
749 "every issue serving criterion `{}` ({}) is DONE and it has {}",
750 c.id,
751 ids.join(", "),
752 state_words(c.state)
753 ),
754 );
755 }
756 _ => {}
757 }
758 }
759 verdict_drift(view, &mut out);
760 out
761}
762
763pub const EVIDENCE_AHEAD_OF_PLAN: &str = "evidence_ahead_of_plan";
766
767pub const CLOSED_WORK_NOT_SATISFIED: &str = "closed_work_not_satisfied";
770
771fn verdict_drift(view: &IntentView, out: &mut Vec<IntentFinding>) {
773 match (view.stage, view.verdict.state) {
774 (IntentStage::Planned | IntentStage::Executing, IntentVerdictState::Satisfied) => {
775 let open: Vec<String> = view
778 .milestones
779 .iter()
780 .filter_map(|m| m.status.as_deref().map(|s| (m.id.as_str(), s)))
781 .filter(|(_, s)| !matches!(*s, "DONE" | "CANCELLED" | "SUPERSEDED"))
782 .map(|(id, s)| format!("{id} is {s}"))
783 .collect();
784 warn(
785 out,
786 EVIDENCE_AHEAD_OF_PLAN,
787 &view.id,
788 format!(
789 "the verdict is satisfied — every criterion has current evidence — and the \
790 stage is {} ({}): the evidence settles the intent before the plan closes it",
791 view.stage.as_str(),
792 open.join(", ")
793 ),
794 );
795 }
796 (IntentStage::Verifying, state) if state != IntentVerdictState::Satisfied => {
797 let held = if view.verdict.reasons.is_empty() {
798 "no criterion is declared, so no evidence can settle it".to_string()
799 } else {
800 let named: Vec<String> = view
801 .verdict
802 .reasons
803 .iter()
804 .map(|r| format!("`{}` ({} {})", r.criterion, r.evidence, r.state.as_str()))
805 .collect();
806 format!("held back by {}", named.join(", "))
807 };
808 warn(
809 out,
810 CLOSED_WORK_NOT_SATISFIED,
811 &view.id,
812 format!(
813 "every milestone is DONE and the verdict is {}: {held}",
814 state.as_str()
815 ),
816 );
817 }
818 _ => {}
819 }
820}
821
822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
831pub struct IntentRealization {
832 pub intents: Vec<IntentRealizationView>,
834 pub work: Vec<IntentRealizedWork>,
836 pub orphans: usize,
838 pub findings: Vec<IntentFinding>,
840}
841
842pub fn realize(intents: &Intents, plan: &Plan, units: Vec<IntentWorkUnit>) -> IntentRealization {
861 let work: Vec<IntentRealizedWork> = units.into_iter().map(|u| link(u, intents, plan)).collect();
862 let mut findings = Vec::new();
863 let mut views = Vec::with_capacity(intents.intents.len());
864 for view in &intents.intents {
865 let mut refs: Vec<IntentWorkRef> = work
866 .iter()
867 .filter_map(|w| {
868 let provenance = w
869 .links
870 .iter()
871 .filter(|l| l.intent == view.id)
872 .map(|l| l.provenance)
873 .min()?;
874 Some(IntentWorkRef {
875 kind: w.work.kind,
876 id: w.work.id.clone(),
877 outcome: w.work.outcome.clone(),
878 provenance,
879 providers: w.work.providers(),
880 handovers: w.work.handovers.len(),
881 })
882 })
883 .collect();
884 canonical(&mut refs);
885 let mut providers = Vec::new();
886 for r in &refs {
887 for p in &r.providers {
888 push_once(&mut providers, p);
889 }
890 }
891 let unmet = view
892 .satisfaction
893 .iter()
894 .filter(|c| !c.met)
895 .map(|c| {
896 let key = format!("{}#{}", view.id, c.id);
897 IntentUnmetCriterion {
898 id: c.id.clone(),
899 criterion: c.criterion.clone(),
900 evidence: c.evidence.clone(),
901 state: c.state,
902 reproduce: c.reproduce.clone(),
903 issues: plan
904 .issues
905 .iter()
906 .filter(|i| i.serves.contains(&key))
907 .map(|i| i.id.clone())
908 .collect(),
909 }
910 })
911 .collect();
912 let drifted = drift(view, plan);
913 findings.extend(drifted.iter().cloned());
914 views.push(IntentRealizationView {
915 intent: view.id.clone(),
916 title: view.title.clone(),
917 stage: view.stage,
918 criteria: view.satisfaction.len(),
919 met: view.met,
920 unmet,
921 work: refs,
922 providers,
923 findings: drifted,
924 });
925 }
926 let mut orphans = 0;
927 for w in &work {
928 let Some(why) = &w.unlinked else {
929 continue;
930 };
931 orphans += 1;
932 let live = match w.work.kind {
933 IntentWorkKind::Task => w.work.outcome == "active",
934 IntentWorkKind::PeerClaim => w.work.outcome == "attached",
935 IntentWorkKind::SessionRecord => false,
936 };
937 if live {
938 warn(
939 &mut findings,
940 "work_serves_no_intent",
941 &w.work.id,
942 format!(
943 "live work serves no declared intent: {why}. Maintenance may; work meant to \
944 realise an intent names its issue"
945 ),
946 );
947 }
948 }
949 IntentRealization {
950 intents: views,
951 work,
952 orphans,
953 findings,
954 }
955}
956
957#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
969pub struct IntentExplanation {
970 pub intent: IntentView,
972 pub because: Vec<String>,
974 pub coverage: Vec<CriterionCoverage>,
976 pub realization: IntentRealizationView,
978 pub work: Vec<IntentRealizedWork>,
980}
981
982fn strength_words(s: CoverageStrength) -> &'static str {
983 match s {
984 CoverageStrength::Uncovered => "no live issue serves it",
985 CoverageStrength::Observed => "the recorded gap observed it already true",
986 CoverageStrength::Weak => "the issues serving it owe no evidence",
987 CoverageStrength::Covered => "an issue serving it owes evidence before it is DONE",
988 }
989}
990
991pub fn explain(
1016 view: &IntentView,
1017 coverage: &IntentCoverage,
1018 realization: &IntentRealization,
1019) -> IntentExplanation {
1020 let mut because = Vec::new();
1021 let statuses: Vec<String> = view
1022 .milestones
1023 .iter()
1024 .map(|m| match &m.status {
1025 Some(s) => format!("{} is {s}", m.id),
1026 None => format!("{} does not resolve", m.id),
1027 })
1028 .collect();
1029 let total = view.satisfaction.len();
1030 because.push(match view.stage {
1031 IntentStage::Declared if view.milestones.is_empty() => {
1032 "declared: it names no milestone, so no work realises it".to_string()
1033 }
1034 IntentStage::Declared => format!("declared: {}", statuses.join(", ")),
1035 IntentStage::Planned => format!(
1036 "planned: every milestone resolves and none has started ({})",
1037 statuses.join(", ")
1038 ),
1039 IntentStage::Executing => format!("executing: {}", statuses.join(", ")),
1040 IntentStage::Verifying => format!(
1041 "verifying: every milestone is DONE and {} of {total} criteria have current \
1042 evidence; closed work is not a satisfied intent",
1043 view.met
1044 ),
1045 IntentStage::Satisfied => format!(
1046 "satisfied: every milestone is DONE and all {total} criteria have current evidence, \
1047 re-derived on this read"
1048 ),
1049 IntentStage::Cancelled => "cancelled: the record says so".to_string(),
1050 IntentStage::Superseded => format!(
1051 "superseded by {}",
1052 view.superseded_by.clone().unwrap_or_default()
1053 ),
1054 });
1055 let mine: Vec<CriterionCoverage> = coverage
1056 .criteria
1057 .iter()
1058 .filter(|c| c.intent == view.id)
1059 .cloned()
1060 .collect();
1061 for c in &view.satisfaction {
1062 let mut s = if c.met {
1063 format!(
1064 "`{}` is met: its {} `{}` has current evidence",
1065 c.id, c.evidence, c.reference
1066 )
1067 } else {
1068 format!(
1069 "`{}` is not met: its {} `{}` has {}",
1070 c.id,
1071 c.evidence,
1072 c.reference,
1073 state_words(c.state)
1074 )
1075 };
1076 if let Some(cov) = mine.iter().find(|k| k.criterion == c.id) {
1077 let ids: Vec<&str> = cov.issues.iter().map(|i| i.id.as_str()).collect();
1078 s.push_str(&format!("; {}", strength_words(cov.strength)));
1079 if !ids.is_empty() {
1080 s.push_str(&format!(" ({})", ids.join(", ")));
1081 }
1082 }
1083 if !c.met {
1084 if let Some(r) = &c.reproduce {
1085 s.push_str(&format!("; reproduce: {r}"));
1086 }
1087 }
1088 because.push(s);
1089 }
1090 let rv = realization
1091 .intents
1092 .iter()
1093 .find(|v| v.intent == view.id)
1094 .cloned()
1095 .unwrap_or_else(|| IntentRealizationView {
1096 intent: view.id.clone(),
1097 title: view.title.clone(),
1098 stage: view.stage,
1099 criteria: total,
1100 met: view.met,
1101 unmet: Vec::new(),
1102 work: Vec::new(),
1103 providers: Vec::new(),
1104 findings: Vec::new(),
1105 });
1106 because.push(if rv.work.is_empty() {
1107 "no recorded task, session or live claim realises it".to_string()
1108 } else {
1109 let handovers: usize = rv.work.iter().map(|w| w.handovers).sum();
1110 format!(
1111 "{} unit(s) of work realise it across {} handover(s){}",
1112 rv.work.len(),
1113 handovers,
1114 if rv.providers.is_empty() {
1115 String::new()
1116 } else {
1117 format!(", run by {}", rv.providers.join(", "))
1118 }
1119 )
1120 });
1121 for f in &rv.findings {
1122 because.push(format!("{}: {}", f.code, f.message));
1123 }
1124 let work = realization
1125 .work
1126 .iter()
1127 .filter(|w| w.links.iter().any(|l| l.intent == view.id))
1128 .cloned()
1129 .collect();
1130 IntentExplanation {
1131 intent: view.clone(),
1132 because,
1133 coverage: mine,
1134 realization: rv,
1135 work,
1136 }
1137}
1138
1139fn payload_str(e: &Entry, key: &str) -> String {
1142 e.payload
1143 .get(key)
1144 .and_then(|v| v.as_str())
1145 .unwrap_or_default()
1146 .to_string()
1147}
1148
1149pub fn tasks_from_ledger(
1186 entries: &[Entry],
1187 providers: &BTreeMap<String, (String, String)>,
1188) -> Vec<IntentWorkUnit> {
1189 let mut providers = providers.clone();
1190 for e in entries.iter().filter(|e| {
1193 matches!(
1194 e.event.as_str(),
1195 "session.started" | "provider.event.received"
1196 )
1197 }) {
1198 let provider = payload_str(e, "provider");
1199 if let (Some(s), false) = (&e.session, provider.is_empty()) {
1200 providers
1201 .entry(s.clone())
1202 .or_insert_with(|| (provider, payload_str(e, "provider_session")));
1203 }
1204 }
1205 let mut order: Vec<String> = Vec::new();
1206 let mut units: BTreeMap<String, IntentWorkUnit> = BTreeMap::new();
1207 let mut current: BTreeMap<String, String> = BTreeMap::new();
1208 for e in entries {
1209 let task_id = payload_str(e, "task_id");
1210 if e.event.starts_with("task.") && !task_id.is_empty() {
1211 let unit = units.entry(task_id.clone()).or_insert_with(|| {
1212 order.push(task_id.clone());
1213 IntentWorkUnit::new(IntentWorkKind::Task, &task_id)
1214 });
1215 push_once(&mut unit.branches, &e.branch);
1216 if let Some(s) = &e.session {
1217 current.insert(s.clone(), task_id.clone());
1218 if !unit.episodes.iter().any(|x| &x.session == s) {
1219 let (p, ps) = providers.get(s).cloned().unwrap_or_default();
1220 unit.episodes.push(IntentEpisode {
1221 session: s.clone(),
1222 provider: p,
1223 provider_session: ps,
1224 });
1225 }
1226 }
1227 match e.event.as_str() {
1228 "task.started" => {
1229 unit.outcome = "active".into();
1230 for p in payload_str(e, "scope").split_whitespace() {
1231 push_once(&mut unit.scope, p);
1232 }
1233 let named = payload_str(e, "issue");
1237 if !named.is_empty() {
1238 push_once(&mut unit.named_issues, &named);
1239 }
1240 }
1241 "task.checkpoint" => unit.outcome = "active".into(),
1242 "task.handed_over" => {
1243 unit.outcome = "handed_over".into();
1244 push_once(&mut unit.handovers, &payload_str(e, "handover_path"));
1245 }
1246 "task.finished" => {
1247 let o = payload_str(e, "outcome");
1248 unit.outcome = if o.is_empty() { "finished".into() } else { o };
1249 }
1250 _ => {}
1251 }
1252 } else if e.event.starts_with("plan_") {
1253 let issue = payload_str(e, "issue");
1254 let owner = e.session.as_ref().and_then(|s| current.get(s));
1255 if let (Some(unit), false) = (owner.and_then(|t| units.get_mut(t)), issue.is_empty()) {
1257 push_once(&mut unit.moved_issues, &issue);
1258 }
1259 }
1260 }
1261 order
1262 .into_iter()
1263 .filter_map(|id| units.remove(&id))
1264 .collect()
1265}
1266
1267pub fn gather(root: &Path, index: &Index, peers: &[Peer]) -> (Vec<IntentWorkUnit>, usize) {
1283 let dir = root.join(STATE_DIR);
1284 let (entries, skipped) = crate::ledger::read(root);
1285
1286 let mut providers: BTreeMap<String, (String, String)> = BTreeMap::new();
1287 if let Ok(rd) = std::fs::read_dir(dir.join("sessions-open")) {
1288 for f in rd.flatten() {
1289 if let Some(d) = document(&f.path()) {
1290 if let Some(s) = d.get("session_id") {
1291 providers.insert(
1292 s.clone(),
1293 (
1294 d.get("provider").cloned().unwrap_or_default(),
1295 d.get("provider_session").cloned().unwrap_or_default(),
1296 ),
1297 );
1298 }
1299 }
1300 }
1301 }
1302 let mut units = tasks_from_ledger(&entries, &providers);
1303 let ids: Vec<String> = crate::plan::Plan::build(index)
1304 .issues
1305 .iter()
1306 .map(|i| i.id.clone())
1307 .collect();
1308
1309 let active = read_task(&dir.join("current.yaml"));
1311 if let Some(a) = &active {
1312 if !units.iter().any(|u| u.id == a.id) {
1313 let mut u = IntentWorkUnit::new(IntentWorkKind::Task, &a.id);
1314 u.outcome = if a.outcome.is_empty() {
1315 "active".into()
1316 } else {
1317 a.outcome.clone()
1318 };
1319 units.push(u);
1320 }
1321 }
1322 for u in &mut units {
1323 let record = match &active {
1324 Some(a) if a.id == u.id => Some(a.clone()),
1325 _ => read_task(&dir.join("archive").join(format!("{}.yaml", u.id))),
1326 };
1327 if let Some(r) = record {
1328 u.title = r.task.clone();
1329 for p in &r.scope {
1330 push_once(&mut u.scope, p);
1331 }
1332 if !r.issue.is_empty() {
1333 push_once(&mut u.named_issues, &r.issue);
1334 }
1335 }
1336 for i in issue_tokens(&u.title, &ids) {
1337 push_once(&mut u.named_issues, &i);
1338 }
1339 }
1340
1341 let held: BTreeSet<String> = units
1342 .iter()
1343 .flat_map(|u| u.episodes.iter().map(|e| e.session.clone()))
1344 .collect();
1345 let strings = |v: Option<&serde_json::Value>| -> Vec<String> {
1346 v.and_then(|v| v.as_array())
1347 .map(|a| {
1348 a.iter()
1349 .filter_map(|x| x.as_str().map(str::to_string))
1350 .collect()
1351 })
1352 .unwrap_or_default()
1353 };
1354 for o in index.objects.iter().filter(|o| o.kind == "session") {
1355 let m = &o.metadata;
1356 let sid = m
1357 .get("session_id")
1358 .and_then(|v| v.as_str())
1359 .unwrap_or_default();
1360 if sid.is_empty() || held.contains(sid) {
1361 continue;
1362 }
1363 let mut u = IntentWorkUnit::new(IntentWorkKind::SessionRecord, sid);
1364 u.title = m
1365 .get("title")
1366 .and_then(|v| v.as_str())
1367 .unwrap_or_default()
1368 .to_string();
1369 u.outcome = m
1370 .get("outcome")
1371 .and_then(|v| v.as_str())
1372 .unwrap_or_default()
1373 .to_string();
1374 push_once(
1375 &mut u.branches,
1376 m.get("branch").and_then(|v| v.as_str()).unwrap_or_default(),
1377 );
1378 u.episodes.push(IntentEpisode {
1379 session: sid.to_string(),
1380 provider: String::new(),
1381 provider_session: String::new(),
1382 });
1383 for p in strings(m.get("changed_files")) {
1386 push_once(&mut u.scope, &p);
1387 }
1388 u.handovers = strings(m.get("handovers"));
1389 u.moved_issues = strings(m.get("issues"));
1390 units.push(u);
1391 }
1392
1393 for p in peers {
1394 for c in &p.claims {
1395 let id = match &c.name {
1396 Some(n) => format!("{}/{n}", p.id),
1397 None => p.id.to_string(),
1398 };
1399 let mut u = IntentWorkUnit::new(IntentWorkKind::PeerClaim, &id);
1400 u.title = c.intent.clone();
1401 u.outcome = if p.attached { "attached" } else { "departed" }.into();
1402 u.scope = c.scope.clone();
1403 if let Some(b) = p.checkout.as_ref().and_then(|k| k.branch.as_deref()) {
1404 push_once(&mut u.branches, b);
1405 }
1406 u.episodes.push(IntentEpisode {
1407 session: String::new(),
1408 provider: p.client.name.clone(),
1409 provider_session: String::new(),
1410 });
1411 let words = format!("{} {}", c.name.clone().unwrap_or_default(), c.intent);
1412 u.named_issues = issue_tokens(&words, &ids);
1413 units.push(u);
1414 }
1415 }
1416 (units, skipped)
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::*;
1422 use crate::intent::{verdict, IntentCriterion, IntentMilestone};
1423 use crate::intent_plan::CoveringIssue;
1424 use crate::plan::{PlanIssue, PlanProject, PlanVocabulary};
1425 use serde_json::json;
1426
1427 fn issue(
1428 id: &str,
1429 milestone: &str,
1430 status: &str,
1431 scope: &[&str],
1432 serves: &[&str],
1433 ) -> PlanIssue {
1434 PlanIssue {
1435 id: id.into(),
1436 milestone: milestone.into(),
1437 status: status.into(),
1438 wave: 0,
1439 priority: "p1".into(),
1440 profile: "implementation".into(),
1441 parallel_safe: true,
1442 title: id.into(),
1443 slug: id.into(),
1444 depends_on: vec![],
1445 blocked_by: vec![],
1446 dependents: vec![],
1447 scope: scope.iter().map(|s| s.to_string()).collect(),
1448 serves: serves.iter().map(|s| s.to_string()).collect(),
1449 objective: String::new(),
1450 evidence_have: 0,
1451 evidence_need: 0,
1452 started_at: String::new(),
1453 verified_at: String::new(),
1454 completed_at: String::new(),
1455 }
1456 }
1457
1458 fn plan(issues: Vec<PlanIssue>) -> Plan {
1459 Plan {
1460 project: PlanProject {
1461 name: "p".into(),
1462 repository: "o/p".into(),
1463 default_branch: "master".into(),
1464 active_milestone: String::new(),
1465 },
1466 statuses: PlanVocabulary {
1467 issue: vec![],
1468 milestone: vec![],
1469 },
1470 milestones: vec![],
1471 issues,
1472 waves: vec![],
1473 edges: vec![],
1474 milestone_edges: vec![],
1475 findings: vec![],
1476 }
1477 }
1478
1479 fn criterion(id: &str, state: IntentEvidenceState) -> IntentCriterion {
1480 IntentCriterion {
1481 id: id.into(),
1482 criterion: format!("{id} holds"),
1483 evidence: "test".into(),
1484 reference: format!("suite:{id}"),
1485 state,
1486 met: state == IntentEvidenceState::Current,
1487 proof: None,
1488 reproduce: None,
1489 }
1490 }
1491
1492 fn view(id: &str, stage: IntentStage, milestones: &[(&str, Option<&str>)]) -> IntentView {
1493 IntentView {
1494 id: id.into(),
1495 title: id.to_uppercase(),
1496 statement: String::new(),
1497 invariants: vec![],
1498 stage,
1499 milestones: milestones
1500 .iter()
1501 .map(|(m, s)| IntentMilestone {
1502 id: m.to_string(),
1503 resolved: s.is_some(),
1504 status: s.map(str::to_string),
1505 })
1506 .collect(),
1507 satisfaction: vec![],
1508 met: 0,
1509 verdict: crate::intent::verdict(&[]),
1510 governance: vec![],
1511 non_goals: vec![],
1512 superseded_by: None,
1513 source: String::new(),
1514 }
1515 }
1516
1517 fn intents(views: Vec<IntentView>) -> Intents {
1518 Intents {
1519 intents: views,
1520 findings: vec![],
1521 }
1522 }
1523
1524 fn entry(ts: &str, event: &str, session: Option<&str>, payload: serde_json::Value) -> Entry {
1525 Entry {
1526 ts: ts.into(),
1527 event: event.into(),
1528 head: "h".into(),
1529 branch: "feature/I0001-x".into(),
1530 by: "majordomus/0".into(),
1531 session: session.map(str::to_string),
1532 payload: payload
1533 .as_object()
1534 .unwrap()
1535 .iter()
1536 .map(|(k, v)| (k.clone(), v.clone()))
1537 .collect(),
1538 }
1539 }
1540
1541 fn codes(f: &[IntentFinding]) -> Vec<&str> {
1542 f.iter().map(|f| f.code.as_str()).collect()
1543 }
1544
1545 #[test]
1546 fn every_word_is_the_one_serde_writes() {
1547 for p in [
1548 IntentLinkProvenance::Declared,
1549 IntentLinkProvenance::Observed,
1550 IntentLinkProvenance::Derived,
1551 IntentLinkProvenance::Inferred,
1552 ] {
1553 assert_eq!(serde_json::to_value(p).unwrap(), p.as_str());
1554 }
1555 for k in [
1556 IntentWorkKind::Task,
1557 IntentWorkKind::PeerClaim,
1558 IntentWorkKind::SessionRecord,
1559 ] {
1560 assert_eq!(serde_json::to_value(k).unwrap(), k.as_str());
1561 }
1562 for v in [
1563 IntentLinkVia::NamedIssue,
1564 IntentLinkVia::MovedIssue,
1565 IntentLinkVia::BranchIssue,
1566 IntentLinkVia::ScopeOverlap,
1567 ] {
1568 assert_eq!(serde_json::to_value(v).unwrap(), v.as_str());
1569 }
1570 let graded: BTreeSet<IntentLinkProvenance> = [
1572 IntentLinkVia::NamedIssue,
1573 IntentLinkVia::MovedIssue,
1574 IntentLinkVia::BranchIssue,
1575 IntentLinkVia::ScopeOverlap,
1576 ]
1577 .iter()
1578 .map(|v| v.provenance())
1579 .collect();
1580 assert_eq!(graded.len(), 4);
1581 }
1582
1583 #[test]
1584 fn a_link_keeps_the_strongest_fact_per_issue_and_the_criteria_it_serves() {
1585 let p = plan(vec![
1586 issue("I0001", "m1", "ACTIVE", &["lib"], &["x#a", "y#b", "x#c"]),
1587 issue("I0002", "m2", "READY", &["docs"], &[]),
1588 ]);
1589 let i = intents(vec![
1590 view("x", IntentStage::Executing, &[("m1", Some("ACTIVE"))]),
1591 view("y", IntentStage::Planned, &[("m2", Some("READY"))]),
1592 ]);
1593 let mut u = IntentWorkUnit::new(IntentWorkKind::Task, "t-1");
1594 u.branches = vec!["feature/I0001-work".into()];
1596 u.moved_issues = vec!["I0001".into(), "I9999".into()];
1597 u.named_issues = vec!["I0002".into()];
1598 let w = link(u, &i, &p);
1599 assert_eq!(w.unlinked, None);
1600 let got: Vec<(&str, &str, IntentLinkVia)> = w
1601 .links
1602 .iter()
1603 .map(|l| (l.intent.as_str(), l.issue.as_str(), l.via))
1604 .collect();
1605 assert_eq!(
1607 got,
1608 [
1609 ("y", "I0002", IntentLinkVia::NamedIssue),
1610 ("x", "I0001", IntentLinkVia::MovedIssue),
1611 ]
1612 );
1613 assert_eq!(w.links[1].criteria, ["a", "c"]);
1614 assert!(w.links[0].criteria.is_empty());
1615 assert!(w.links.iter().all(|l| l.provenance == l.via.provenance()));
1616
1617 let mut u = IntentWorkUnit::new(IntentWorkKind::Task, "t-2");
1619 u.branches = vec!["feature/I0001-work".into()];
1620 let w = link(u, &i, &p);
1621 assert_eq!(w.links[0].provenance, IntentLinkProvenance::Derived);
1622 }
1623
1624 #[test]
1625 fn a_scope_overlap_is_inferred_and_only_from_open_issues() {
1626 let p = plan(vec![
1627 issue("I0001", "m1", "ACTIVE", &["lib"], &[]),
1628 issue("I0002", "m1", "DONE", &["lib"], &[]),
1629 issue("I0003", "m1", "CANCELLED", &["lib"], &[]),
1630 ]);
1631 let i = intents(vec![view("x", IntentStage::Executing, &[("m1", None)])]);
1632 let mut u = IntentWorkUnit::new(IntentWorkKind::PeerClaim, "p1/c");
1633 u.scope = vec!["lib/a.rs".into()];
1634 let w = link(u, &i, &p);
1635 let issues: Vec<&str> = w.links.iter().map(|l| l.issue.as_str()).collect();
1636 assert_eq!(issues, ["I0001"]);
1637 assert_eq!(w.links[0].provenance, IntentLinkProvenance::Inferred);
1638 }
1639
1640 #[test]
1641 fn unlinked_work_says_which_link_is_missing() {
1642 let p = plan(vec![issue(
1643 "I0001",
1644 "orphan-milestone",
1645 "ACTIVE",
1646 &["lib"],
1647 &[],
1648 )]);
1649 let i = intents(vec![view("x", IntentStage::Executing, &[("m1", None)])]);
1650
1651 let mut u = IntentWorkUnit::new(IntentWorkKind::Task, "t-1");
1652 u.branches = vec!["feature/no-issue".into()];
1653 u.scope = vec!["docs".into()];
1654 let why = link(u, &i, &p).unlinked.unwrap();
1655 assert!(
1656 why.contains("its branch feature/no-issue names none"),
1657 "{why}"
1658 );
1659 assert!(why.contains("no open issue's scope covers docs"), "{why}");
1660
1661 let why = link(IntentWorkUnit::new(IntentWorkKind::Task, "t-2"), &i, &p)
1662 .unlinked
1663 .unwrap();
1664 assert!(why.contains("it ran on no recorded branch"), "{why}");
1665 assert!(why.contains("it claims no scope"), "{why}");
1666
1667 let mut u = IntentWorkUnit::new(IntentWorkKind::Task, "t-3");
1669 u.named_issues = vec!["I0001".into()];
1670 let w = link(u, &i, &p);
1671 assert!(w.links.is_empty());
1672 assert_eq!(
1673 w.unlinked.unwrap(),
1674 "its issues belong to milestones no intent names: orphan-milestone (I0001)"
1675 );
1676 }
1677
1678 #[test]
1679 fn drift_names_closed_work_reality_contradicts_or_never_proved() {
1680 let p = plan(vec![
1681 issue("I0001", "m1", "DONE", &[], &["x#a"]),
1682 issue("I0002", "m1", "DONE", &[], &["x#a"]),
1683 issue("I0003", "m1", "ACTIVE", &[], &["x#b"]),
1684 ]);
1685 let mut v = view("x", IntentStage::Verifying, &[("m1", Some("DONE"))]);
1686 v.satisfaction = vec![
1687 criterion("a", IntentEvidenceState::Stale),
1688 criterion("b", IntentEvidenceState::Failing),
1689 criterion("c", IntentEvidenceState::NotRun),
1690 criterion("d", IntentEvidenceState::Current),
1691 ];
1692 v.verdict = verdict(&v.satisfaction);
1693 let found = drift(&v, &p);
1694 assert_eq!(
1695 codes(&found),
1696 [
1697 "closed_work_contradicted",
1698 "closed_work_contradicted",
1699 "closed_work_unproven",
1700 "closed_work_not_satisfied"
1701 ]
1702 );
1703 assert!(found[0].message.contains("no longer current evidence"));
1704 assert!(found[1].message.contains("did not pass"));
1705 assert!(found[2].message.contains("no recorded run"));
1706 assert!(found[3].message.contains(
1707 "the verdict is unsatisfied: held back by `a` (test stale), `b` (test failing), \
1708 `c` (test not_run)"
1709 ));
1710 assert!(found
1711 .iter()
1712 .all(|f| f.level == WARN && f.reproduce == REPRODUCE));
1713
1714 v.stage = IntentStage::Executing;
1716 let found = drift(&v, &p);
1717 assert_eq!(codes(&found), ["criterion_closed_unmet"]);
1718 assert!(found[0].message.contains("(I0001, I0002)"));
1719 assert!(found[0].message.contains("criterion `a`"));
1720
1721 v.stage = IntentStage::Planned;
1724 assert_eq!(codes(&drift(&v, &p)), ["criterion_closed_unmet"]);
1725
1726 v.stage = IntentStage::Declared;
1728 assert!(drift(&v, &p).is_empty());
1729 }
1730
1731 #[test]
1732 fn drift_names_where_the_stage_and_the_verdict_disagree() {
1733 let p = plan(vec![]);
1734 let all_current = vec![
1735 criterion("a", IntentEvidenceState::Current),
1736 criterion("b", IntentEvidenceState::Current),
1737 ];
1738 for (stage, status) in [
1740 (IntentStage::Executing, "ACTIVE"),
1741 (IntentStage::Planned, "READY"),
1742 ] {
1743 let mut v = view("x", stage, &[("m1", Some("DONE")), ("m2", Some(status))]);
1744 v.satisfaction = all_current.clone();
1745 v.met = 2;
1746 v.verdict = verdict(&v.satisfaction);
1747 let found = drift(&v, &p);
1748 assert_eq!(codes(&found), [EVIDENCE_AHEAD_OF_PLAN], "{stage:?}");
1749 assert!(
1750 found[0]
1751 .message
1752 .contains(&format!("stage is {} (m2 is {status})", stage.as_str())),
1753 "{}",
1754 found[0].message
1755 );
1756 assert_eq!(found[0].level, WARN);
1757 assert_eq!(found[0].reproduce, REPRODUCE);
1758 }
1759
1760 let mut v = view("x", IntentStage::Verifying, &[("m1", Some("DONE"))]);
1762 let mut cmd = criterion("ships", IntentEvidenceState::NotDerivable);
1763 cmd.evidence = "command".into();
1764 v.satisfaction = vec![criterion("a", IntentEvidenceState::Current), cmd];
1765 v.met = 1;
1766 v.verdict = verdict(&v.satisfaction);
1767 let found = drift(&v, &p);
1768 assert_eq!(
1769 codes(&found),
1770 ["closed_work_unproven", CLOSED_WORK_NOT_SATISFIED]
1771 );
1772 assert!(found[1]
1773 .message
1774 .ends_with("the verdict is unknown: held back by `ships` (command not_derivable)"));
1775
1776 for stage in [
1778 IntentStage::Satisfied,
1779 IntentStage::Declared,
1780 IntentStage::Cancelled,
1781 IntentStage::Superseded,
1782 ] {
1783 let mut v = view("x", stage, &[("m1", Some("DONE"))]);
1784 v.satisfaction = all_current.clone();
1785 v.met = 2;
1786 v.verdict = verdict(&v.satisfaction);
1787 assert!(drift(&v, &p).is_empty(), "{stage:?}");
1788 }
1789 let mut v = view("x", IntentStage::Executing, &[("m1", Some("ACTIVE"))]);
1790 v.satisfaction = vec![criterion("a", IntentEvidenceState::NotRun)];
1791 v.verdict = verdict(&v.satisfaction);
1792 assert!(
1793 drift(&v, &p).is_empty(),
1794 "an unsatisfied verdict on open work is no drift"
1795 );
1796 }
1797
1798 #[test]
1799 fn every_evidence_state_and_coverage_strength_has_its_own_words() {
1800 let states = [
1801 IntentEvidenceState::Current,
1802 IntentEvidenceState::Stale,
1803 IntentEvidenceState::Failing,
1804 IntentEvidenceState::NotRun,
1805 IntentEvidenceState::NotDerivable,
1806 IntentEvidenceState::Unresolved,
1807 ];
1808 let words: BTreeSet<&str> = states.iter().map(|s| state_words(*s)).collect();
1809 assert_eq!(words.len(), states.len());
1810 assert!(state_words(IntentEvidenceState::NotDerivable).contains("never met by itself"));
1811 assert!(state_words(IntentEvidenceState::Unresolved).contains("names nothing"));
1812 let strengths = [
1813 CoverageStrength::Uncovered,
1814 CoverageStrength::Observed,
1815 CoverageStrength::Weak,
1816 CoverageStrength::Covered,
1817 ];
1818 let words: BTreeSet<&str> = strengths.iter().map(|s| strength_words(*s)).collect();
1819 assert_eq!(words.len(), strengths.len());
1820 }
1821
1822 #[test]
1823 fn realize_joins_work_to_intents_and_names_live_orphans_only() {
1824 let p = plan(vec![issue("I0001", "m1", "ACTIVE", &["lib"], &["x#a"])]);
1825 let mut v = view("x", IntentStage::Executing, &[("m1", Some("ACTIVE"))]);
1826 let mut a = criterion("a", IntentEvidenceState::NotRun);
1827 a.reproduce = Some("test/run.sh a".into());
1828 v.satisfaction = vec![a, criterion("b", IntentEvidenceState::Current)];
1829 v.met = 1;
1830 v.verdict = verdict(&v.satisfaction);
1831 let i = intents(vec![v]);
1832
1833 let mut t = IntentWorkUnit::new(IntentWorkKind::Task, "t-1");
1834 t.named_issues = vec!["I0001".into()];
1835 t.handovers = vec!["h1.md".into(), "h2.md".into()];
1836 for p in ["claude-code", "codex"] {
1837 t.episodes.push(IntentEpisode {
1838 session: format!("s-{p}"),
1839 provider: p.into(),
1840 provider_session: String::new(),
1841 });
1842 }
1843 let mut claim = IntentWorkUnit::new(IntentWorkKind::PeerClaim, "p1/c");
1844 claim.scope = vec!["lib".into()];
1845 claim.episodes.push(IntentEpisode {
1846 session: String::new(),
1847 provider: "codex".into(),
1848 provider_session: String::new(),
1849 });
1850 let mut live_task = IntentWorkUnit::new(IntentWorkKind::Task, "t-orphan");
1851 live_task.outcome = "active".into();
1852 let mut finished_task = IntentWorkUnit::new(IntentWorkKind::Task, "t-old");
1853 finished_task.outcome = "completed".into();
1854 let mut live_claim = IntentWorkUnit::new(IntentWorkKind::PeerClaim, "p2/c");
1855 live_claim.outcome = "attached".into();
1856 let mut gone_claim = IntentWorkUnit::new(IntentWorkKind::PeerClaim, "p3/c");
1857 gone_claim.outcome = "departed".into();
1858 let mut record = IntentWorkUnit::new(IntentWorkKind::SessionRecord, "s-r");
1859 record.outcome = "active".into();
1860
1861 let r = realize(
1862 &i,
1863 &p,
1864 vec![
1865 t,
1866 claim,
1867 live_task,
1868 finished_task,
1869 live_claim,
1870 gone_claim,
1871 record,
1872 ],
1873 );
1874 assert_eq!(r.orphans, 5);
1875 assert_eq!(
1876 r.findings
1877 .iter()
1878 .map(|f| f.subject.as_str())
1879 .collect::<Vec<_>>(),
1880 ["t-orphan", "p2/c"],
1881 "only live work is named; a finished task, a departed claim and a closed session \
1882 record are history"
1883 );
1884 let x = &r.intents[0];
1885 assert_eq!((x.criteria, x.met), (2, 1));
1886 assert_eq!(x.unmet.len(), 1);
1887 assert_eq!(x.unmet[0].issues, ["I0001"]);
1888 assert_eq!(x.unmet[0].reproduce.as_deref(), Some("test/run.sh a"));
1889 let work: Vec<(&str, IntentLinkProvenance, usize)> = x
1890 .work
1891 .iter()
1892 .map(|w| (w.id.as_str(), w.provenance, w.handovers))
1893 .collect();
1894 assert_eq!(
1895 work,
1896 [
1897 ("t-1", IntentLinkProvenance::Declared, 2),
1898 ("p1/c", IntentLinkProvenance::Inferred, 0),
1899 ]
1900 );
1901 assert_eq!(x.providers, ["claude-code", "codex"]);
1902 }
1903
1904 #[test]
1905 fn explain_gives_one_sentence_per_fact_for_every_stage() {
1906 let r = IntentRealization {
1907 intents: vec![],
1908 work: vec![],
1909 orphans: 0,
1910 findings: vec![],
1911 };
1912 let none = IntentCoverage {
1913 criteria: vec![],
1914 issues: vec![],
1915 findings: vec![],
1916 };
1917 let cases = [
1918 (
1919 IntentStage::Declared,
1920 vec![],
1921 "declared: it names no milestone",
1922 ),
1923 (
1924 IntentStage::Declared,
1925 vec![("m1", None)],
1926 "declared: m1 does not resolve",
1927 ),
1928 (
1929 IntentStage::Planned,
1930 vec![("m1", Some("READY"))],
1931 "planned: every milestone resolves and none has started (m1 is READY)",
1932 ),
1933 (
1934 IntentStage::Executing,
1935 vec![("m1", Some("ACTIVE"))],
1936 "executing: m1 is ACTIVE",
1937 ),
1938 (
1939 IntentStage::Verifying,
1940 vec![],
1941 "verifying: every milestone is DONE and 0 of 0",
1942 ),
1943 (
1944 IntentStage::Satisfied,
1945 vec![],
1946 "satisfied: every milestone is DONE and all 0",
1947 ),
1948 (
1949 IntentStage::Cancelled,
1950 vec![],
1951 "cancelled: the record says so",
1952 ),
1953 (IntentStage::Superseded, vec![], "superseded by y"),
1954 ];
1955 for (stage, milestones, first) in cases {
1956 let mut v = view("x", stage, &milestones);
1957 v.superseded_by = Some("y".into());
1958 let e = explain(&v, &none, &r);
1959 assert!(
1960 e.because[0].starts_with(first),
1961 "{stage:?}: {}",
1962 e.because[0]
1963 );
1964 assert_eq!(
1966 e.because.last().unwrap(),
1967 "no recorded task, session or live claim realises it"
1968 );
1969 assert_eq!(e.realization.intent, "x");
1970 }
1971 }
1972
1973 #[test]
1974 fn explain_reads_each_criterion_with_its_coverage_and_the_work() {
1975 let p = plan(vec![
1976 issue("I0001", "m1", "DONE", &[], &["x#a"]),
1977 issue("I0002", "m1", "DONE", &[], &["x#b"]),
1978 ]);
1979 let mut v = view("x", IntentStage::Verifying, &[("m1", Some("DONE"))]);
1980 let mut b = criterion("b", IntentEvidenceState::Failing);
1981 b.reproduce = Some("test/run.sh b".into());
1982 let mut c = criterion("c", IntentEvidenceState::NotRun);
1983 c.reproduce = None;
1984 let mut met = criterion("a", IntentEvidenceState::Current);
1985 met.reproduce = Some("never printed for a met criterion".into());
1986 v.satisfaction = vec![met, b, c];
1987 v.met = 1;
1988 v.verdict = verdict(&v.satisfaction);
1989 let mut t = IntentWorkUnit::new(IntentWorkKind::Task, "t-1");
1990 t.moved_issues = vec!["I0001".into()];
1991 t.handovers = vec!["h.md".into()];
1992 t.episodes.push(IntentEpisode {
1993 session: "s-1".into(),
1994 provider: "codex".into(),
1995 provider_session: String::new(),
1996 });
1997 let mut quiet = IntentWorkUnit::new(IntentWorkKind::Task, "t-2");
1998 quiet.moved_issues = vec!["I0002".into()];
1999 let unrelated = IntentWorkUnit::new(IntentWorkKind::Task, "t-3");
2000 let r = realize(&intents(vec![v.clone()]), &p, vec![t, quiet, unrelated]);
2001 let coverage = IntentCoverage {
2002 criteria: vec![
2003 CriterionCoverage {
2004 intent: "x".into(),
2005 criterion: "a".into(),
2006 strength: CoverageStrength::Covered,
2007 issues: vec![CoveringIssue {
2008 id: "I0001".into(),
2009 milestone: "m1".into(),
2010 status: "DONE".into(),
2011 evidence_need: 1,
2012 }],
2013 milestones: vec!["m1".into()],
2014 },
2015 CriterionCoverage {
2016 intent: "x".into(),
2017 criterion: "b".into(),
2018 strength: CoverageStrength::Uncovered,
2019 issues: vec![],
2020 milestones: vec![],
2021 },
2022 CriterionCoverage {
2023 intent: "other".into(),
2024 criterion: "c".into(),
2025 strength: CoverageStrength::Weak,
2026 issues: vec![],
2027 milestones: vec![],
2028 },
2029 ],
2030 issues: vec![],
2031 findings: vec![],
2032 };
2033 let e = explain(&v, &coverage, &r);
2034 assert_eq!(
2035 e.because[1],
2036 "`a` is met: its test `suite:a` has current evidence; an issue serving it owes \
2037 evidence before it is DONE (I0001)"
2038 );
2039 assert_eq!(
2040 e.because[2],
2041 "`b` is not met: its test `suite:b` has a latest recorded run that did not pass; no \
2042 live issue serves it; reproduce: test/run.sh b"
2043 );
2044 assert_eq!(
2046 e.because[3],
2047 "`c` is not met: its test `suite:c` has no recorded run"
2048 );
2049 assert_eq!(
2050 e.because[4],
2051 "2 unit(s) of work realise it across 1 handover(s), run by codex"
2052 );
2053 assert!(e.because[5].starts_with("closed_work_contradicted: "));
2054 assert!(e.because[6].starts_with("closed_work_unproven: "));
2055 assert!(e.because[7].starts_with("closed_work_not_satisfied: "));
2056 assert_eq!(e.coverage.len(), 2);
2057 let ids: Vec<&str> = e.work.iter().map(|w| w.work.id.as_str()).collect();
2058 assert_eq!(ids, ["t-1", "t-2"]);
2059
2060 let mut v2 = v.clone();
2062 v2.satisfaction.clear();
2063 v2.verdict = verdict(&v2.satisfaction);
2064 let r2 = realize(
2065 &intents(vec![v2.clone()]),
2066 &p,
2067 vec![{
2068 let mut u = IntentWorkUnit::new(IntentWorkKind::Task, "t-9");
2069 u.moved_issues = vec!["I0001".into()];
2070 u
2071 }],
2072 );
2073 let e2 = explain(&v2, &coverage, &r2);
2074 assert_eq!(
2075 e2.because[1],
2076 "1 unit(s) of work realise it across 0 handover(s)"
2077 );
2078 assert_eq!(
2080 e2.because.last().unwrap(),
2081 "closed_work_not_satisfied: every milestone is DONE and the verdict is unknown: no \
2082 criterion is declared, so no evidence can settle it"
2083 );
2084 }
2085
2086 #[test]
2087 fn a_ledger_attributes_each_task_its_episodes_providers_and_outcome() {
2088 let mut opened = BTreeMap::new();
2089 opened.insert("s-b".to_string(), ("gemini".to_string(), "g-1".to_string()));
2090 let entries = vec![
2091 entry(
2092 "1",
2093 "session.started",
2094 Some("s-a"),
2095 json!({ "provider": "codex" }),
2096 ),
2097 entry(
2099 "2",
2100 "provider.event.received",
2101 Some("s-a"),
2102 json!({ "provider": "claude-code", "provider_session": "x" }),
2103 ),
2104 entry(
2105 "3",
2106 "session.started",
2107 None,
2108 json!({ "provider": "orphan" }),
2109 ),
2110 entry("4", "session.started", Some("s-c"), json!({})),
2111 entry(
2112 "5",
2113 "task.started",
2114 Some("s-a"),
2115 json!({ "task_id": "t-1", "scope": "lib lib docs" }),
2116 ),
2117 entry("6", "plan_start", Some("s-b"), json!({ "issue": "I0009" })),
2119 entry("7", "plan_start", None, json!({ "issue": "I0008" })),
2120 entry(
2121 "8",
2122 "task.checkpoint",
2123 Some("s-b"),
2124 json!({ "task_id": "t-1" }),
2125 ),
2126 entry("9", "plan_done", Some("s-b"), json!({ "issue": "I0001" })),
2127 entry("10", "plan_done", Some("s-b"), json!({ "issue": "" })),
2128 entry(
2129 "11",
2130 "task.finished",
2131 Some("s-b"),
2132 json!({ "task_id": "t-1" }),
2133 ),
2134 entry("12", "task.started", None, json!({ "task_id": "t-2" })),
2135 entry(
2136 "13",
2137 "task.finished",
2138 None,
2139 json!({ "task_id": "t-2", "outcome": "abandoned" }),
2140 ),
2141 entry("14", "task.started", Some("s-c"), json!({ "task_id": "" })),
2142 entry(
2143 "15",
2144 "task.unknown",
2145 Some("s-c"),
2146 json!({ "task_id": "t-3" }),
2147 ),
2148 ];
2149 let tasks = tasks_from_ledger(&entries, &opened);
2150 let ids: Vec<&str> = tasks.iter().map(|t| t.id.as_str()).collect();
2151 assert_eq!(ids, ["t-1", "t-2", "t-3"]);
2152 let t1 = &tasks[0];
2153 assert_eq!(t1.outcome, "finished");
2154 assert_eq!(t1.scope, ["lib", "docs"]);
2155 assert_eq!(t1.moved_issues, ["I0001"]);
2156 assert_eq!(t1.branches, ["feature/I0001-x"]);
2157 assert_eq!(t1.providers(), ["codex", "gemini"]);
2158 assert_eq!(t1.episodes[1].provider_session, "g-1");
2159 assert_eq!(tasks[1].outcome, "abandoned");
2160 assert!(tasks[1].episodes.is_empty());
2161 assert_eq!(tasks[2].outcome, "");
2163 assert_eq!(tasks[2].episodes[0].session, "s-c");
2164 assert_eq!(tasks[2].episodes[0].provider, "");
2165 assert!(tasks[2].providers().is_empty());
2167 let mut twice = tasks[0].clone();
2168 twice.episodes.push(tasks[0].episodes[0].clone());
2169 twice.episodes.push(tasks[2].episodes[0].clone());
2170 assert_eq!(twice.providers(), ["codex", "gemini"]);
2171 }
2172
2173 #[test]
2174 fn issue_tokens_are_whole_words_each_once() {
2175 let ids = ["I0001".to_string(), "I0002".to_string()];
2176 assert_eq!(
2177 issue_tokens("I0002 then I0001, again I0002; I0001x _I0001 I0001-y", &ids),
2178 ["I0002", "I0001"]
2179 );
2180 assert!(issue_tokens("", &ids).is_empty());
2181 }
2182
2183 #[test]
2184 fn gather_names_providers_from_open_episodes_and_branches_from_peer_checkouts() {
2185 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2186 let root = repo.root();
2187 let state = root.join(STATE_DIR);
2188 let open = state.join("sessions-open");
2189 std::fs::create_dir_all(open.join("not-a-document")).unwrap();
2190 std::fs::write(
2192 open.join("s-x.yaml"),
2193 "session_id: s-x\nprovider: gemini\nprovider_session: g-1\n",
2194 )
2195 .unwrap();
2196 std::fs::write(open.join("anonymous.yaml"), "owner: nobody\n").unwrap();
2198 std::fs::write(
2199 state.join("ledger.jsonl"),
2200 concat!(
2201 r#"{"ts":"1","event":"task.started","head":"abc","branch":"master","#,
2202 r#""by":"majordomus/0.7.0","session":"s-x","task_id":"t-1","scope":"lib"}"#,
2203 "\n",
2204 r#"{"ts":"2","event":"task.started","head":"abc","branch":"master","#,
2205 r#""by":"majordomus/0.7.0","session":"s-anon","task_id":"t-2"}"#,
2206 "\n",
2207 r#"{"ts":"3","event":"task.started","head":"abc","branch":"master","#,
2209 r#""by":"majordomus/0.15.0","session":"s-x","task_id":"t-3","scope":"docs","#,
2210 r#""issue":"I0007","binding":"bound"}"#,
2211 "\n",
2212 ),
2213 )
2214 .unwrap();
2215 std::fs::write(
2217 state.join("current.yaml"),
2218 "id: t-3\ntask: \"tidy the documents\"\nprofile: implementation\nscope:\n - docs\nissue: I0008\noutcome: active\n",
2219 )
2220 .unwrap();
2221 let peer = |id: &str, checkout: serde_json::Value| -> Peer {
2222 let mut p = json!({
2223 "id": id,
2224 "client": { "name": "codex", "version": "1" },
2225 "transport": "http",
2226 "connected_at": "2026-10-02T00:00:00Z",
2227 "last_seen_seconds_ago": 0,
2228 "attached": id == "p1",
2229 "claims": [{ "name": "work", "intent": "finish it", "scope": ["lib"],
2230 "at": "2026-10-02T00:00:00Z" }],
2231 });
2232 if !checkout.is_null() {
2233 p["checkout"] = checkout;
2234 }
2235 serde_json::from_value(p).unwrap()
2236 };
2237 let peers = [
2238 peer(
2239 "p1",
2240 json!({ "id": "c1", "worktree": "/r-wt/feature/x", "branch": "feature/x",
2241 "this_checkout": false }),
2242 ),
2243 peer(
2246 "p2",
2247 json!({ "id": "c2", "worktree": "/r", "this_checkout": true }),
2248 ),
2249 peer("p3", serde_json::Value::Null),
2250 ];
2251
2252 let (units, skipped) = gather(root, &repo.index().unwrap(), &peers);
2253 assert_eq!(skipped, 0);
2254 let unit = |id: &str| {
2255 units
2256 .iter()
2257 .find(|u| u.id == id)
2258 .unwrap_or_else(|| panic!("no unit {id}: {units:?}"))
2259 };
2260 assert_eq!(unit("t-1").providers(), ["gemini"]);
2261 assert_eq!(unit("t-1").episodes[0].provider_session, "g-1");
2262 assert!(unit("t-2").providers().is_empty());
2263 assert_eq!(unit("t-3").named_issues, ["I0007", "I0008"]);
2265 assert!(unit("t-1").named_issues.is_empty());
2266
2267 let claim = unit("p1/work");
2268 assert_eq!(claim.kind, IntentWorkKind::PeerClaim);
2269 assert_eq!(claim.outcome, "attached");
2270 assert_eq!(claim.branches, ["feature/x"]);
2271 assert_eq!(claim.scope, ["lib"]);
2272 assert_eq!(claim.providers(), ["codex"]);
2273 assert_eq!(unit("p2/work").outcome, "departed");
2274 assert!(unit("p2/work").branches.is_empty());
2275 assert!(unit("p3/work").branches.is_empty());
2276 }
2277}