Skip to main content

majordomus_cli/capability/builtin/
reasoning.rs

1//! The `reasoning` module: provider-independent reasoning, projected (ADR 0098).
2//!
3//! Six capabilities over one model (`crate::reasoning`): which advisors can be asked and
4//! what they can do (`reasoning.advisors`), what a stated uncertainty calls for
5//! (`reasoning.plan`), the one writer of the session's reasoning records
6//! (`reasoning.record`), the state those records derive (`reasoning.status`), one
7//! record's provenance (`reasoning.explain`), and the checks that keep the design honest
8//! (`reasoning.check`). None of them talks to an advisor: the transport is the Node layer
9//! (`scripts/lib/advisors/`), which records what it did through `reasoning.record`.
10//!
11//! # Example
12//!
13//! ```
14//! use majordomus_cli::capability::builtin::reasoning::module;
15//! use majordomus_cli::capability::model::Effect;
16//!
17//! // The declaration is the only place these names exist; every surface derives from it.
18//! let m = module();
19//! assert_eq!(m.id.as_str(), "reasoning");
20//! let plan = m.capabilities.iter().find(|e| e.capability.id.as_str() == "reasoning.plan").unwrap();
21//! let exposure = &plan.capability.exposure;
22//! assert_eq!(exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()), Some("majordomus_reasoning_plan"));
23//! assert_eq!(exposure.http.as_ref().map(|h| h.path.as_str()), Some("/api/v1/reasoning/plan"));
24//!
25//! // one writer: only `reasoning.record` changes the repository
26//! let writers: Vec<&str> = m
27//!     .capabilities
28//!     .iter()
29//!     .filter(|e| e.capability.execution.effect == Effect::RepositoryMutation)
30//!     .map(|e| e.capability.id.as_str())
31//!     .collect();
32//! assert_eq!(writers, ["reasoning.record"]);
33//! ```
34
35use 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
59/// The MCP resource of `reasoning.advisors`.
60pub const ADVISORS_URI: &str = "majordomus://reasoning/advisors";
61/// The MCP resource of `reasoning.status`.
62pub 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// ---------------------------------------------------------------- reasoning.advisors
96
97/// One advisory capability and who can provide it now: a row of the capacity table
98/// `reasoning.advisors` reports. An empty `available` is answered by the local review.
99///
100/// ```
101/// use majordomus_cli::capability::builtin::reasoning::AdvisorCapacity;
102///
103/// let row: AdvisorCapacity = serde_json::from_value(serde_json::json!({
104///     "capability": "code_review", "description": "Critique of a change.", "available": [],
105/// })).unwrap();
106/// assert_eq!(row.capability, "code_review");
107/// assert!(row.available.is_empty());
108/// ```
109#[derive(Debug, Serialize, Deserialize, JsonSchema)]
110pub struct AdvisorCapacity {
111    /// The capability word.
112    pub capability: String,
113    /// What it means.
114    pub description: String,
115    /// Available advisors offering it, in preference order.
116    pub available: Vec<String>,
117}
118
119/// The answer of `reasoning.advisors`: the mode in force, how many advisors may and may
120/// not be asked, every advisor's state, the capacity per advisory capability, and the
121/// catalogue's own findings. `operational` is `true` whatever the counts.
122///
123/// ```
124/// use majordomus_cli::capability::builtin::reasoning::AdvisorsReport;
125///
126/// let report: AdvisorsReport = serde_json::from_value(serde_json::json!({
127///     "operational": true,
128///     "mode": { "mode": "ci", "source": "CI" },
129///     "available": 0, "unavailable": 0, "advisors": [], "capacity": [],
130/// })).unwrap();
131/// assert!(report.operational && report.advisors.is_empty());
132/// assert_eq!(report.mode.source, "CI");
133/// assert!(report.diagnostics.is_empty(), "absent findings are the ordinary case");
134/// ```
135#[derive(Debug, Serialize, Deserialize, JsonSchema)]
136pub struct AdvisorsReport {
137    /// Reasoning works whatever the advisors: always `true`, stated so a reader need not
138    /// infer it from a count.
139    pub operational: bool,
140    /// The mode in force and what decided it.
141    pub mode: ModeResolution,
142    /// Advisors that may be asked now.
143    pub available: usize,
144    /// Advisors that may not, each optional.
145    pub unavailable: usize,
146    /// Every advisor, declared ones in preference order, then linked peers.
147    pub advisors: Vec<AdvisorState>,
148    /// Per advisory capability, who can provide it now. A row with nobody is answered by
149    /// the local review path.
150    pub capacity: Vec<AdvisorCapacity>,
151    /// The catalogue's own findings.
152    #[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// ---------------------------------------------------------------- reasoning.plan
194
195#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
196#[serde(deny_unknown_fields, default)]
197/// The input of `reasoning.plan`: a stated uncertainty, in transport-friendly scalars.
198/// Nothing is recorded; `reasoning.record` with a `plan` records one.
199///
200/// ```
201/// use majordomus_cli::capability::builtin::reasoning::PlanPreviewInput;
202/// use majordomus_cli::reasoning::Materiality;
203///
204/// let input: PlanPreviewInput = serde_json::from_value(serde_json::json!({
205///     "materiality": "high", "capabilities": "code_review,security_review",
206/// })).unwrap();
207/// assert_eq!(input.materiality, Some(Materiality::High));
208/// assert!(input.confidence.is_none(), "an absent field takes the policy's default");
209/// assert!(serde_json::from_value::<PlanPreviewInput>(serde_json::json!({ "quorum": 2 })).is_err());
210/// ```
211pub struct PlanPreviewInput {
212    /// `trivial`, `low`, `material`, `high` or `critical`.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub materiality: Option<Materiality>,
215    /// `low`, `medium` or `high`.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub confidence: Option<ReasoningConfidence>,
218    /// Advisory capabilities review needs, comma-separated.
219    #[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// ---------------------------------------------------------------- reasoning.record
264
265#[derive(Debug, Serialize, Deserialize, JsonSchema)]
266#[serde(deny_unknown_fields)]
267/// The input of `reasoning.record`: one record, as authored, tagged by its `kind`. The
268/// writer computes everything else (id, task, stamp, a plan's selection) itself.
269///
270/// ```
271/// use majordomus_cli::capability::builtin::reasoning::ReasoningRecordInput;
272/// use majordomus_cli::reasoning::record::ReasoningInput;
273///
274/// let input: ReasoningRecordInput = serde_json::from_value(serde_json::json!({
275///     "record": { "kind": "plan", "assessment": "assessment-1" },
276/// })).unwrap();
277/// assert!(matches!(input.record, ReasoningInput::Plan(ref p) if p.assessment == "assessment-1"));
278/// ```
279pub struct ReasoningRecordInput {
280    /// The record.
281    pub record: ReasoningInput,
282}
283
284impl BenchmarkCases for ReasoningRecordInput {
285    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
286        // A plan for an assessment no store holds: the refusal path, which writes nothing.
287        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/// The answer of `reasoning.record`: the record as stored, where it was written, and the
299/// timeline step it adds.
300///
301/// ```
302/// use majordomus_cli::capability::builtin::reasoning::ReasoningRecorded;
303///
304/// let schema = serde_json::to_value(schemars::schema_for!(ReasoningRecorded)).unwrap();
305/// for field in ["record", "path", "step"] {
306///     assert!(schema["required"].as_array().unwrap().iter().any(|f| f == field), "{field}");
307/// }
308/// ```
309#[derive(Debug, Serialize, Deserialize, JsonSchema)]
310pub struct ReasoningRecorded {
311    /// The stored record.
312    pub record: ReasoningRecord,
313    /// Where it was written, relative to the repository.
314    pub path: String,
315    /// The step it adds to the timeline: the report a terminal or the Cockpit shows.
316    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// ---------------------------------------------------------------- reasoning.status
372
373#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
374#[serde(deny_unknown_fields, default)]
375/// The input of `reasoning.status`: which task's records to derive the state from. Absent
376/// is the open task; `all` is every task the store holds.
377///
378/// ```
379/// use majordomus_cli::capability::builtin::reasoning::ReasoningStatusInput;
380///
381/// let open: ReasoningStatusInput = serde_json::from_str("{}").unwrap();
382/// assert!(open.task.is_none());
383/// let all: ReasoningStatusInput = serde_json::from_str(r#"{"task":"all"}"#).unwrap();
384/// assert_eq!(all.task.as_deref(), Some("all"));
385/// ```
386pub struct ReasoningStatusInput {
387    /// A task id, or `all`; the open task when absent.
388    #[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/// The answer of `reasoning.status`: the mode in force, the advisor counts, the state the
407/// records derive, and that state rendered as the Markdown a handover carries.
408///
409/// ```
410/// use majordomus_cli::capability::builtin::reasoning::ReasoningStatus;
411///
412/// let schema = serde_json::to_value(schemars::schema_for!(ReasoningStatus)).unwrap();
413/// for field in ["mode", "advisors_available", "advisors_unavailable", "state", "report"] {
414///     assert!(schema["required"].as_array().unwrap().iter().any(|f| f == field), "{field}");
415/// }
416/// ```
417#[derive(Debug, Serialize, Deserialize, JsonSchema)]
418pub struct ReasoningStatus {
419    /// The mode in force.
420    pub mode: ModeResolution,
421    /// Advisors available now.
422    pub advisors_available: usize,
423    /// Advisors not available now, each optional.
424    pub advisors_unavailable: usize,
425    /// The state.
426    pub state: ReasoningState,
427    /// The state as Markdown: what a handover carries.
428    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// ---------------------------------------------------------------- reasoning.explain
457
458#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
459#[serde(deny_unknown_fields)]
460/// The input of `reasoning.explain`: the id of the record whose assessment chain is
461/// explained. An id no record carries is a not-found answer.
462///
463/// ```
464/// use majordomus_cli::capability::builtin::reasoning::ReasoningExplainInput;
465///
466/// let input: ReasoningExplainInput = serde_json::from_str(r#"{"id":"conclusion-1"}"#).unwrap();
467/// assert_eq!(input.id, "conclusion-1");
468/// assert!(serde_json::from_str::<ReasoningExplainInput>("{}").is_err(), "the id is required");
469/// ```
470pub struct ReasoningExplainInput {
471    /// A record id: a conclusion, usually.
472    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// ---------------------------------------------------------------- reasoning.check
496
497/// The answer of `reasoning.check`: whether it is clean, the checks performed in order,
498/// and every finding. `ok` is `true` exactly when `findings` is empty.
499///
500/// ```
501/// use majordomus_cli::capability::builtin::reasoning::ReasoningCheck;
502///
503/// let clean: ReasoningCheck = serde_json::from_value(serde_json::json!({
504///     "ok": true, "checks": ["catalogue"], "findings": [],
505/// })).unwrap();
506/// assert!(clean.ok && clean.findings.is_empty());
507/// assert_eq!(clean.checks, ["catalogue"]);
508/// ```
509#[derive(Debug, Serialize, Deserialize, JsonSchema)]
510pub struct ReasoningCheck {
511    /// No finding.
512    pub ok: bool,
513    /// The checks performed, in order.
514    pub checks: Vec<String>,
515    /// What they found.
516    pub findings: Vec<ReasoningFinding>,
517}
518
519fn reasoning_check(ctx: &Context, _: super::Empty) -> Result<ReasoningCheck, CapabilityError> {
520    let s = setting(ctx)?;
521    // the checks this repository is held to: `ci` and `claims` judge what Majordomus's own
522    // workflows and documents say, and apply only where its rule is declared (I1996)
523    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
544/// The canonical declaration of the `reasoning` module: its six capabilities with every
545/// projection each is exposed through. The MCP tools and resources, HTTP routes, CLI
546/// paths and the generated OpenAPI are read from here and nowhere else (ADR 0004).
547///
548/// ```
549/// use majordomus_cli::capability::builtin::reasoning::{module, REASONING_URI};
550///
551/// let m = module();
552/// let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
553/// assert_eq!(ids, [
554///     "reasoning.advisors", "reasoning.plan", "reasoning.record",
555///     "reasoning.status", "reasoning.explain", "reasoning.check",
556/// ]);
557/// let status = &m.capabilities[3].capability.exposure;
558/// let resource = status.mcp.as_ref().and_then(|m| m.resource.as_ref()).unwrap();
559/// assert_eq!(resource.uri, REASONING_URI);
560/// ```
561pub 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                // reasoning records are checkout state under .ai/local: no benchmark
661                // repository holds one to explain, so there is nothing to time but a miss
662                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}