Skip to main content

majordomus_cli/capability/builtin/
intents.rs

1//! The `intents` module:
2//!
3//! The module is `intents` and not `intent` because the registry composes builtin modules and
4//! declarative kinds into one namespace, and `intent` is the **kind** of the records under
5//! `.ai/repo/project/intents/`; the registry refuses the collision, as it did for `session`.
6//! The command line keeps the singular word, `majordomus intent <verb>`.
7//!
8//! What it answers: what must become true, derived against the plan and the evidence.
9//!
10//! Every capability here reads [`crate::intent::Intents`], built on demand from the index,
11//! the plan [`crate::plan::Plan::build`] derives from the same index, and the evidence
12//! ledger. Six are read-only: there is no intent status to write, because the stage is
13//! derived, and no transition, because the plan already owns the lifecycle of the work an
14//! intent is realised by (ADR 0070), and the coverage of its criteria by the plan (ADR 0073).
15//! One writes, and only a new record: `intents.propose` declares what must become true and
16//! leaves the intent unplanned, its stage still derived (ADR 0144).
17//!
18//! ```
19//! use majordomus_cli::capability::builtin::intents;
20//! let m = intents::module();
21//! let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
22//! assert_eq!(ids, ["intents.list", "intents.record", "intents.validate",
23//!                  "intents.coverage", "intents.preflight", "intents.binding",
24//!                  "intents.propose"]);
25//! ```
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29
30use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
31use crate::capability::handler::{CapabilityError, Context};
32use crate::capability::model::{
33    BenchmarkPolicy, CachePolicy, CapabilityKind, Exposure, McpExposure, McpResource, Stability,
34    WaiverReason,
35};
36use crate::capability::module::ModuleDescriptor;
37use crate::intent::{
38    IntentFinding, IntentPreflight, IntentView, Intents, RepositoryEvidence, INTENT,
39};
40use crate::intent_binding::{bind, BindingRequest, IntentBinding};
41use crate::intent_plan::IntentCoverage;
42use crate::intent_review::{CritiqueRecord, GapRecord};
43use crate::plan::Plan;
44use crate::{capability, module};
45
46use super::{get, mcp, post, Empty};
47
48/// The URI under which every intent, derived, is read as an MCP resource.
49pub const INTENTS_URI: &str = "majordomus://intents";
50
51// ---------------------------------------------------------------- views
52
53/// Every intent, with its derived stage and verdict, and how many there are in each: what
54/// `majordomus intent list`, `GET /api/v1/intents` and the `majordomus_intents` tool all answer
55/// with, out of one derivation.
56///
57/// ```
58/// use majordomus_cli::capability::builtin::intents::IntentList;
59/// let empty = IntentList {
60///     count: 0,
61///     stages: std::collections::BTreeMap::new(),
62///     verdicts: std::collections::BTreeMap::new(),
63///     intents: vec![],
64/// };
65/// // a repository that declares no intent answers the route, with nothing in it
66/// assert_eq!(empty.count, 0);
67/// ```
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
69pub struct IntentList {
70    /// How many intents.
71    pub count: usize,
72    /// How many in each stage, keyed by the stage word.
73    pub stages: std::collections::BTreeMap<String, usize>,
74    /// How many with each verdict, keyed by the verdict word.
75    pub verdicts: std::collections::BTreeMap<String, usize>,
76    /// Every intent, in identity order.
77    pub intents: Vec<IntentView>,
78}
79
80/// What the intent model refuses: whether it is valid, how many intents were examined, and
81/// every finding with its level. Warnings are reported and do not make it invalid.
82///
83/// ```
84/// use majordomus_cli::capability::builtin::intents::IntentValidation;
85/// let v = IntentValidation {
86///     valid: true,
87///     intents: 2,
88///     failures: 0,
89///     warnings: 20,
90///     findings: vec![],
91/// };
92/// // twenty warnings and no failure is a valid model
93/// assert!(v.valid);
94/// ```
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96pub struct IntentValidation {
97    /// True when no finding is a failure. Warnings do not make it false.
98    pub valid: bool,
99    /// How many intents were examined.
100    pub intents: usize,
101    /// How many findings are failures.
102    pub failures: usize,
103    /// How many findings are warnings.
104    pub warnings: usize,
105    /// Every finding.
106    pub findings: Vec<IntentFinding>,
107}
108
109// ---------------------------------------------------------------- inputs
110
111/// Which intent to read, by the id that is also its file name. An id this repository does not
112/// hold is a refusal naming what was looked for, never an empty answer.
113///
114/// ```
115/// use majordomus_cli::capability::builtin::intents::IntentRecordInput;
116/// let input = IntentRecordInput { id: "intent-lifecycle".into() };
117/// assert_eq!(input.id, "intent-lifecycle");
118/// ```
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120#[serde(deny_unknown_fields)]
121pub struct IntentRecordInput {
122    /// The intent's id, which is also its file name under `.ai/repo/project/intents/`.
123    pub id: String,
124}
125
126impl BenchmarkCases for IntentRecordInput {
127    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
128        let id = ctx
129            .index
130            .objects
131            .iter()
132            .find(|o| o.kind == INTENT)
133            .map(|o| o.identity.clone());
134        // a repository without an intent still answers the route, with the refusal, so the
135        // required parameter keeps an example in the OpenAPI document
136        vec![NamedCase::new(
137            if id.is_some() {
138                "first-intent"
139            } else {
140                "absent"
141            },
142            IntentRecordInput {
143                id: id.unwrap_or_else(|| "absent".into()),
144            },
145        )]
146    }
147}
148
149/// The work to preflight: an issue, or the paths it will touch.
150///
151/// `paths` is one comma-separated string rather than a list because a preflight changes
152/// nothing and is therefore a `GET`, and this repository binds a `GET` input to the query
153/// string.
154///
155/// ```
156/// use majordomus_cli::capability::builtin::intents::IntentPreflightInput;
157/// // an issue, when the work executes one
158/// let by_issue = IntentPreflightInput { issue: Some("I1900".into()), paths: String::new() };
159/// assert!(by_issue.path_list().is_empty());
160/// // or the paths it will touch, when it names no issue yet
161/// let by_paths = IntentPreflightInput {
162///     issue: None,
163///     paths: "apps/majordomus-cli/src,docs".into(),
164/// };
165/// assert_eq!(by_paths.path_list(), ["apps/majordomus-cli/src", "docs"]);
166/// ```
167#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
168#[serde(deny_unknown_fields)]
169pub struct IntentPreflightInput {
170    /// The issue the work executes, by id. When given, the paths are not consulted.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub issue: Option<String>,
173    /// Repository-relative paths the work will touch, separated by commas.
174    #[serde(default)]
175    pub paths: String,
176}
177
178impl IntentPreflightInput {
179    /// The paths as a list: trimmed, with the empties a trailing or doubled comma leaves
180    /// dropped, so a query string written by hand resolves the same way as one written by a
181    /// program.
182    ///
183    /// ```
184    /// use majordomus_cli::capability::builtin::intents::IntentPreflightInput;
185    /// let i = IntentPreflightInput { issue: None, paths: " lib, ,docs/X.md".into() };
186    /// assert_eq!(i.path_list(), ["lib", "docs/X.md"]);
187    /// ```
188    pub fn path_list(&self) -> Vec<String> {
189        self.paths
190            .split(',')
191            .map(str::trim)
192            .filter(|s| !s.is_empty())
193            .map(str::to_string)
194            .collect()
195    }
196}
197
198impl BenchmarkCases for IntentPreflightInput {
199    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
200        let plan = Plan::build(ctx.index);
201        match plan.issues.first() {
202            Some(i) => vec![NamedCase::new(
203                "first-issue",
204                IntentPreflightInput {
205                    issue: Some(i.id.clone()),
206                    paths: String::new(),
207                },
208            )],
209            None => vec![NamedCase::new(
210                "paths",
211                IntentPreflightInput {
212                    issue: None,
213                    paths: "README.md".into(),
214                },
215            )],
216        }
217    }
218}
219
220/// The input of `intents.binding`: what a worker names when it asks what its work serves.
221/// Flat strings, because the same value arrives as a query string, as MCP arguments and as
222/// the flags of `majordomus intent binding`.
223///
224/// ```
225/// use majordomus_cli::capability::builtin::intents::IntentBindingInput;
226/// let i = IntentBindingInput { issue: Some("I1900".into()), paths: "lib, docs".into(), ..Default::default() };
227/// let r = i.request();
228/// assert_eq!(r.issue.as_deref(), Some("I1900"));
229/// assert_eq!(r.paths, ["lib", "docs"]);
230/// ```
231#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
232#[serde(deny_unknown_fields)]
233pub struct IntentBindingInput {
234    /// The issue the work executes, by id.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub issue: Option<String>,
237    /// The intent the work serves, by id, when the worker names it directly.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub intent: Option<String>,
240    /// Repository-relative paths the work will touch, separated by commas.
241    #[serde(default)]
242    pub paths: String,
243    /// An exemption class the policy declares under `intent.exemptions`.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub exemption: Option<String>,
246    /// Why the exemption applies.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub because: Option<String>,
249}
250
251impl IntentBindingInput {
252    /// The typed request: the paths split at commas, everything else as given.
253    ///
254    /// ```
255    /// use majordomus_cli::capability::builtin::intents::IntentBindingInput;
256    /// assert!(IntentBindingInput::default().request().trimmed().names_nothing());
257    /// ```
258    pub fn request(&self) -> BindingRequest {
259        BindingRequest {
260            issue: self.issue.clone(),
261            intent: self.intent.clone(),
262            paths: self
263                .paths
264                .split(',')
265                .map(str::trim)
266                .filter(|p| !p.is_empty())
267                .map(str::to_string)
268                .collect(),
269            exemption: self.exemption.clone(),
270            because: self.because.clone(),
271        }
272    }
273}
274
275impl BenchmarkCases for IntentBindingInput {
276    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
277        let plan = Plan::build(ctx.index);
278        match plan.issues.first() {
279            Some(i) => vec![NamedCase::new(
280                "first-issue",
281                IntentBindingInput {
282                    issue: Some(i.id.clone()),
283                    ..Default::default()
284                },
285            )],
286            None => vec![NamedCase::new(
287                "paths",
288                IntentBindingInput {
289                    paths: "README.md".into(),
290                    ..Default::default()
291                },
292            )],
293        }
294    }
295}
296
297// ---------------------------------------------------------------- handlers
298
299fn derived(ctx: &Context) -> Result<(Plan, Intents), CapabilityError> {
300    let plan = Plan::build(&ctx.index);
301    let evidence = RepositoryEvidence::load(&ctx.index)
302        .map_err(|e| CapabilityError::Internal(e.to_string()))?;
303    let intents = Intents::build(&ctx.index, &plan, &evidence);
304    Ok((plan, intents))
305}
306
307fn intent_list(ctx: &Context, _: Empty) -> Result<IntentList, CapabilityError> {
308    let (_, intents) = derived(ctx)?;
309    let mut stages = std::collections::BTreeMap::new();
310    let mut verdicts = std::collections::BTreeMap::new();
311    for i in &intents.intents {
312        *stages.entry(i.stage.as_str().to_string()).or_insert(0) += 1;
313        *verdicts
314            .entry(i.verdict.state.as_str().to_string())
315            .or_insert(0) += 1;
316    }
317    Ok(IntentList {
318        count: intents.intents.len(),
319        stages,
320        verdicts,
321        intents: intents.intents,
322    })
323}
324
325fn intent_record(ctx: &Context, input: IntentRecordInput) -> Result<IntentView, CapabilityError> {
326    let (_, intents) = derived(ctx)?;
327    intents.intent(&input.id).cloned().ok_or_else(|| {
328        CapabilityError::NotFound(format!(
329            "no intent '{}'; `intents.list` names every one this repository holds",
330            input.id
331        ))
332    })
333}
334
335fn intent_validate(ctx: &Context, _: Empty) -> Result<IntentValidation, CapabilityError> {
336    let (_, intents) = derived(ctx)?;
337    Ok(IntentValidation {
338        valid: intents.failures() == 0,
339        intents: intents.intents.len(),
340        failures: intents.failures(),
341        warnings: intents.warnings(),
342        findings: intents.findings,
343    })
344}
345
346fn intent_coverage(ctx: &Context, _: Empty) -> Result<IntentCoverage, CapabilityError> {
347    let plan = Plan::build(&ctx.index);
348    Ok(Intents::coverage(&ctx.index, &plan))
349}
350
351fn intent_preflight(
352    ctx: &Context,
353    input: IntentPreflightInput,
354) -> Result<IntentPreflight, CapabilityError> {
355    let paths = input.path_list();
356    let issue = input
357        .issue
358        .as_deref()
359        .map(str::trim)
360        .filter(|s| !s.is_empty());
361    if issue.is_none() && paths.is_empty() {
362        return Err(CapabilityError::InvalidInput(
363            "name the issue the work executes, or the paths it will touch".into(),
364        ));
365    }
366    let (plan, intents) = derived(ctx)?;
367    Ok(intents.preflight(
368        &plan,
369        &GapRecord::all(&ctx.index),
370        &CritiqueRecord::all(&ctx.index),
371        issue,
372        &paths,
373    ))
374}
375
376/// The policy's `intent:` block, or its default when the repository or the policy cannot be
377/// read: no exemption class is then declared, so none can be given, which is the refusal an
378/// unreadable policy owes rather than a class invented for it.
379fn intent_policy(ctx: &Context) -> crate::policy::IntentPolicy {
380    let root = std::path::PathBuf::from(&ctx.index.repository.root);
381    crate::repository::Repository::open(&root)
382        .ok()
383        .and_then(|repo| crate::policy::LoadedPolicy::load(&repo).ok())
384        .map(|loaded| loaded.policy.intent)
385        .unwrap_or_default()
386}
387
388fn intent_binding(
389    ctx: &Context,
390    input: IntentBindingInput,
391) -> Result<IntentBinding, CapabilityError> {
392    derived(ctx).map(|(plan, intents)| {
393        bind(
394            &intents,
395            &plan,
396            &GapRecord::all(&ctx.index),
397            &CritiqueRecord::all(&ctx.index),
398            &intent_policy(ctx),
399            input.request(),
400        )
401    })
402}
403
404// ---------------------------------------------------------------- intents.propose
405
406/// The input of `intents.propose`: what must become true, and optionally its title and id.
407///
408/// No milestone, criterion or invariant: a proposal says what is wanted, and planning it is a
409/// separate act (ADR 0144). No timestamp either: the record is dated when it is written.
410///
411/// ```
412/// use majordomus_cli::capability::builtin::intents::IntentProposeInput;
413/// let i: IntentProposeInput =
414///     serde_json::from_str(r#"{"statement": "The UI answers within 100 ms warm"}"#).unwrap();
415/// assert_eq!(i.title, None);
416/// // a field the input does not have is refused, never ignored
417/// assert!(serde_json::from_str::<IntentProposeInput>(r#"{"statement": "s", "stage": "x"}"#).is_err());
418/// ```
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
420#[serde(deny_unknown_fields)]
421pub struct IntentProposeInput {
422    /// What must become true, in the proposer's words; kept verbatim.
423    pub statement: String,
424    /// One line naming it; the statement's first line when absent.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub title: Option<String>,
427    /// The identity and file name; the title's slug when absent.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub id: Option<String>,
430}
431
432impl BenchmarkCases for IntentProposeInput {
433    // Waived as destructive, so never timed: the case is the example every projection shows.
434    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
435        vec![NamedCase::new(
436            "a sentence",
437            IntentProposeInput {
438                statement: "Every page of the UI answers within 100 ms when warm".into(),
439                title: None,
440                id: None,
441            },
442        )]
443    }
444}
445
446fn intent_propose(
447    ctx: &Context,
448    input: IntentProposeInput,
449) -> Result<crate::intent::Proposed, CapabilityError> {
450    let root = std::path::Path::new(&ctx.index.repository.root);
451    let today = crate::ledger::now();
452    crate::intent::propose(
453        root,
454        &input.statement,
455        input.title.as_deref(),
456        input.id.as_deref(),
457        &today[..10],
458    )
459    .map_err(|e| match e {
460        crate::intent::ProposeError::Invalid(m) => CapabilityError::InvalidInput(m),
461        crate::intent::ProposeError::Exists(m) => CapabilityError::Refused(m),
462        crate::intent::ProposeError::Write(m) => CapabilityError::Internal(m),
463    })
464}
465
466/// The module the registry composes: six read-only capabilities over one derivation and one
467/// that proposes a new intent, each declared once here and projected to the command line,
468/// HTTP, MCP and OpenAPI from that declaration.
469///
470/// ```
471/// use majordomus_cli::capability::builtin::intents;
472/// let m = intents::module();
473/// assert_eq!(m.id.as_str(), "intents");
474/// // every capability of this module is declared under its own name
475/// assert!(m
476///     .capabilities
477///     .iter()
478///     .all(|e| e.capability.id.as_str().starts_with("intents.")));
479/// ```
480pub fn module() -> ModuleDescriptor {
481    module! {
482        id: "intents",
483        title: "Intent",
484        description: "What must become true above the milestones that realise it: each intent's statement, invariants and satisfaction criteria, its stage derived from the plan's milestone status, each criterion's state derived from the evidence ledger, and its verdict derived from those criteria alone. Nothing is stored and nothing transitions; an intent added under the project model is answered by all of these without a registration anywhere.",
485        stability: Stability::BehaviorallyVerified,
486        capabilities: [
487            capability! {
488                id: "intents.list",
489                title: "Every intent, with its derived stage and verdict",
490                description: "Every intent the project model declares, each with the status the plan derives for its milestones, the state of the evidence behind each satisfaction criterion, the stage those two derive — declared, planned, executing, verifying or satisfied, or cancelled or superseded when the record says so — and the verdict the criteria alone derive (ADR 0107): satisfied when every criterion is met, unsatisfied when a test or claim criterion is not, unknown when only command or deployment criteria are unmet or none is declared, with the criteria holding it back.",
491                input: Empty,
492                output: IntentList,
493                stability: Stability::BehaviorallyVerified,
494                exposure: Exposure {
495                    mcp: Some(McpExposure {
496                        tool: Some("majordomus_intents".into()),
497                        resource: Some(McpResource { uri: INTENTS_URI.into(), name: "intents".into() }),
498                    }),
499                    http: get("/api/v1/intents"),
500                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "list".into()] }),
501                },
502                tags: ["intent", "project"],
503                cache: CachePolicy::Checkout { max_entries: 2 },
504                handler: intent_list,
505            },
506            capability! {
507                id: "intents.record",
508                title: "One intent, with everything derived about it",
509                description: "One intent in full: its statement and invariants as authored, each milestone with the status the plan derives, each satisfaction criterion with the state of its evidence and the command that reproduces it, the stage, and the verdict the criteria alone derive with the criteria holding it back. The record's own file stays at `majordomus://intent/<id>`.",
510                input: IntentRecordInput,
511                output: IntentView,
512                stability: Stability::BehaviorallyVerified,
513                exposure: Exposure {
514                    mcp: mcp("majordomus_intent_record"),
515                    http: get("/api/v1/intents/record"),
516                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "show".into()] }),
517                },
518                tags: ["intent", "project"],
519                cache: CachePolicy::Disabled,
520                handler: intent_record,
521            },
522            capability! {
523                id: "intents.validate",
524                title: "What the intent model refuses",
525                description: "Every finding over the intents: an intent naming no milestone or no criterion, a milestone that does not resolve, a criterion with no evidence reference or one that names nothing this repository holds, governance that does not resolve, a successor that is not an intent, a file name that disagrees with its id — each a failure — and a milestone no intent serves, a warning.",
526                input: Empty,
527                output: IntentValidation,
528                stability: Stability::BehaviorallyVerified,
529                exposure: Exposure {
530                    mcp: mcp("majordomus_intent_validate"),
531                    http: get("/api/v1/intents/validate"),
532                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "validate".into()] }),
533                },
534                tags: ["intent", "project", "validation"],
535                cache: CachePolicy::Disabled,
536                handler: intent_validate,
537            },
538            capability! {
539                id: "intents.coverage",
540                title: "Which work carries which criterion, and why each issue exists",
541                description: "The plan read against the intents (ADR 0073): every criterion of every live intent with the live issues that serve it, the milestones they belong to, and its strength — covered, weakly covered when no serving issue requires evidence, observed when the recorded gap saw it already true, or uncovered when nothing in the plan will make it true — and every issue with the reason it exists: the criteria it serves, maintenance under a milestone no intent names, or unexplained under one that realises an intent.",
542                input: Empty,
543                output: IntentCoverage,
544                stability: Stability::BehaviorallyVerified,
545                exposure: Exposure {
546                    mcp: mcp("majordomus_intent_coverage"),
547                    http: get("/api/v1/intents/coverage"),
548                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "coverage".into()] }),
549                },
550                tags: ["intent", "project", "planning"],
551                cache: CachePolicy::Disabled,
552                handler: intent_coverage,
553            },
554            capability! {
555                id: "intents.preflight",
556                title: "Which intent a piece of work serves, or why it may not proceed",
557                description: "Given the issue a piece of work executes, or the paths it will touch, one verdict: `serves` when no issue is refused and at least one serves a criterion of a live intent through a link that holds, the plan of each such intent critiqued with no blocking finding open (issues judged maintenance beside it do not change the verdict); `maintenance` when the issues sit under milestones no live intent names and serve nothing, as `intent validate` allows; `refused` otherwise, each refusal with its issue, a cause — unknown_issue, no_issue_covers_paths, issue_serves_nothing, serves_another_intent, serves_unknown_criterion, intent_not_critiqued, open_blocking_finding — in path mode every issue judged and the worst verdict answered. The answer carries, for the intents reached and no others, the statement, the served criteria with the live state of their evidence, the invariants, non-goals and governance, the critique with its open blocking findings, and the recorded gap bounded to those criteria.",
558                input: IntentPreflightInput,
559                output: IntentPreflight,
560                stability: Stability::BehaviorallyVerified,
561                exposure: Exposure {
562                    mcp: mcp("majordomus_intent_preflight"),
563                    http: get("/api/v1/intents/preflight"),
564                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "preflight".into()] }),
565                },
566                tags: ["intent", "project", "governance"],
567                cache: CachePolicy::Disabled,
568                handler: intent_preflight,
569            },
570            capability! {
571                id: "intents.binding",
572                title: "What a piece of work is bound to before it starts",
573                description: "What a task asks before it starts (ADR 0111): given the issue the work executes, the intent it serves, the paths it will touch, or an exemption class with its reason, one standing — `bound` when the work serves a criterion of a live intent through links the preflight accepts, `maintenance` when its issues sit under milestones no live intent names, `exempt` when the class is one the policy declares under `intent.exemptions` and a reason was given, `refused` otherwise, each refusal with a cause: the preflight's own seven, and nothing_named, unknown_intent, intent_retired, intent_has_no_open_work, issue_outside_intent, ambiguous_intent (paths alone reached more than one intent), unknown_exemption, exemption_without_reason, exemption_names_work. The answer carries what was named, the issues and intents resolved with what each intent asks of the worker, a note when the paths lie outside the named issue's scope, and two pins a later reader compares: `plan_revision`, which moves when the intent, a link or the critique is edited, and `evidence_standing`, which moves when a served criterion's evidence changes state. Nothing is stored.",
574                input: IntentBindingInput,
575                output: IntentBinding,
576                stability: Stability::BehaviorallyVerified,
577                exposure: Exposure {
578                    mcp: mcp("majordomus_intent_binding"),
579                    http: get("/api/v1/intents/binding"),
580                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "binding".into()] }),
581                },
582                tags: ["intent", "project", "governance"],
583                cache: CachePolicy::Disabled,
584                handler: intent_binding,
585            },
586            capability! {
587                id: "intents.propose",
588                kind: CapabilityKind::Command,
589                title: "Propose an intent: what must become true",
590                description: "Write a new intent record under `.ai/repo/project/intents/` from a statement of what must become true, with an optional title (the statement's first line when absent) and id (the title's slug when absent). The record names no milestone, criterion or invariant: its stage derives as `declared`, `intent validate` reports the two absences as warnings rather than failures, and no work can serve it until it is planned (ADR 0144). Nothing about its standing is stored. An existing record is refused, never overwritten, and the file appears whole or not at all. The answer names the identity and the file; `intents.record` answers the derived stage once the index holds it.",
591                input: IntentProposeInput,
592                output: crate::intent::Proposed,
593                stability: Stability::Experimental,
594                exposure: Exposure {
595                    mcp: mcp("majordomus_intent_propose"),
596                    http: post("/api/v1/intents/propose"),
597                    cli: Some(crate::capability::CliExposure { path: vec!["intent".into(), "propose".into()] }),
598                },
599                tags: ["intent", "project"],
600                cache: CachePolicy::Disabled,
601                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
602                handler: intent_propose,
603            }
604            // The one thing its kind cannot say: this writes a tracked record, so every
605            // surface's exposure ceiling is the one a repository write has.
606            .writes_repository()
607            // `/intent <sentence>`: the sentence is the statement, verbatim.
608            .skill("intent", "statement"),
609        ],
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    /// The declaration is the only place these names exist, and every projection is derived
618    /// from it. A refactor that dropped an exposure or renamed a route would still compile;
619    /// this is the assertion that would not.
620    #[test]
621    fn the_declaration_yields_the_projections_it_claims() {
622        let m = module();
623        assert_eq!(m.id.as_str(), "intents");
624        let expected: &[(&str, &str, &str, &[&str])] = &[
625            (
626                "intents.list",
627                "majordomus_intents",
628                "/api/v1/intents",
629                &["intent", "list"],
630            ),
631            (
632                "intents.record",
633                "majordomus_intent_record",
634                "/api/v1/intents/record",
635                &["intent", "show"],
636            ),
637            (
638                "intents.validate",
639                "majordomus_intent_validate",
640                "/api/v1/intents/validate",
641                &["intent", "validate"],
642            ),
643            (
644                "intents.coverage",
645                "majordomus_intent_coverage",
646                "/api/v1/intents/coverage",
647                &["intent", "coverage"],
648            ),
649            (
650                "intents.preflight",
651                "majordomus_intent_preflight",
652                "/api/v1/intents/preflight",
653                &["intent", "preflight"],
654            ),
655            (
656                "intents.binding",
657                "majordomus_intent_binding",
658                "/api/v1/intents/binding",
659                &["intent", "binding"],
660            ),
661            (
662                "intents.propose",
663                "majordomus_intent_propose",
664                "/api/v1/intents/propose",
665                &["intent", "propose"],
666            ),
667        ];
668        let ids: Vec<&str> = m
669            .capabilities
670            .iter()
671            .map(|e| e.capability.id.as_str())
672            .collect();
673        let want: Vec<&str> = expected.iter().map(|(id, ..)| *id).collect();
674        assert_eq!(
675            ids, want,
676            "the module declares a different set of capabilities"
677        );
678        for (executable, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
679            let exposure = &executable.capability.exposure;
680            assert_eq!(
681                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
682                Some(*tool),
683                "{id} lost or renamed its MCP tool"
684            );
685            assert_eq!(
686                exposure.http.as_ref().map(|h| h.path.as_str()),
687                Some(*path),
688                "{id} lost or renamed its HTTP route"
689            );
690            assert_eq!(
691                exposure.cli.as_ref().map(|c| c.path.clone()),
692                Some(cli.iter().map(|w| w.to_string()).collect::<Vec<_>>()),
693                "{id} lost or renamed its command"
694            );
695        }
696    }
697
698    /// One capability writes, and it only proposes: the stage is derived, so there is no
699    /// status to record and no transition (ADR 0070, ADR 0144).
700    #[test]
701    fn only_the_proposal_writes_the_repository() {
702        let writers: Vec<String> = module()
703            .capabilities
704            .iter()
705            .filter(|e| {
706                e.capability.execution.effect == crate::capability::Effect::RepositoryMutation
707            })
708            .map(|e| e.capability.id.as_str().to_string())
709            .collect();
710        assert_eq!(writers, ["intents.propose"]);
711    }
712}