Skip to main content

majordomus_cli/capability/builtin/
intent_realization.rs

1//! The `intent_realization` module: which work realises which intent, and why an intent stands
2//! where it stands.
3//!
4//! Two read-only capabilities over [`crate::intent_realization`]. `intent_realization.work`
5//! joins this checkout's ledger tasks (with every episode, provider and handover), the shared
6//! layer's closed session records and the peer board to the intents, each link carrying its
7//! provenance, and reports each intent's unmet criteria and drift. `intent_realization.explain`
8//! answers one intent: the sentences its stage rests on, the plan's coverage of its criteria and
9//! the work realising it. Nothing is stored: a session ending or a provider changing moves no
10//! intent, because no intent state was ever held by either (ADR 0075).
11//!
12//! ```
13//! use majordomus_cli::capability::builtin::intent_realization;
14//! let m = intent_realization::module();
15//! let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
16//! assert_eq!(ids, ["intent_realization.work", "intent_realization.explain"]);
17//! ```
18
19use std::path::PathBuf;
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
25use crate::capability::handler::{CapabilityError, Context};
26use crate::capability::model::{CachePolicy, Exposure, Stability};
27use crate::capability::module::ModuleDescriptor;
28use crate::intent::{IntentFinding, Intents, RepositoryEvidence, INTENT};
29use crate::intent_realization::{explain, gather, realize, IntentExplanation, IntentRealization};
30use crate::plan::Plan;
31use crate::{capability, module};
32
33use super::{get, mcp};
34
35/// Which intent to answer the realization of; every intent when absent.
36///
37/// ```
38/// use majordomus_cli::capability::builtin::intent_realization::IntentRealizationInput;
39/// let all = IntentRealizationInput::default();
40/// assert!(all.intent.is_none());
41/// ```
42#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct IntentRealizationInput {
45    /// An intent id. When given, only that intent, the work linked to it and its findings are
46    /// answered; the unlinked work is left out.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub intent: Option<String>,
49}
50
51fn first_intent(ctx: &CaseContext<'_>) -> Option<String> {
52    ctx.index
53        .objects
54        .iter()
55        .find(|o| o.kind == INTENT)
56        .map(|o| o.identity.clone())
57}
58
59impl BenchmarkCases for IntentRealizationInput {
60    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
61        let mut cases = vec![NamedCase::new(
62            "every-intent",
63            IntentRealizationInput::default(),
64        )];
65        // the narrowed read is measured in every repository, as IntentExplainInput's is: on an
66        // intent the repository holds, or else on one it does not, so the `intent` parameter
67        // always carries an example (case 92) and the not-found answer is measured too
68        cases.push(match first_intent(ctx) {
69            Some(id) => NamedCase::new("first-intent", IntentRealizationInput { intent: Some(id) }),
70            None => NamedCase::new(
71                "absent-intent",
72                IntentRealizationInput {
73                    intent: Some("absent".into()),
74                },
75            ),
76        });
77        cases
78    }
79}
80
81/// Which intent to explain, by the id that is also its file name.
82///
83/// ```
84/// use majordomus_cli::capability::builtin::intent_realization::IntentExplainInput;
85/// let input = IntentExplainInput { id: "intent-lifecycle".into() };
86/// assert_eq!(input.id, "intent-lifecycle");
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct IntentExplainInput {
91    /// The intent's id.
92    pub id: String,
93}
94
95impl BenchmarkCases for IntentExplainInput {
96    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
97        let id = first_intent(ctx);
98        vec![NamedCase::new(
99            if id.is_some() {
100                "first-intent"
101            } else {
102                "absent"
103            },
104            IntentExplainInput {
105                id: id.unwrap_or_else(|| "absent".into()),
106            },
107        )]
108    }
109}
110
111fn derived(ctx: &Context) -> Result<(Plan, Intents, IntentRealization), CapabilityError> {
112    let plan = Plan::build(&ctx.index);
113    let evidence = RepositoryEvidence::load(&ctx.index)
114        .map_err(|e| CapabilityError::Internal(e.to_string()))?;
115    let intents = Intents::build(&ctx.index, &plan, &evidence);
116    let root = PathBuf::from(&ctx.index.repository.root);
117    let (units, skipped) = gather(&root, &ctx.index, &ctx.peers.list());
118    let mut realization = realize(&intents, &plan, units);
119    if skipped > 0 {
120        realization.findings.push(IntentFinding {
121            level: crate::intent::WARN.into(),
122            code: "ledger_lines_skipped".into(),
123            subject: ".ai/local/state/ledger.jsonl".into(),
124            message: format!(
125                "{skipped} ledger line(s) could not be read, so the work they recorded is not \
126                 joined; run `majordomus doctor`"
127            ),
128            reproduce: crate::intent_realization::REPRODUCE.into(),
129        });
130    }
131    Ok((plan, intents, realization))
132}
133
134fn not_found(id: &str) -> CapabilityError {
135    CapabilityError::NotFound(format!(
136        "no intent '{id}'; `intents.list` names every one this repository holds"
137    ))
138}
139
140fn realization_work(
141    ctx: &Context,
142    input: IntentRealizationInput,
143) -> Result<IntentRealization, CapabilityError> {
144    let (_, intents, mut r) = derived(ctx)?;
145    let Some(id) = input
146        .intent
147        .as_deref()
148        .map(str::trim)
149        .filter(|s| !s.is_empty())
150    else {
151        return Ok(r);
152    };
153    if intents.intent(id).is_none() {
154        return Err(not_found(id));
155    }
156    r.intents.retain(|v| v.intent == id);
157    r.work.retain(|w| w.links.iter().any(|l| l.intent == id));
158    r.findings.retain(|f| f.subject == id);
159    r.orphans = 0;
160    Ok(r)
161}
162
163fn realization_explain(
164    ctx: &Context,
165    input: IntentExplainInput,
166) -> Result<IntentExplanation, CapabilityError> {
167    let (plan, intents, r) = derived(ctx)?;
168    let view = intents
169        .intent(input.id.trim())
170        .ok_or_else(|| not_found(&input.id))?;
171    let coverage = Intents::coverage(&ctx.index, &plan);
172    Ok(explain(view, &coverage, &r))
173}
174
175/// The module the registry composes: two read-only capabilities over one join, each declared
176/// once here and projected to the command line, HTTP, MCP and OpenAPI from that declaration.
177///
178/// ```
179/// use majordomus_cli::capability::builtin::intent_realization;
180/// let m = intent_realization::module();
181/// assert_eq!(m.id.as_str(), "intent_realization");
182/// ```
183pub fn module() -> ModuleDescriptor {
184    module! {
185        id: "intent_realization",
186        title: "Intent realization",
187        description: "Which work realises which intent: every task of this checkout's ledger with the episodes, providers and handovers that carried it, every closed session record and every claim on the peer board, joined through the plan to the intents they serve, each link marked declared, observed, derived or inferred; each intent's unmet criteria with the issues serving them; and the drift between closed work and current evidence. Derived on every read and stored nowhere, so an intent outlives every session and provider that worked on it.",
188        stability: Stability::BehaviorallyVerified,
189        capabilities: [
190            capability! {
191                id: "intent_realization.work",
192                title: "Which work realises which intent, and what each still lacks",
193                description: "Every unit of work this checkout can see — ledger tasks with their episodes, providers and handovers, closed session records, peer claims — with every intent it realises through issue, milestone and criterion, each link's provenance (declared: the work cites the issue; observed: its episode moved the issue; derived: its branch names the issue; inferred: only an open issue's scope overlaps) or the first missing link; every intent with its unmet criteria and the issues serving each, the work and providers realising it, and its drift: closed_work_contradicted when every milestone is DONE and a criterion's evidence is stale or failing, closed_work_unproven when it never existed, criterion_closed_unmet when every serving issue is DONE and the criterion is not met; and where the stage and the verdict disagree (ADR 0107), evidence_ahead_of_plan when the verdict is satisfied while the stage is planned or executing, closed_work_not_satisfied when every milestone is DONE and the verdict is unsatisfied or unknown. Live work serving no intent is a warning.",
194                input: IntentRealizationInput,
195                output: IntentRealization,
196                stability: Stability::BehaviorallyVerified,
197                exposure: Exposure {
198                    mcp: mcp("majordomus_intent_realization"),
199                    http: get("/api/v1/intents/realization"),
200                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "realization".into()] }),
201                },
202                tags: ["intent", "session", "handover", "peers"],
203                cache: CachePolicy::Disabled,
204                handler: realization_work,
205            },
206            capability! {
207                id: "intent_realization.explain",
208                title: "Why an intent stands where it stands",
209                description: "One intent explained from the derivations alone: a sentence for its stage naming each milestone's derived status, a sentence per criterion naming its evidence state, the plan's coverage of it and the command that reproduces it, a sentence for the work and providers realising it, and each drift finding; beside them the derived intent, the coverage of its criteria, its realization and the work linked to it.",
210                input: IntentExplainInput,
211                output: IntentExplanation,
212                stability: Stability::BehaviorallyVerified,
213                exposure: Exposure {
214                    mcp: mcp("majordomus_intent_explain"),
215                    http: get("/api/v1/intents/explain"),
216                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "explain".into()] }),
217                },
218                tags: ["intent", "evidence", "explain"],
219                cache: CachePolicy::Disabled,
220                handler: realization_explain,
221            },
222        ],
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    /// The declaration is the only place these names exist; a refactor that dropped an exposure
231    /// would still compile, and this is the assertion that would not.
232    #[test]
233    fn the_declaration_yields_the_projections_it_claims() {
234        let m = module();
235        let expected = [
236            (
237                "intent_realization.work",
238                "majordomus_intent_realization",
239                "/api/v1/intents/realization",
240                ["intent", "realization"],
241            ),
242            (
243                "intent_realization.explain",
244                "majordomus_intent_explain",
245                "/api/v1/intents/explain",
246                ["intent", "explain"],
247            ),
248        ];
249        assert_eq!(m.capabilities.len(), expected.len());
250        for (e, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
251            let x = &e.capability.exposure;
252            assert_eq!(e.capability.id.as_str(), id);
253            assert_eq!(x.mcp.as_ref().and_then(|m| m.tool.as_deref()), Some(tool));
254            assert_eq!(x.http.as_ref().map(|h| h.path.as_str()), Some(path));
255            assert_eq!(
256                x.cli.as_ref().map(|c| c.path.clone()),
257                Some(cli.iter().map(|w| w.to_string()).collect::<Vec<_>>())
258            );
259        }
260    }
261
262    /// Nothing here writes: realization is a join over records other processes wrote.
263    #[test]
264    fn no_realization_capability_writes_the_repository() {
265        for e in module().capabilities {
266            assert_ne!(
267                e.capability.execution.effect,
268                crate::capability::Effect::RepositoryMutation,
269                "{} claims to write",
270                e.capability.id
271            );
272        }
273    }
274}