Skip to main content

majordomus_cli/
intent_realization.rs

1//! Intent realization: which work realises which intent, through which sessions and providers,
2//! how each link is known, and what the intent still lacks.
3//!
4//! An intent (ADR 0070) says what must become true; the plan says which issues realise it
5//! (ADR 0073). Neither says who is doing that work, in which episode, under which provider, or
6//! which handover carried it from one session to the next. That lineage already exists as
7//! records — the ledger stamps every task, handover and plan transition with its episode, the
8//! provider events name the provider of each episode, a closed session record lists the issues
9//! it moved, and a peer's claim names what it is working on and where. This module joins them
10//! to the intents. It stores nothing: every link is derived on read, so a session that ends or
11//! a provider that changes leaves the intent exactly where it was.
12//!
13//! **Every link says how it is known**, because a join that flattened a guess into a fact would
14//! make inferred lineage read as declared intent:
15//!
16//! | provenance | link                                                              |
17//! |------------|-------------------------------------------------------------------|
18//! | `declared` | the work names the issue itself: a claim or a task title citing it |
19//! | `observed` | the ledger or a session record shows the episode moving the issue  |
20//! | `derived`  | the branch the work ran on names the issue                          |
21//! | `inferred` | nothing stronger exists and an open issue's scope overlaps the work |
22//!
23//! And for each intent it answers what remains: the criteria without current evidence with the
24//! issues serving each, and the drift between closed work and unproven reality — every milestone
25//! DONE while a criterion's evidence is stale or failing is closed work that reality contradicts,
26//! reported as `closed_work_contradicted` rather than hidden behind a closed plan. An intent that
27//! was satisfied and regresses shows exactly this: its stage falls back to `verifying` and the
28//! criterion that stopped holding is named.
29//!
30//! ```
31//! use majordomus_cli::intent_realization::IntentLinkProvenance;
32//! // declared is the strongest link and inferred the weakest; a join keeps the strongest
33//! assert!(IntentLinkProvenance::Declared < IntentLinkProvenance::Inferred);
34//! assert_eq!(IntentLinkProvenance::Observed.as_str(), "observed");
35//! ```
36
37use 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
55/// The command that shows every realization finding again.
56pub const REPRODUCE: &str = "majordomus intent realization";
57
58/// Where the untracked half of the layer lives, relative to the repository root.
59const STATE_DIR: &str = ".ai/local/state";
60
61// ---------------------------------------------------------------- vocabulary
62
63/// How a link from a piece of work to an issue is known, strongest first.
64///
65/// ```
66/// use majordomus_cli::intent_realization::IntentLinkProvenance;
67/// assert_eq!(
68///     serde_json::to_string(&IntentLinkProvenance::Inferred).unwrap(),
69///     "\"inferred\""
70/// );
71/// assert!(IntentLinkProvenance::Observed < IntentLinkProvenance::Derived);
72/// ```
73#[derive(
74    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
75)]
76#[serde(rename_all = "snake_case")]
77pub enum IntentLinkProvenance {
78    /// The work names the issue itself.
79    Declared,
80    /// A recorded event shows the work's episode moving the issue.
81    Observed,
82    /// The branch the work ran on names the issue, by the worktree naming rule.
83    Derived,
84    /// Nothing stronger exists, and an open issue's scope overlaps the work's scope.
85    Inferred,
86}
87
88impl IntentLinkProvenance {
89    /// The word every surface prints for how this link is known.
90    ///
91    /// ```
92    /// use majordomus_cli::intent_realization::IntentLinkProvenance;
93    /// assert_eq!(IntentLinkProvenance::Declared.as_str(), "declared");
94    /// ```
95    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/// Which record a unit of work was read from.
106///
107/// ```
108/// use majordomus_cli::intent_realization::IntentWorkKind;
109/// assert_eq!(serde_json::to_string(&IntentWorkKind::PeerClaim).unwrap(), "\"peer_claim\"");
110/// ```
111#[derive(
112    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
113)]
114#[serde(rename_all = "snake_case")]
115pub enum IntentWorkKind {
116    /// A task of this checkout's ledger: the durable unit a handover continues, across every
117    /// episode that worked on it.
118    Task,
119    /// A claim a peer announced on the shared server's board.
120    PeerClaim,
121    /// A closed session record of the shared layer whose episode this checkout's ledger does
122    /// not hold — another machine's or another checkout's work.
123    SessionRecord,
124}
125
126impl IntentWorkKind {
127    /// The word every surface prints for the record this work came from.
128    ///
129    /// ```
130    /// use majordomus_cli::intent_realization::IntentWorkKind;
131    /// assert_eq!(IntentWorkKind::SessionRecord.as_str(), "session_record");
132    /// ```
133    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/// The fact a link was read from.
143///
144/// ```
145/// use majordomus_cli::intent_realization::IntentLinkVia;
146/// assert_eq!(serde_json::to_string(&IntentLinkVia::BranchIssue).unwrap(), "\"branch_issue\"");
147/// assert_eq!(IntentLinkVia::ScopeOverlap.provenance().as_str(), "inferred");
148/// ```
149#[derive(
150    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
151)]
152#[serde(rename_all = "snake_case")]
153pub enum IntentLinkVia {
154    /// The claim or the task's own words cite the issue by id.
155    NamedIssue,
156    /// A plan transition in the work's episode, or the session record's `issues`.
157    MovedIssue,
158    /// The branch name carries the issue id.
159    BranchIssue,
160    /// An open issue's scope overlaps the work's scope.
161    ScopeOverlap,
162}
163
164impl IntentLinkVia {
165    /// The word every surface prints for the fact this link was read from.
166    ///
167    /// ```
168    /// use majordomus_cli::intent_realization::IntentLinkVia;
169    /// assert_eq!(IntentLinkVia::ScopeOverlap.as_str(), "scope_overlap");
170    /// ```
171    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    /// The provenance this kind of fact carries. One mapping, so no reader can grade the same
181    /// fact two ways.
182    ///
183    /// ```
184    /// use majordomus_cli::intent_realization::{IntentLinkProvenance, IntentLinkVia};
185    /// assert_eq!(IntentLinkVia::MovedIssue.provenance(), IntentLinkProvenance::Observed);
186    /// ```
187    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// ---------------------------------------------------------------- the work, as recorded
198
199/// One execution episode of a piece of work, and the provider that ran it when a record says.
200/// A provider is never guessed: an episode opened by hand has none.
201///
202/// ```
203/// use majordomus_cli::intent_realization::IntentEpisode;
204/// let e = IntentEpisode {
205///     session: "s-1".into(),
206///     provider: "codex".into(),
207///     provider_session: String::new(),
208/// };
209/// assert_eq!(e.provider, "codex");
210/// ```
211#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
212pub struct IntentEpisode {
213    /// The episode id, `s-...`; empty for a peer's connection, which is not an episode.
214    #[serde(default, skip_serializing_if = "String::is_empty")]
215    pub session: String,
216    /// The provider, as its own events or the peer's client name it; empty when unknown.
217    #[serde(default, skip_serializing_if = "String::is_empty")]
218    pub provider: String,
219    /// The provider's own session identity, when recorded.
220    #[serde(default, skip_serializing_if = "String::is_empty")]
221    pub provider_session: String,
222}
223
224/// One unit of work as its records describe it, before it is joined to any intent.
225///
226/// ```
227/// use majordomus_cli::intent_realization::{IntentWorkKind, IntentWorkUnit};
228/// let unit = IntentWorkUnit::new(IntentWorkKind::Task, "t-1");
229/// assert!(unit.episodes.is_empty());
230/// assert_eq!(unit.id, "t-1");
231/// ```
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
233pub struct IntentWorkUnit {
234    /// Which record it was read from.
235    pub kind: IntentWorkKind,
236    /// Its identity in that record: a task id, a session id, or `<peer>/<claim>`.
237    pub id: String,
238    /// What it says it is, in its own words.
239    #[serde(default, skip_serializing_if = "String::is_empty")]
240    pub title: String,
241    /// Where it stands: a task's outcome, a session's, or `attached`/`departed` for a claim.
242    #[serde(default, skip_serializing_if = "String::is_empty")]
243    pub outcome: String,
244    /// The branches it ran on, in first-seen order.
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub branches: Vec<String>,
247    /// The paths it claims.
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub scope: Vec<String>,
250    /// Every episode that worked on it, in first-seen order.
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub episodes: Vec<IntentEpisode>,
253    /// The handovers that carried it from one episode to the next, repository-relative.
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub handovers: Vec<String>,
256    /// Issue ids its own words cite.
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub named_issues: Vec<String>,
259    /// Issue ids a recorded event shows it moving.
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub moved_issues: Vec<String>,
262}
263
264impl IntentWorkUnit {
265    /// An empty unit of a kind and id, for a reader to fill.
266    ///
267    /// ```
268    /// use majordomus_cli::intent_realization::{IntentWorkKind, IntentWorkUnit};
269    /// let u = IntentWorkUnit::new(IntentWorkKind::PeerClaim, "p3/x");
270    /// assert_eq!(u.kind, IntentWorkKind::PeerClaim);
271    /// ```
272    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    /// The providers that ran it, each once, in first-seen order.
288    ///
289    /// ```
290    /// use majordomus_cli::intent_realization::{IntentEpisode, IntentWorkKind, IntentWorkUnit};
291    /// let mut u = IntentWorkUnit::new(IntentWorkKind::Task, "t-1");
292    /// for p in ["claude-code", "codex", "claude-code", ""] {
293    ///     u.episodes.push(IntentEpisode {
294    ///         session: String::new(), provider: p.into(), provider_session: String::new() });
295    /// }
296    /// assert_eq!(u.providers(), ["claude-code", "codex"]);
297    /// ```
298    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// ---------------------------------------------------------------- the join
316
317/// One link from a piece of work to an intent: through which issue and milestone, serving
318/// which of its criteria, read from which fact, known how.
319///
320/// ```
321/// use majordomus_cli::intent::IntentStage;
322/// use majordomus_cli::intent_realization::{IntentLink, IntentLinkProvenance, IntentLinkVia};
323/// let l = IntentLink {
324///     intent: "intent-lifecycle".into(), stage: IntentStage::Executing,
325///     milestone: "intent-lifecycle".into(), issue: "I1900".into(),
326///     criteria: vec!["stage-is-derived".into()],
327///     via: IntentLinkVia::BranchIssue, provenance: IntentLinkProvenance::Derived,
328/// };
329/// assert_eq!(l.provenance, l.via.provenance());
330/// ```
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
332pub struct IntentLink {
333    /// The intent.
334    pub intent: String,
335    /// Its derived stage.
336    pub stage: IntentStage,
337    /// The milestone that links the issue to it.
338    pub milestone: String,
339    /// The issue that links the work to that milestone.
340    pub issue: String,
341    /// The criteria of this intent the issue declares it serves; empty when it declares none.
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub criteria: Vec<String>,
344    /// The fact the link was read from.
345    pub via: IntentLinkVia,
346    /// How the link is known, from `via`.
347    pub provenance: IntentLinkProvenance,
348}
349
350/// A unit of work's links read strongest first, then by intent, then by issue.
351impl Ordered for IntentLink {
352    fn order_key(&self) -> OrderKey<'_> {
353        OrderKey::plain(&self.intent, &self.issue).ranked(rank(self.provenance, None))
354    }
355}
356
357/// The explicit position a link's provenance, and then the record a piece of work came from,
358/// give it: provenance first, because the strongest link is the one a reader reads first.
359fn 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/// A unit of work with every intent it realises, or the reason it realises none.
365///
366/// ```
367/// use majordomus_cli::intent_realization::{IntentRealizedWork, IntentWorkKind, IntentWorkUnit};
368/// let w = IntentRealizedWork {
369///     work: IntentWorkUnit::new(IntentWorkKind::Task, "t-1"),
370///     links: vec![],
371///     unlinked: Some("it names no issue".into()),
372/// };
373/// assert!(w.links.is_empty() && w.unlinked.is_some());
374/// ```
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
376pub struct IntentRealizedWork {
377    /// The work, as recorded.
378    pub work: IntentWorkUnit,
379    /// Every intent it realises, strongest link per issue.
380    pub links: Vec<IntentLink>,
381    /// When it realises none: the first link that is missing.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub unlinked: Option<String>,
384}
385
386/// The ids in a text that are issues of the plan, each once, in order of appearance.
387///
388/// ```
389/// use majordomus_cli::intent_realization::issue_tokens;
390/// let ids = ["I1900".to_string(), "I0001".to_string()];
391/// assert_eq!(issue_tokens("finish I1900; not XI1900 or I19000", &ids), ["I1900"]);
392/// ```
393pub 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
403/// Join one unit of work to the intents, through the plan.
404///
405/// The candidate issues are, strongest first: those its words cite, those a recorded event shows
406/// it moving, the one its branch names. Only when none of those exists are the open issues whose
407/// scope overlaps its scope taken, and they are marked `inferred`. Each issue is followed to its
408/// milestone and the milestone to the intents naming it, keeping the strongest link per issue.
409///
410/// ```
411/// use majordomus_cli::intent::Intents;
412/// use majordomus_cli::intent_realization::{link, IntentWorkKind, IntentWorkUnit};
413/// # let plan: majordomus_cli::plan::Plan = serde_json::from_value(serde_json::json!({
414/// #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
415/// #                 "active_milestone": ""},
416/// #     "statuses": {"issue": [], "milestone": []},
417/// #     "milestones": [], "issues": [], "waves": [], "edges": [],
418/// #     "milestone_edges": [], "findings": []})).unwrap();
419/// let intents = Intents { intents: vec![], findings: vec![] };
420/// let w = link(IntentWorkUnit::new(IntentWorkKind::Task, "t-1"), &intents, &plan);
421/// // nothing to follow is itself the answer, and it says why
422/// assert!(w.links.is_empty());
423/// assert!(w.unlinked.unwrap().contains("names no issue"));
424/// ```
425pub 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        // the strongest fact an issue is offered by is its link, whatever the order offered
433        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    // every candidate is an issue of the plan: `offer` and the scope walk only take those
464    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// ---------------------------------------------------------------- per intent
529
530/// A criterion an intent still lacks, with the issues that declare they serve it.
531///
532/// ```
533/// use majordomus_cli::intent::IntentEvidenceState;
534/// use majordomus_cli::intent_realization::IntentUnmetCriterion;
535/// let c = IntentUnmetCriterion {
536///     id: "surfaces-agree".into(), criterion: "the surfaces agree".into(),
537///     evidence: "test".into(), state: IntentEvidenceState::Stale,
538///     reproduce: None, issues: vec!["I1900".into()],
539/// };
540/// assert_eq!(c.state, IntentEvidenceState::Stale);
541/// ```
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
543pub struct IntentUnmetCriterion {
544    /// The criterion id.
545    pub id: String,
546    /// What must be observably true.
547    pub criterion: String,
548    /// The kind of evidence that settles it.
549    pub evidence: String,
550    /// Why it is not met.
551    pub state: IntentEvidenceState,
552    /// The command that produces its evidence, when one is known.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub reproduce: Option<String>,
555    /// The issues declaring they serve it, in plan order.
556    #[serde(default, skip_serializing_if = "Vec::is_empty")]
557    pub issues: Vec<String>,
558}
559
560/// A unit of work as one intent sees it: which record, how it stands, and the strongest link.
561///
562/// ```
563/// use majordomus_cli::intent_realization::{IntentLinkProvenance, IntentWorkKind, IntentWorkRef};
564/// let r = IntentWorkRef {
565///     kind: IntentWorkKind::Task, id: "t-1".into(), outcome: "active".into(),
566///     provenance: IntentLinkProvenance::Observed, providers: vec!["codex".into()],
567///     handovers: 1,
568/// };
569/// assert_eq!(r.handovers, 1);
570/// ```
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
572pub struct IntentWorkRef {
573    /// Which record.
574    pub kind: IntentWorkKind,
575    /// Its identity there.
576    pub id: String,
577    /// Where it stands.
578    #[serde(default, skip_serializing_if = "String::is_empty")]
579    pub outcome: String,
580    /// The strongest of its links to this intent.
581    pub provenance: IntentLinkProvenance,
582    /// The providers that ran it.
583    #[serde(default, skip_serializing_if = "Vec::is_empty")]
584    pub providers: Vec<String>,
585    /// How many handovers carried it.
586    pub handovers: usize,
587}
588
589/// The work realising an intent reads strongest link first, then by the record it came from,
590/// then by its identity.
591impl 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/// One intent, realised: how far reality is from it, the work realising it and by whom, and the
598/// drift between closed work and current evidence.
599///
600/// ```
601/// use majordomus_cli::intent::IntentStage;
602/// use majordomus_cli::intent_realization::IntentRealizationView;
603/// let v = IntentRealizationView {
604///     intent: "x".into(), title: "X".into(), stage: IntentStage::Verifying,
605///     criteria: 2, met: 1, unmet: vec![], work: vec![], providers: vec![], findings: vec![],
606/// };
607/// assert!(v.met < v.criteria);
608/// ```
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610pub struct IntentRealizationView {
611    /// The intent.
612    pub intent: String,
613    /// Its title.
614    pub title: String,
615    /// Its derived stage.
616    pub stage: IntentStage,
617    /// How many criteria it declares.
618    pub criteria: usize,
619    /// How many have current evidence.
620    pub met: usize,
621    /// Every criterion without current evidence.
622    pub unmet: Vec<IntentUnmetCriterion>,
623    /// Every unit of work linked to it, strongest link first.
624    pub work: Vec<IntentWorkRef>,
625    /// Every provider that ran work linked to it, each once.
626    pub providers: Vec<String>,
627    /// Drift between the plan's closure and the evidence.
628    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
658/// The drift of one intent: the plan's closure against the evidence's verdict.
659///
660/// * `closed_work_contradicted` — every milestone is DONE and a criterion's recorded evidence
661///   says no: stale (a pass that is no longer current evidence at the checkout), or failing.
662///   A satisfied intent whose reality regresses lands here.
663/// * `closed_work_unproven` — every milestone is DONE and a criterion has never had evidence.
664/// * `criterion_closed_unmet` — every issue declaring it serves a criterion is DONE, and the
665///   criterion is not met, while the intent is still being executed.
666///
667/// And the stage against the verdict, once per intent (ADR 0107):
668///
669/// * `evidence_ahead_of_plan` ([`EVIDENCE_AHEAD_OF_PLAN`]) — the verdict is `satisfied` while
670///   the stage is `planned` or `executing`: the evidence settles the intent before the plan
671///   closes it.
672/// * `closed_work_not_satisfied` ([`CLOSED_WORK_NOT_SATISFIED`]) — every milestone is DONE (the
673///   stage is `verifying`) and the verdict is `unsatisfied` or `unknown`, naming the criteria
674///   that hold it back.
675///
676/// ```
677/// use majordomus_cli::intent::{
678///     verdict, IntentCriterion, IntentEvidenceState, IntentStage, IntentView,
679/// };
680/// use majordomus_cli::intent_realization::drift;
681/// # let plan: majordomus_cli::plan::Plan = serde_json::from_value(serde_json::json!({
682/// #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
683/// #                 "active_milestone": ""},
684/// #     "statuses": {"issue": [], "milestone": []},
685/// #     "milestones": [], "issues": [], "waves": [], "edges": [],
686/// #     "milestone_edges": [], "findings": []})).unwrap();
687/// let satisfaction = vec![IntentCriterion {
688///     id: "c".into(), criterion: "c".into(), evidence: "test".into(),
689///     reference: "t".into(), state: IntentEvidenceState::Failing, met: false,
690///     proof: None, reproduce: None }];
691/// let view = IntentView {
692///     id: "x".into(), title: "X".into(), statement: String::new(), invariants: vec![],
693///     stage: IntentStage::Verifying, milestones: vec![], met: 0,
694///     verdict: verdict(&satisfaction), satisfaction,
695///     governance: vec![], non_goals: vec![], superseded_by: None, source: String::new(),
696/// };
697/// let found = drift(&view, &plan);
698/// assert_eq!(found[0].code, "closed_work_contradicted");
699/// assert_eq!(found[1].code, "closed_work_not_satisfied");
700/// ```
701pub 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
763/// The drift code of an intent whose verdict is `satisfied` while its stage is `planned` or
764/// `executing`: the evidence settles it before the plan closes it.
765pub const EVIDENCE_AHEAD_OF_PLAN: &str = "evidence_ahead_of_plan";
766
767/// The drift code of an intent whose milestones are all DONE (stage `verifying`) and whose
768/// verdict is not `satisfied`.
769pub const CLOSED_WORK_NOT_SATISFIED: &str = "closed_work_not_satisfied";
770
771/// Where the stage and the verdict disagree in a way ADR 0107 names, one finding per intent.
772fn verdict_drift(view: &IntentView, out: &mut Vec<IntentFinding>) {
773    match (view.stage, view.verdict.state) {
774        (IntentStage::Planned | IntentStage::Executing, IntentVerdictState::Satisfied) => {
775            // planned or executing means every milestone resolved (one that does not holds the
776            // stage at declared), so each carries a status and none is skipped here
777            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/// Every intent, realised, and every unit of work, joined: what `majordomus intent realization`
823/// answers.
824///
825/// ```
826/// use majordomus_cli::intent_realization::IntentRealization;
827/// let r = IntentRealization { intents: vec![], work: vec![], orphans: 0, findings: vec![] };
828/// assert_eq!(r.orphans, 0);
829/// ```
830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
831pub struct IntentRealization {
832    /// Every intent, in identity order.
833    pub intents: Vec<IntentRealizationView>,
834    /// Every unit of work read, with its links.
835    pub work: Vec<IntentRealizedWork>,
836    /// How many units of work realise no intent.
837    pub orphans: usize,
838    /// Every finding: each intent's drift, then the live work that serves no intent.
839    pub findings: Vec<IntentFinding>,
840}
841
842/// Join every unit of work to every intent. Pure: the records are already read.
843///
844/// ```
845/// use majordomus_cli::intent::Intents;
846/// use majordomus_cli::intent_realization::{realize, IntentWorkKind, IntentWorkUnit};
847/// # let plan: majordomus_cli::plan::Plan = serde_json::from_value(serde_json::json!({
848/// #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
849/// #                 "active_milestone": ""},
850/// #     "statuses": {"issue": [], "milestone": []},
851/// #     "milestones": [], "issues": [], "waves": [], "edges": [],
852/// #     "milestone_edges": [], "findings": []})).unwrap();
853/// let mut live = IntentWorkUnit::new(IntentWorkKind::Task, "t-1");
854/// live.outcome = "active".into();
855/// let r = realize(&Intents { intents: vec![], findings: vec![] }, &plan, vec![live]);
856/// // live work that serves no intent is an orphan, and is named
857/// assert_eq!(r.orphans, 1);
858/// assert_eq!(r.findings[0].code, "work_serves_no_intent");
859/// ```
860pub 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// ---------------------------------------------------------------- explain
958
959/// Why an intent stands where it stands: the record, each sentence that explains the stage and
960/// each criterion, the plan's coverage of its criteria, and the work realising it.
961///
962/// ```
963/// use majordomus_cli::intent_realization::IntentExplanation;
964/// // every sentence is derived; an explanation holds no authored status
965/// let fields = serde_json::to_value(schemars::schema_for!(IntentExplanation)).unwrap();
966/// assert!(fields["properties"]["because"].is_object());
967/// ```
968#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
969pub struct IntentExplanation {
970    /// The intent, derived.
971    pub intent: IntentView,
972    /// One sentence per fact the stage rests on: the stage, each criterion, the work.
973    pub because: Vec<String>,
974    /// The plan's coverage of each of its criteria.
975    pub coverage: Vec<CriterionCoverage>,
976    /// Its realization.
977    pub realization: IntentRealizationView,
978    /// Every unit of work linked to it, with its links.
979    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
991/// Explain one intent out of the derivations already made.
992///
993/// ```
994/// use majordomus_cli::intent::{verdict, IntentStage, IntentView, Intents};
995/// use majordomus_cli::intent_plan::IntentCoverage;
996/// use majordomus_cli::intent_realization::{explain, realize};
997/// # let plan: majordomus_cli::plan::Plan = serde_json::from_value(serde_json::json!({
998/// #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
999/// #                 "active_milestone": ""},
1000/// #     "statuses": {"issue": [], "milestone": []},
1001/// #     "milestones": [], "issues": [], "waves": [], "edges": [],
1002/// #     "milestone_edges": [], "findings": []})).unwrap();
1003/// let view = IntentView {
1004///     id: "x".into(), title: "X".into(), statement: String::new(), invariants: vec![],
1005///     stage: IntentStage::Declared, milestones: vec![], met: 0, satisfaction: vec![],
1006///     verdict: verdict(&[]),
1007///     governance: vec![], non_goals: vec![], superseded_by: None, source: String::new(),
1008/// };
1009/// let intents = Intents { intents: vec![view.clone()], findings: vec![] };
1010/// let r = realize(&intents, &plan, vec![]);
1011/// let coverage = IntentCoverage { criteria: vec![], issues: vec![], findings: vec![] };
1012/// let e = explain(&view, &coverage, &r);
1013/// assert!(e.because[0].starts_with("declared"));
1014/// ```
1015pub 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
1139// ---------------------------------------------------------------- reading the records
1140
1141fn 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
1149/// The tasks of a ledger, each with every episode that worked on it, the provider of each
1150/// episode where a record names one, the handovers that carried it, and the issues its episodes
1151/// moved. `providers` maps an episode to its provider and provider session, as the open episode
1152/// files name them; the ledger's own provider events add to it.
1153///
1154/// A plan transition is attributed to the task whose event last preceded it in the same episode,
1155/// so an episode that ran two tasks in turn does not credit the first with the second's issues.
1156///
1157/// ```
1158/// use majordomus_cli::intent_realization::tasks_from_ledger;
1159/// use majordomus_cli::ledger::Entry;
1160/// let line = |s: &str| serde_json::from_str::<Entry>(s).unwrap();
1161/// let env = r#""head":"h","by":"majordomus/0""#;
1162/// let entries = vec![
1163///     line(&format!(r#"{{"ts":"1","event":"task.started",{env},"branch":"m","session":"s-a",
1164///         "task_id":"t-1","scope":"lib docs"}}"#)),
1165///     line(&format!(r#"{{"ts":"2","event":"provider.event.received",{env},"branch":"m",
1166///         "session":"s-a","provider":"claude-code","provider_session":"x"}}"#)),
1167///     line(&format!(r#"{{"ts":"3","event":"task.handed_over",{env},"branch":"m","session":"s-a",
1168///         "task_id":"t-1","handover_path":"h.md"}}"#)),
1169///     line(&format!(r#"{{"ts":"4","event":"plan_start",{env},"branch":"m","session":"s-b",
1170///         "issue":"I0001"}}"#)),
1171///     line(&format!(r#"{{"ts":"5","event":"task.checkpoint",{env},"branch":"m","session":"s-b",
1172///         "task_id":"t-1"}}"#)),
1173///     line(&format!(r#"{{"ts":"6","event":"plan_done",{env},"branch":"m","session":"s-b",
1174///         "issue":"I0002"}}"#)),
1175/// ];
1176/// let tasks = tasks_from_ledger(&entries, &Default::default());
1177/// let t = &tasks[0];
1178/// assert_eq!(t.scope, ["lib", "docs"]);
1179/// assert_eq!(t.handovers, ["h.md"]);
1180/// assert_eq!(t.providers(), ["claude-code"]);
1181/// // I0001 moved before t-1 was resumed in s-b, so only I0002 is its own
1182/// assert_eq!(t.moved_issues, ["I0002"]);
1183/// assert_eq!(t.outcome, "active");
1184/// ```
1185pub fn tasks_from_ledger(
1186    entries: &[Entry],
1187    providers: &BTreeMap<String, (String, String)>,
1188) -> Vec<IntentWorkUnit> {
1189    let mut providers = providers.clone();
1190    // the episode's own start line names its provider since ADR 0075; a provider event names it
1191    // for episodes opened before that
1192    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                    // the issue the worker named at start (ADR 0111) is a declared link,
1234                    // exactly as an issue cited in the title is: the binding and this join
1235                    // must not disagree about what one task said it executes
1236                    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            // `current` only ever names a task `units` holds
1256            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
1267/// Every unit of work this checkout can see, and the ledger lines it could not read.
1268///
1269/// The tasks of this checkout's ledger, titled from the active task or its archive; the closed
1270/// session records of the shared layer whose episode no task here already holds; and every claim
1271/// on the peer board. Nothing is written.
1272///
1273/// ```
1274/// use majordomus_cli::intent_realization::gather;
1275/// # fn demo(index: &majordomus_cli::index::Index) {
1276/// let (units, skipped) = gather(std::path::Path::new("/nonexistent"), index, &[]);
1277/// // a checkout whose lifecycle never ran still has the shared layer's session records
1278/// assert_eq!(skipped, 0);
1279/// let _ = units;
1280/// # }
1281/// ```
1282pub 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    // the active task first: it may predate this ledger's reader, and it carries the title
1310    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        // the paths its episode left changed are what it touched: an observation, still only
1384        // ever an `inferred` link, because touching a path is not serving the issue that owns it
1385        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        // each fact grades to exactly one provenance, and the four are four
1571        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        // the same issue offered by an event and by the branch: the event's link is kept
1595        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        // declared sorts before observed; I9999 is no issue of the plan and links nothing
1606        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        // only the branch names it: derived
1618        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        // the issue exists, but no intent names its milestone
1668        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        // still executing: only a criterion every serving issue closed is drift
1715        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        // a planned intent is held the same way: closing every issue that serves a criterion
1722        // does not meet it
1723        v.stage = IntentStage::Planned;
1724        assert_eq!(codes(&drift(&v, &p)), ["criterion_closed_unmet"]);
1725
1726        // a declared intent has no closure to drift from
1727        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        // the evidence settles the intent while the plan has not closed it
1739        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        // closed work the ledger cannot settle: unknown, named with each criterion's own state
1761        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        // where the two agree there is nothing to report
1777        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            // an intent the realization never saw is realised by nothing, and says so
1965            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        // another intent's coverage of a criterion with the same id is not this one's
2045        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        // work without a provider names none
2061        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        // closed work over an intent declaring no criterion: nothing can settle it
2079        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            // a later provider event does not overwrite what the start line named
2098            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            // a plan transition before any task ran in the episode belongs to none
2118            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        // an event of the task family that moves no outcome still records the episode
2162        assert_eq!(tasks[2].outcome, "");
2163        assert_eq!(tasks[2].episodes[0].session, "s-c");
2164        assert_eq!(tasks[2].episodes[0].provider, "");
2165        // an episode without a provider names none, and a provider is named once
2166        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        // the one open episode that names itself gives its task a provider
2191        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        // a document without a session id, and an entry that is no document, name nobody
2197        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                // a task that named the issue it executes at start (ADR 0111)
2208                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        // and whose record says the same of another issue: both are what it declared
2216        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            // a checkout whose branch could not be read, and a peer read without a checkout,
2244            // name no branch
2245            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        // the title cites no issue; the names come from what the task declared at start
2264        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}