1use std::path::Path;
36
37use schemars::JsonSchema;
38use serde::{Deserialize, Serialize};
39
40use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
41use crate::capability::handler::{CapabilityError, Context};
42use crate::capability::model::{CachePolicy, CapabilityKind, CliExposure, Exposure, Stability};
43use crate::capability::module::ModuleDescriptor;
44use crate::reasoning::availability::LinkedPeer;
45use crate::reasoning::check::{applicable, ReasoningFinding};
46use crate::reasoning::record::{ReasoningInput, ReasoningRecord, RECORD_SCHEMA, RECORD_VERSION};
47use crate::reasoning::state::{
48 derive, explain, render_markdown, ReasoningExplanation, ReasoningState, ReasoningStep,
49};
50use crate::reasoning::store::{admit, mint_id, redact, Admission};
51use crate::reasoning::{
52 plan, AdvisorState, AdvisorStatus, Materiality, ModeResolution, PlanRequest,
53 ReasoningConfidence, ReviewPlan,
54};
55use crate::{capability, module};
56
57use super::{get, mcp, post};
58
59pub const ADVISORS_URI: &str = "majordomus://reasoning/advisors";
61pub const REASONING_URI: &str = "majordomus://reasoning";
63
64pub(crate) use crate::reasoning::Situation as Setting;
65
66pub(crate) fn setting(ctx: &Context) -> Result<Setting, CapabilityError> {
67 let peers: Vec<LinkedPeer> = match ctx.mesh.cooperation() {
68 Some(coop) => coop
69 .peers()
70 .into_iter()
71 .filter(|p| {
72 matches!(
73 p.state,
74 crate::mesh::cooperation::LinkState::Connected
75 | crate::mesh::cooperation::LinkState::Degraded
76 )
77 })
78 .map(|p| LinkedPeer {
79 runtime: p.runtime,
80 name: p.name,
81 features: p.features,
82 })
83 .collect(),
84 None => Vec::new(),
85 };
86 crate::reasoning::Situation::resolve(
87 Path::new(&ctx.index.repository.root),
88 ctx.index.share.as_deref(),
89 &ctx.index.providers,
90 &peers,
91 )
92 .map_err(CapabilityError::Internal)
93}
94
95#[derive(Debug, Serialize, Deserialize, JsonSchema)]
110pub struct AdvisorCapacity {
111 pub capability: String,
113 pub description: String,
115 pub available: Vec<String>,
117}
118
119#[derive(Debug, Serialize, Deserialize, JsonSchema)]
136pub struct AdvisorsReport {
137 pub operational: bool,
140 pub mode: ModeResolution,
142 pub available: usize,
144 pub unavailable: usize,
146 pub advisors: Vec<AdvisorState>,
148 pub capacity: Vec<AdvisorCapacity>,
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub diagnostics: Vec<String>,
154}
155
156fn advisors_of(s: &Setting) -> AdvisorsReport {
157 let available = s
158 .states
159 .iter()
160 .filter(|a| a.status == AdvisorStatus::Available)
161 .count();
162 AdvisorsReport {
163 operational: true,
164 mode: s.mode.clone(),
165 available,
166 unavailable: s.states.len() - available,
167 capacity: s
168 .catalogue
169 .capabilities
170 .iter()
171 .map(|c| AdvisorCapacity {
172 capability: c.id.clone(),
173 description: c.description.clone(),
174 available: s
175 .states
176 .iter()
177 .filter(|a| {
178 a.status == AdvisorStatus::Available && a.capabilities.contains(&c.id)
179 })
180 .map(|a| a.id.clone())
181 .collect(),
182 })
183 .collect(),
184 advisors: s.states.clone(),
185 diagnostics: s.diagnostics.clone(),
186 }
187}
188
189fn reasoning_advisors(ctx: &Context, _: super::Empty) -> Result<AdvisorsReport, CapabilityError> {
190 Ok(advisors_of(&setting(ctx)?))
191}
192
193#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
196#[serde(deny_unknown_fields, default)]
197pub struct PlanPreviewInput {
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub materiality: Option<Materiality>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub confidence: Option<ReasoningConfidence>,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub capabilities: Option<String>,
221}
222
223impl BenchmarkCases for PlanPreviewInput {
224 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
225 vec![NamedCase::new(
226 "material",
227 PlanPreviewInput {
228 materiality: Some(Materiality::Material),
229 confidence: Some(ReasoningConfidence::Medium),
230 capabilities: Some("independent_reasoning,code_review".into()),
231 },
232 )]
233 }
234}
235
236fn reasoning_plan(ctx: &Context, input: PlanPreviewInput) -> Result<ReviewPlan, CapabilityError> {
237 let s = setting(ctx)?;
238 let request = PlanRequest {
239 materiality: input.materiality.unwrap_or_default(),
240 confidence: input.confidence.unwrap_or_default(),
241 capabilities: input
242 .capabilities
243 .as_deref()
244 .unwrap_or_default()
245 .split(',')
246 .map(str::trim)
247 .filter(|w| !w.is_empty())
248 .map(str::to_string)
249 .collect(),
250 prior_conclusion: None,
251 new_evidence: false,
252 };
253 for w in &request.capabilities {
254 if !s.catalogue.knows_capability(w) {
255 return Err(CapabilityError::InvalidInput(format!(
256 "'{w}' is not an advisory capability; `majordomus reasoning advisors` lists them"
257 )));
258 }
259 }
260 Ok(plan(&request, &s.mode, &s.states))
261}
262
263#[derive(Debug, Serialize, Deserialize, JsonSchema)]
266#[serde(deny_unknown_fields)]
267pub struct ReasoningRecordInput {
280 pub record: ReasoningInput,
282}
283
284impl BenchmarkCases for ReasoningRecordInput {
285 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
286 vec![NamedCase::new(
288 "refused",
289 ReasoningRecordInput {
290 record: ReasoningInput::Plan(crate::reasoning::record::PlanInput {
291 assessment: "assessment-benchmark-absent".into(),
292 }),
293 },
294 )]
295 }
296}
297
298#[derive(Debug, Serialize, Deserialize, JsonSchema)]
310pub struct ReasoningRecorded {
311 pub record: ReasoningRecord,
313 pub path: String,
315 pub step: ReasoningStep,
317}
318
319fn reasoning_record(
320 ctx: &Context,
321 input: ReasoningRecordInput,
322) -> Result<ReasoningRecorded, CapabilityError> {
323 let s = setting(ctx)?;
324 let body = admit(
325 input.record,
326 &s.loaded,
327 &Admission {
328 mode: &s.mode,
329 availability: &s.states,
330 },
331 )
332 .map_err(|r| CapabilityError::Refused(r.0))?;
333 let (body, redacted) = redact(body);
334 let recorded_at = crate::reasoning::now_stamp();
335 let head = match &ctx.index.repository.git {
336 crate::git::GitState::Available(g) => g.head.clone(),
337 _ => None,
338 };
339 let record = ReasoningRecord {
340 schema: RECORD_SCHEMA.into(),
341 version: RECORD_VERSION,
342 id: mint_id(body.kind(), &recorded_at),
343 task: s.store.current_task(),
344 recorded_at,
345 head,
346 redacted,
347 body,
348 };
349 let path = s
350 .store
351 .write(&record)
352 .map_err(|e| CapabilityError::Refused(format!("the record could not be written: {e}")))?;
353 let mut after = s.loaded;
354 after.records.push(record.clone());
355 let step = derive(&after, None)
356 .timeline
357 .into_iter()
358 .find(|e| e.id == record.id)
359 .ok_or_else(|| CapabilityError::Internal("the record has no timeline step".into()))?;
360 Ok(ReasoningRecorded {
361 path: path
362 .strip_prefix(&ctx.index.repository.root)
363 .unwrap_or(&path)
364 .display()
365 .to_string(),
366 record,
367 step,
368 })
369}
370
371#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
374#[serde(deny_unknown_fields, default)]
375pub struct ReasoningStatusInput {
387 #[serde(skip_serializing_if = "Option::is_none")]
389 pub task: Option<String>,
390}
391
392impl BenchmarkCases for ReasoningStatusInput {
393 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
394 vec![
395 NamedCase::new("current", ReasoningStatusInput::default()),
396 NamedCase::new(
397 "all",
398 ReasoningStatusInput {
399 task: Some("all".into()),
400 },
401 ),
402 ]
403 }
404}
405
406#[derive(Debug, Serialize, Deserialize, JsonSchema)]
418pub struct ReasoningStatus {
419 pub mode: ModeResolution,
421 pub advisors_available: usize,
423 pub advisors_unavailable: usize,
425 pub state: ReasoningState,
427 pub report: String,
429}
430
431fn reasoning_status(
432 ctx: &Context,
433 input: ReasoningStatusInput,
434) -> Result<ReasoningStatus, CapabilityError> {
435 let s = setting(ctx)?;
436 let task = match input.task.as_deref() {
437 Some("all") => None,
438 Some(t) => Some(t.to_string()),
439 None => Some(s.store.current_task()),
440 };
441 let state = derive(&s.loaded, task.as_deref());
442 let available = s
443 .states
444 .iter()
445 .filter(|a| a.status == AdvisorStatus::Available)
446 .count();
447 Ok(ReasoningStatus {
448 mode: s.mode.clone(),
449 advisors_available: available,
450 advisors_unavailable: s.states.len() - available,
451 report: render_markdown(&state),
452 state,
453 })
454}
455
456#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
459#[serde(deny_unknown_fields)]
460pub struct ReasoningExplainInput {
471 pub id: String,
473}
474
475impl BenchmarkCases for ReasoningExplainInput {
476 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
477 vec![NamedCase::new(
478 "absent",
479 ReasoningExplainInput {
480 id: "conclusion-benchmark-absent".into(),
481 },
482 )]
483 }
484}
485
486fn reasoning_explain(
487 ctx: &Context,
488 input: ReasoningExplainInput,
489) -> Result<ReasoningExplanation, CapabilityError> {
490 let s = setting(ctx)?;
491 explain(&s.loaded, &input.id)
492 .ok_or_else(|| CapabilityError::NotFound(format!("no reasoning record {}", input.id)))
493}
494
495#[derive(Debug, Serialize, Deserialize, JsonSchema)]
510pub struct ReasoningCheck {
511 pub ok: bool,
513 pub checks: Vec<String>,
515 pub findings: Vec<ReasoningFinding>,
517}
518
519fn reasoning_check(ctx: &Context, _: super::Empty) -> Result<ReasoningCheck, CapabilityError> {
520 let s = setting(ctx)?;
521 let checks = applicable(
524 ctx.index
525 .objects
526 .iter()
527 .filter(|o| o.kind == "rule")
528 .filter_map(|o| o.metadata.get("id").and_then(|v| v.as_str())),
529 );
530 let findings = crate::reasoning::check::run_checks(
531 Path::new(&ctx.index.repository.root),
532 &s.catalogue,
533 &s.refs,
534 &s.loaded,
535 &checks,
536 );
537 Ok(ReasoningCheck {
538 ok: findings.is_empty(),
539 checks: checks.iter().map(|c| c.to_string()).collect(),
540 findings,
541 })
542}
543
544pub fn module() -> ModuleDescriptor {
562 module! {
563 id: "reasoning",
564 title: "Reasoning",
565 description: "Provider-independent reasoning (ADR 0098): which optional advisors can be asked and what they offer, what a material uncertainty calls for, the session's typed reasoning records — assessments, plans, consultations, disagreements, resolutions, conclusions, validations, attempts — and the state, timeline and provenance they derive. Nothing here talks to an advisor, and nothing here needs one: with none available, the plan is a structured local review and the session concludes on its own evidence.",
566 stability: Stability::Experimental,
567 capabilities: [
568 capability! {
569 id: "reasoning.advisors",
570 title: "The advisors and what they can do now",
571 description: "Every declared advisor — then every linked mesh peer carrying the review feature — with its status (available, unavailable, not_configured, disabled, temporarily_failed, rate_limited) and why, from presence alone (an executable on PATH, a credential variable set; never a value), the reasoning mode and the recorded outcomes of earlier consultations. Plus the capacity per advisory capability. No advisor is an ordinary answer: reasoning is operational either way.",
572 input: super::Empty,
573 output: AdvisorsReport,
574 stability: Stability::Experimental,
575 exposure: Exposure {
576 mcp: Some(crate::capability::model::McpExposure {
577 tool: Some("majordomus_reasoning_advisors".into()),
578 resource: Some(crate::capability::model::McpResource {
579 uri: ADVISORS_URI.into(),
580 name: "reasoning-advisors".into(),
581 }),
582 }),
583 http: get("/api/v1/reasoning/advisors"),
584 cli: Some(CliExposure { path: vec!["reasoning".into(), "advisors".into()] }),
585 },
586 tags: ["reasoning", "advisors"],
587 cache: CachePolicy::Disabled,
588 handler: reasoning_advisors,
589 },
590 capability! {
591 id: "reasoning.plan",
592 title: "What an uncertainty calls for",
593 description: "Decide, without recording anything, whether a stated uncertainty warrants independent review, how much the mode allows, and which available advisors a capability-driven selection would ask — with every advisor left out and why. Trivial and low uncertainty is decided locally; material uncertainty with no suitable advisor gets the structured local review instead. Pure over the catalogue, the availability and the input.",
594 input: PlanPreviewInput,
595 output: ReviewPlan,
596 stability: Stability::Experimental,
597 exposure: Exposure {
598 mcp: mcp("majordomus_reasoning_plan"),
599 http: get("/api/v1/reasoning/plan"),
600 cli: Some(CliExposure { path: vec!["reasoning".into(), "plan".into()] }),
601 },
602 tags: ["reasoning", "policy"],
603 cache: CachePolicy::Disabled,
604 handler: reasoning_plan,
605 },
606 capability! {
607 id: "reasoning.record",
608 kind: CapabilityKind::Command,
609 title: "Record a reasoning step",
610 description: "Write one typed reasoning record of the open task under .ai/local/state/reasoning/: an assessment (with its evidence), a plan (computed here from the availability of this moment, never supplied), a consultation (only of an advisor its plan selected), a disagreement, a resolution (evidence, never a count), a conclusion (its review count computed from the consultations it cites; refused while a disagreement on its assessment is unsettled), a validation or a failed attempt. Secret shapes are redacted before anything is written.",
611 input: ReasoningRecordInput,
612 output: ReasoningRecorded,
613 stability: Stability::Experimental,
614 exposure: Exposure {
615 mcp: mcp("majordomus_reasoning_record"),
616 http: post("/api/v1/reasoning/records"),
617 cli: Some(CliExposure { path: vec!["reasoning".into(), "record".into()] }),
618 },
619 tags: ["reasoning", "records"],
620 cache: CachePolicy::Disabled,
621 handler: reasoning_record,
622 }
623 .writes_repository(),
624 capability! {
625 id: "reasoning.status",
626 title: "The session's reasoning state",
627 description: "The reasoning state of the open task (or of a named one, or of all): assessments and where each stands, consultations and how each ended, disagreements and what settled them, conclusions with their computed review and validation, the timeline, token totals the adapters reported, and the Markdown report a handover carries. Derived from the records alone, deterministically.",
628 input: ReasoningStatusInput,
629 output: ReasoningStatus,
630 stability: Stability::Experimental,
631 exposure: Exposure {
632 mcp: Some(crate::capability::model::McpExposure {
633 tool: Some("majordomus_reasoning".into()),
634 resource: Some(crate::capability::model::McpResource {
635 uri: REASONING_URI.into(),
636 name: "reasoning".into(),
637 }),
638 }),
639 http: get("/api/v1/reasoning"),
640 cli: Some(CliExposure { path: vec!["reasoning".into(), "status".into()] }),
641 },
642 tags: ["reasoning", "session"],
643 cache: CachePolicy::Disabled,
644 handler: reasoning_status,
645 },
646 capability! {
647 id: "reasoning.explain",
648 title: "Why a decision was made",
649 description: "One reasoning record with the whole chain of its assessment: the uncertainty and its evidence, the plan and who it selected or why nobody, each consultation, each disagreement and the experiment that settled it, the conclusion and its validation — the provenance of a decision without the conversation that produced it.",
650 input: ReasoningExplainInput,
651 output: ReasoningExplanation,
652 stability: Stability::Experimental,
653 exposure: Exposure {
654 mcp: mcp("majordomus_reasoning_explain"),
655 http: get("/api/v1/reasoning/explain"),
656 cli: Some(CliExposure { path: vec!["reasoning".into(), "explain".into()] }),
657 },
658 tags: ["reasoning", "provenance"],
659 cache: CachePolicy::Disabled,
660 benchmark: crate::capability::model::BenchmarkPolicy::Waived {
663 reason: crate::capability::model::WaiverReason::TransientState,
664 },
665 handler: reasoning_explain,
666 },
667 capability! {
668 id: "reasoning.check",
669 title: "Check that reasoning stays provider-independent",
670 description: "The executable half of the rules advisors-are-optional and review-is-recorded-not-claimed: the advisor catalogue resolves against the provider table and the model catalogue; every adapter it names has its transport module; no provider-independent source names an advisor; no CI workflow or gate names a model credential; no document asserts a refused claim; every stored consultation names an advisor its plan selected, and every conclusion's review matches what it cites. The CI and claims checks judge what this repository's own workflows and documents say, so they apply only where the rule advisors-are-optional is declared; an adopter is held to what it adopted.",
671 input: super::Empty,
672 output: ReasoningCheck,
673 stability: Stability::Experimental,
674 exposure: Exposure {
675 mcp: mcp("majordomus_reasoning_check"),
676 http: get("/api/v1/reasoning/check"),
677 cli: Some(CliExposure { path: vec!["reasoning".into(), "check".into()] }),
678 },
679 tags: ["reasoning", "governance"],
680 cache: CachePolicy::Disabled,
681 handler: reasoning_check,
682 },
683 ],
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn the_declaration_yields_the_projections_it_claims() {
693 let m = module();
694 assert_eq!(m.id.as_str(), "reasoning");
695 let expected: &[(&str, &str, &str)] = &[
696 (
697 "reasoning.advisors",
698 "majordomus_reasoning_advisors",
699 "/api/v1/reasoning/advisors",
700 ),
701 (
702 "reasoning.plan",
703 "majordomus_reasoning_plan",
704 "/api/v1/reasoning/plan",
705 ),
706 (
707 "reasoning.record",
708 "majordomus_reasoning_record",
709 "/api/v1/reasoning/records",
710 ),
711 (
712 "reasoning.status",
713 "majordomus_reasoning",
714 "/api/v1/reasoning",
715 ),
716 (
717 "reasoning.explain",
718 "majordomus_reasoning_explain",
719 "/api/v1/reasoning/explain",
720 ),
721 (
722 "reasoning.check",
723 "majordomus_reasoning_check",
724 "/api/v1/reasoning/check",
725 ),
726 ];
727 let ids: Vec<&str> = m
728 .capabilities
729 .iter()
730 .map(|e| e.capability.id.as_str())
731 .collect();
732 assert_eq!(
733 ids,
734 expected.iter().map(|(id, _, _)| *id).collect::<Vec<_>>()
735 );
736 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
737 let exposure = &executable.capability.exposure;
738 assert_eq!(
739 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
740 Some(*tool),
741 "{id}"
742 );
743 assert_eq!(
744 exposure.http.as_ref().map(|h| h.path.as_str()),
745 Some(*path),
746 "{id}"
747 );
748 }
749 }
750
751 #[test]
752 fn only_the_record_writes() {
753 use crate::capability::model::Effect;
754 for e in module().capabilities {
755 let writes = e.capability.execution.effect == Effect::RepositoryMutation;
756 assert_eq!(
757 writes,
758 e.capability.id.as_str() == "reasoning.record",
759 "{}",
760 e.capability.id
761 );
762 }
763 }
764}