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}