1use 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
48pub const INTENTS_URI: &str = "majordomus://intents";
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
69pub struct IntentList {
70 pub count: usize,
72 pub stages: std::collections::BTreeMap<String, usize>,
74 pub verdicts: std::collections::BTreeMap<String, usize>,
76 pub intents: Vec<IntentView>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96pub struct IntentValidation {
97 pub valid: bool,
99 pub intents: usize,
101 pub failures: usize,
103 pub warnings: usize,
105 pub findings: Vec<IntentFinding>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120#[serde(deny_unknown_fields)]
121pub struct IntentRecordInput {
122 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 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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
168#[serde(deny_unknown_fields)]
169pub struct IntentPreflightInput {
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub issue: Option<String>,
173 #[serde(default)]
175 pub paths: String,
176}
177
178impl IntentPreflightInput {
179 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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
232#[serde(deny_unknown_fields)]
233pub struct IntentBindingInput {
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub issue: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub intent: Option<String>,
240 #[serde(default)]
242 pub paths: String,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub exemption: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub because: Option<String>,
249}
250
251impl IntentBindingInput {
252 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
297fn 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
376fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
420#[serde(deny_unknown_fields)]
421pub struct IntentProposeInput {
422 pub statement: String,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub title: Option<String>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub id: Option<String>,
430}
431
432impl BenchmarkCases for IntentProposeInput {
433 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
466pub 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 .writes_repository()
607 .skill("intent", "statement"),
609 ],
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 #[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 #[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}