Skip to main content

majordomus_cli/reasoning/
mod.rs

1//! Reasoning: how a session turns material engineering uncertainty into a recorded,
2//! evidence-backed conclusion — with independent advisors when some are present, and
3//! without them when none are. ADR 0098 is the decision; `docs/REASONING.md` the manual.
4//!
5//! Four separations hold the design together, and each is a module boundary here:
6//!
7//! * **Reasoning is not a provider.** [`policy`] decides *whether* independent review is
8//!   worth having and which advisory capabilities it needs; it never names a vendor, a
9//!   client tool or a model. A test holds that no source file of this module does.
10//! * **Advisors are declared once.** [`catalogue`] reads `share/advisors.yaml`: advisory
11//!   roles that *reference* the provider table (`share/providers.yaml`) and the model
12//!   catalogue (`share/models.yaml`) rather than restating them. Mesh peers carrying the
13//!   `reviews` feature become advisors at run time from the same declaration.
14//! * **Absence is data.** [`availability`] turns presence (an executable on `PATH`, a
15//!   credential variable set — never read), the reasoning mode and the recorded outcomes
16//!   of earlier consultations into one typed status per advisor. No advisor at all is an
17//!   ordinary answer, not an error, and nothing here touches the network (ADR 0032).
18//! * **Records, not transcripts.** [`record`] is the typed vocabulary a session writes —
19//!   assessment, plan, consultation, disagreement, resolution, conclusion, validation,
20//!   attempt — and [`store`] the one writer, which validates every reference before a
21//!   record becomes durable. A consultation may name only an advisor its recorded plan
22//!   selected; a conclusion's review count is computed, never authored; a disagreement is
23//!   closed by evidence, never by a count. [`state`] derives the session's reasoning state,
24//!   its timeline and its report from the records alone, deterministically.
25//!
26//! Talking to an advisor is the one thing this module does not do. The transport is the
27//! Node layer under `scripts/lib/advisors/`, the side of ADR 0032's boundary that already
28//! holds the repository's network code: it asks this crate for a plan, consults what the
29//! plan selected, and records each normalised outcome back through the same writer.
30//!
31//! # Example
32//!
33//! A checkout whose share declares mesh peers as advisors, with one reviewing peer
34//! linked: the situation resolves it as available, and a plan for a material uncertainty
35//! selects it.
36//!
37//! ```
38//! use majordomus_cli::reasoning::availability::LinkedPeer;
39//! use majordomus_cli::reasoning::{plan, Materiality, PlanOutcome, PlanRequest, Situation};
40//! use majordomus_cli::share::ProviderDeclarations;
41//!
42//! // the mode is named outright, so the example does not depend on the runner
43//! std::env::set_var("MAJORDOMUS_REASONING_MODE", "standard");
44//! std::env::remove_var("MAJORDOMUS_ADVISORS_DISABLE");
45//! let root = tempfile::tempdir().unwrap();
46//! let share = tempfile::tempdir().unwrap();
47//! std::fs::write(
48//!     share.path().join("advisors.yaml"),
49//!     "version: 1\ncapabilities:\n  - id: independent_reasoning\n    description: x\npeers:\n  feature: reviews\n  adapter: mesh-review\n  capabilities: [independent_reasoning]\n",
50//! ).unwrap();
51//! let peer = LinkedPeer { runtime: "n1-r1".into(), name: "one".into(), features: vec!["reviews".into()] };
52//!
53//! let situation = Situation::resolve(
54//!     root.path(), Some(share.path()), &ProviderDeclarations::default(), &[peer],
55//! ).unwrap();
56//! assert_eq!(situation.available(), 1);
57//! assert!(situation.loaded.records.is_empty(), "no record has been written yet");
58//!
59//! let request = PlanRequest { materiality: Materiality::Material, ..Default::default() };
60//! let p = plan(&request, &situation.mode, &situation.states);
61//! assert_eq!(p.outcome, PlanOutcome::Consult);
62//! assert_eq!(p.selected[0].advisor, "peer:n1-r1");
63//! ```
64
65pub mod availability;
66pub mod catalogue;
67pub mod check;
68pub mod policy;
69pub mod record;
70pub mod state;
71pub mod store;
72
73pub use availability::{
74    derive_availability, AdvisorCircuit, AdvisorState, AdvisorStatus, ModeResolution, Presence,
75    ReasoningMode, SystemPresence,
76};
77pub use catalogue::{AdvisorCatalogue, AdvisorDeclaration, AdvisorTransport, ADVISORS_FILE};
78pub use policy::{plan, Materiality, PlanOutcome, PlanRequest, ReasoningConfidence, ReviewPlan};
79
80/// Everything reasoning reads about this checkout, resolved once: the catalogue and the
81/// references it resolves against, the mode, every advisor's standing and the records.
82/// Each surface — the capabilities, the environment snapshot — starts here, so none of
83/// them derives availability a second way.
84///
85/// ```
86/// use majordomus_cli::reasoning::Situation;
87/// use majordomus_cli::share::ProviderDeclarations;
88///
89/// // no share: no catalogue, no advisor, and still a situation to reason in
90/// let root = tempfile::tempdir().unwrap();
91/// let s = Situation::resolve(root.path(), None, &ProviderDeclarations::default(), &[]).unwrap();
92/// assert!(s.catalogue.advisors.is_empty() && s.states.is_empty());
93/// assert!(s.diagnostics.is_empty());
94/// assert_eq!(s.available(), 0);
95/// ```
96pub struct Situation {
97    /// The advisor catalogue.
98    pub catalogue: AdvisorCatalogue,
99    /// What it references.
100    pub refs: catalogue::References,
101    /// The mode in force.
102    pub mode: ModeResolution,
103    /// Every advisor's standing now.
104    pub states: Vec<AdvisorState>,
105    /// The record store.
106    pub store: store::Store,
107    /// Its records.
108    pub loaded: store::Loaded,
109    /// The catalogue's own findings.
110    pub diagnostics: Vec<String>,
111}
112
113/// The profile of the open task and the reasoning mode it declares.
114fn profile_mode(root: &std::path::Path) -> Option<(String, Option<String>)> {
115    let current = std::fs::read_to_string(root.join(".ai/local/state/current.yaml")).ok()?;
116    let name = current
117        .lines()
118        .find_map(|l| l.strip_prefix("profile:"))?
119        .trim()
120        .trim_matches('"')
121        .to_string();
122    let profile = std::fs::read_to_string(root.join(format!(".ai/repo/profiles/{name}.yaml"))).ok();
123    let mode = profile.and_then(|t| {
124        t.lines()
125            .find_map(|l| l.strip_prefix("reasoning:"))
126            .map(|v| v.trim().trim_matches('"').to_string())
127    });
128    Some((name, mode))
129}
130
131fn env_word(name: &str) -> Option<String> {
132    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
133}
134
135impl Situation {
136    /// Resolve the situation of the checkout at `root`: no network, no model, no build.
137    /// The catalogue comes from `share` (none without one), the mode from the environment,
138    /// a CI runner or the open task's profile, availability from presence, the disable
139    /// list, the recorded consultations and the linked `peers`. Only a catalogue that
140    /// cannot be read is an error.
141    ///
142    /// ```
143    /// use majordomus_cli::reasoning::{ReasoningMode, Situation};
144    /// use majordomus_cli::share::ProviderDeclarations;
145    ///
146    /// std::env::set_var("MAJORDOMUS_REASONING_MODE", "offline");
147    /// let root = tempfile::tempdir().unwrap();
148    /// let s = Situation::resolve(root.path(), None, &ProviderDeclarations::default(), &[]).unwrap();
149    /// assert_eq!(s.mode.mode, ReasoningMode::Offline);
150    /// assert_eq!(s.mode.source, "MAJORDOMUS_REASONING_MODE");
151    ///
152    /// let share = tempfile::tempdir().unwrap();
153    /// std::fs::write(share.path().join("advisors.yaml"), "version: one\n").unwrap();
154    /// let refused = Situation::resolve(root.path(), Some(share.path()), &ProviderDeclarations::default(), &[]);
155    /// assert!(refused.is_err());
156    /// ```
157    pub fn resolve(
158        root: &std::path::Path,
159        share: Option<&std::path::Path>,
160        providers: &crate::share::ProviderDeclarations,
161        peers: &[availability::LinkedPeer],
162    ) -> Result<Self, String> {
163        let (catalogue, refs) = match share {
164            Some(share) => (
165                AdvisorCatalogue::load(share)?,
166                AdvisorCatalogue::references(share, providers),
167            ),
168            None => (
169                AdvisorCatalogue::default(),
170                catalogue::References::default(),
171            ),
172        };
173        let profile = profile_mode(root);
174        let ci = env_word("CI").is_some_and(|v| v != "false" && v != "0");
175        let mode = availability::resolve_mode(
176            env_word(availability::MODE_ENV).as_deref(),
177            ci,
178            profile.as_ref().map(|(n, m)| (n.as_str(), m.as_deref())),
179        );
180        let disabled: Vec<String> = env_word(availability::DISABLE_ENV)
181            .map(|v| {
182                v.split(',')
183                    .map(|s| s.trim().to_string())
184                    .filter(|s| !s.is_empty())
185                    .collect()
186            })
187            .unwrap_or_default();
188        let cooldown = env_word(availability::COOLDOWN_ENV).and_then(|v| v.parse::<i64>().ok());
189        let store = store::Store::at(root);
190        let loaded = store.load();
191        let history: Vec<availability::Outcome> = loaded
192            .records
193            .iter()
194            .filter_map(|r| match &r.body {
195                record::ReasoningBody::Consultation(c) => Some(availability::Outcome {
196                    id: r.id.clone(),
197                    advisor: c.advisor.clone(),
198                    status: c.status,
199                    at: epoch_seconds(&r.recorded_at)?,
200                    retry_after: c.retry_after_seconds,
201                }),
202                _ => None,
203            })
204            .collect();
205        let states = derive_availability(
206            &catalogue,
207            &availability::Inputs {
208                refs: &refs,
209                presence: &SystemPresence,
210                mode: mode.mode,
211                disabled: &disabled,
212                history: &history,
213                peers,
214                now: now_seconds(),
215                cooldown,
216            },
217        );
218        let diagnostics = catalogue.diagnostics(&refs);
219        Ok(Self {
220            catalogue,
221            refs,
222            mode,
223            states,
224            store,
225            loaded,
226            diagnostics,
227        })
228    }
229
230    /// How many advisors may be asked now: those whose status is `available`, whatever
231    /// the number declared. Zero is an ordinary answer.
232    ///
233    /// ```
234    /// use majordomus_cli::reasoning::availability::LinkedPeer;
235    /// use majordomus_cli::reasoning::Situation;
236    /// use majordomus_cli::share::ProviderDeclarations;
237    ///
238    /// std::env::set_var("MAJORDOMUS_REASONING_MODE", "ci");
239    /// let root = tempfile::tempdir().unwrap();
240    /// let share = tempfile::tempdir().unwrap();
241    /// std::fs::write(
242    ///     share.path().join("advisors.yaml"),
243    ///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\npeers:\n  feature: reviews\n  adapter: mesh-review\n  capabilities: [code_review]\n",
244    /// ).unwrap();
245    /// let peer = LinkedPeer { runtime: "n1-r1".into(), name: "one".into(), features: vec!["reviews".into()] };
246    /// let s = Situation::resolve(root.path(), Some(share.path()), &ProviderDeclarations::default(), &[peer]).unwrap();
247    /// // mode ci admits no advisor, linked or not
248    /// assert_eq!((s.states.len(), s.available()), (1, 0));
249    /// ```
250    pub fn available(&self) -> usize {
251        self.states
252            .iter()
253            .filter(|a| a.status == AdvisorStatus::Available)
254            .count()
255    }
256}
257
258/// Seconds since the epoch of an RFC 3339 UTC stamp as this crate writes them
259/// (`2026-10-01T09:30:00Z`, optionally with fractional seconds). `None` for anything else:
260/// a stamp that cannot be read is not guessed at.
261///
262/// ```
263/// use majordomus_cli::reasoning::epoch_seconds;
264///
265/// assert_eq!(epoch_seconds("1970-01-01T00:00:00Z"), Some(0));
266/// assert_eq!(epoch_seconds("2026-10-01T00:00:10Z"), Some(1_790_812_810));
267/// assert_eq!(epoch_seconds("2026-10-01T00:00:10.25Z"), Some(1_790_812_810));
268/// assert_eq!(epoch_seconds("yesterday"), None);
269/// ```
270pub fn epoch_seconds(stamp: &str) -> Option<i64> {
271    let b = stamp.as_bytes();
272    if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || !stamp.ends_with('Z') {
273        return None;
274    }
275    let num = |from: usize, to: usize| stamp.get(from..to)?.parse::<i64>().ok();
276    let (y, m, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
277    let (hh, mm, ss) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
278    if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
279        return None;
280    }
281    // days from civil (Howard Hinnant's algorithm)
282    let y = if m <= 2 { y - 1 } else { y };
283    let era = y.div_euclid(400);
284    let yoe = y - era * 400;
285    let mp = (m + 9) % 12;
286    let doy = (153 * mp + 2) / 5 + d - 1;
287    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
288    let days = era * 146_097 + doe - 719_468;
289    Some(days * 86_400 + hh * 3_600 + mm * 60 + ss)
290}
291
292/// The RFC 3339 stamp of seconds since the epoch — the inverse of [`epoch_seconds`].
293///
294/// ```
295/// use majordomus_cli::reasoning::{epoch_seconds, stamp_of};
296///
297/// let s = "2026-10-01T09:30:00Z";
298/// assert_eq!(stamp_of(epoch_seconds(s).unwrap()), s);
299/// ```
300pub fn stamp_of(seconds: i64) -> String {
301    let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds.max(0) as u64);
302    crate::peers::rfc3339(t)
303}
304
305/// Now as an RFC 3339 stamp with milliseconds (`2026-10-01T09:30:00.123Z`): the order of
306/// records written within one second is the order they were written in, and the fixed
307/// width keeps byte order and time order the same.
308///
309/// ```
310/// use majordomus_cli::reasoning::{epoch_seconds, now_stamp};
311///
312/// let stamp = now_stamp();
313/// assert_eq!(stamp.len(), "2026-10-01T09:30:00.123Z".len());
314/// assert!(epoch_seconds(&stamp).unwrap() > 0);
315/// ```
316pub fn now_stamp() -> String {
317    let d = std::time::SystemTime::now()
318        .duration_since(std::time::UNIX_EPOCH)
319        .unwrap_or_default();
320    let whole = stamp_of(d.as_secs() as i64);
321    format!("{}.{:03}Z", whole.trim_end_matches('Z'), d.subsec_millis())
322}
323
324/// The current time, seconds since the epoch: the clock availability measures cooldowns
325/// against. Zero on a clock set before the epoch rather than a panic.
326///
327/// ```
328/// use majordomus_cli::reasoning::{epoch_seconds, now_seconds};
329///
330/// assert!(now_seconds() > epoch_seconds("2026-01-01T00:00:00Z").unwrap());
331/// ```
332pub fn now_seconds() -> i64 {
333    std::time::SystemTime::now()
334        .duration_since(std::time::UNIX_EPOCH)
335        .map(|d| d.as_secs() as i64)
336        .unwrap_or(0)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn stamps_round_trip_and_unreadable_ones_are_refused() {
345        for s in [
346            "1970-01-01T00:00:00Z",
347            "2000-02-29T23:59:59Z",
348            "2026-10-01T09:30:00Z",
349        ] {
350            assert_eq!(stamp_of(epoch_seconds(s).unwrap()), s);
351        }
352        for bad in [
353            "2026-13-01T00:00:00Z",
354            "2026-10-01 09:30:00Z",
355            "2026-10-01T09:30:00",
356        ] {
357            assert_eq!(epoch_seconds(bad), None, "{bad}");
358        }
359        let now = now_stamp();
360        assert!(now.ends_with('Z') && now.as_bytes()[19] == b'.');
361    }
362
363    #[test]
364    fn the_open_tasks_profile_names_its_mode() {
365        let root = tempfile::tempdir().unwrap();
366        assert_eq!(profile_mode(root.path()), None, "no open task, no profile");
367        std::fs::create_dir_all(root.path().join(".ai/local/state")).unwrap();
368        std::fs::create_dir_all(root.path().join(".ai/repo/profiles")).unwrap();
369        std::fs::write(
370            root.path().join(".ai/local/state/current.yaml"),
371            "task: t-1\nprofile: \"deep-work\"\n",
372        )
373        .unwrap();
374        assert_eq!(
375            profile_mode(root.path()),
376            Some(("deep-work".to_string(), None)),
377            "a profile that declares no mode"
378        );
379        std::fs::write(
380            root.path().join(".ai/repo/profiles/deep-work.yaml"),
381            "id: deep-work\nreasoning: strict\n",
382        )
383        .unwrap();
384        assert_eq!(
385            profile_mode(root.path()),
386            Some(("deep-work".to_string(), Some("strict".to_string())))
387        );
388    }
389
390    #[test]
391    fn a_checkout_without_a_share_resolves_to_no_advisor() {
392        let root = tempfile::tempdir().unwrap();
393        let s = Situation::resolve(
394            root.path(),
395            None,
396            &crate::share::ProviderDeclarations::default(),
397            &[],
398        )
399        .unwrap();
400        assert!(s.states.is_empty() && s.diagnostics.is_empty());
401        assert_eq!(s.available(), 0);
402        assert!(s.loaded.records.is_empty());
403    }
404}