1use 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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct IntentRealizationInput {
45 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct IntentExplainInput {
91 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
175pub 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 #[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 #[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}