Skip to main content

majordomus_cli/reasoning/
catalogue.rs

1//! The advisor catalogue: `share/advisors.yaml`, one declaration of every advisory role a
2//! session may consult, in the grain of `share/providers.yaml` and `share/models.yaml`.
3//!
4//! An advisor is not a provider and not a vendor. A *provider* is a client tool that works
5//! in this repository (ADR 0024); a *vendor* serves models (ADR 0049); an *advisor* is a
6//! role — something a session may ask for an independent opinion — reached through one
7//! transport. The catalogue therefore holds only what neither neighbour knows: the
8//! transport, the adapter that speaks it, the executable whose presence says it is
9//! installed, and the advisory capabilities it offers. Everything else is a reference —
10//! a provider id, a vendor id, a model id — and [`AdvisorCatalogue::diagnostics`] refuses
11//! a reference that resolves to nothing, so the three tables cannot fork.
12//!
13//! Declaration order is the preference order. That is where a repository's "ask this one
14//! first" lives: as data the policy reads, never as a name the policy knows.
15//!
16//! # Example
17//!
18//! A share directory declaring two advisors and the vendor one of them references: load,
19//! resolve the references, check the catalogue, then look an advisor up.
20//!
21//! ```
22//! use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, ADVISORS_FILE};
23//! use majordomus_cli::share::ProviderDeclarations;
24//!
25//! let share = tempfile::tempdir().unwrap();
26//! std::fs::write(share.path().join(ADVISORS_FILE), "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: api\n    adapter: a-api\n    vendor: acme\n    capabilities: [code_review]\n  - id: b\n    title: B\n    transport: cli\n    adapter: b-cli\n    executable: b\n    capabilities: [code_review]\n").unwrap();
27//! std::fs::write(share.path().join("models.yaml"), "version: 1\nvendors:\n  - id: acme\n    title: Acme\n    credential_env: ACME_KEY\nmodels: []\n").unwrap();
28//!
29//! let catalogue = AdvisorCatalogue::load(share.path()).unwrap();
30//! let refs = AdvisorCatalogue::references(share.path(), &ProviderDeclarations::default());
31//! assert!(catalogue.diagnostics(&refs).is_empty());
32//!
33//! let ids: Vec<&str> = catalogue.advisors.iter().map(|a| a.id.as_str()).collect();
34//! assert_eq!(ids, ["a", "b"], "declaration order is preference order");
35//! let a = catalogue.get("a").unwrap();
36//! assert_eq!(catalogue.credential_of(a, &refs), Some("ACME_KEY"));
37//! ```
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41
42/// The file name under the share directory.
43pub const ADVISORS_FILE: &str = "advisors.yaml";
44
45/// How an advisor is reached. The transport decides how its presence is probed (a
46/// credential variable, an executable, a mesh link) and whether `offline` mode admits it.
47///
48/// ```
49/// use majordomus_cli::reasoning::catalogue::AdvisorTransport;
50///
51/// let t: AdvisorTransport = serde_json::from_str("\"local_runtime\"").unwrap();
52/// assert_eq!(t, AdvisorTransport::LocalRuntime);
53/// assert!(!t.is_remote());
54/// ```
55#[derive(
56    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
57)]
58#[serde(rename_all = "snake_case")]
59pub enum AdvisorTransport {
60    /// A remote API; present when its vendor's credential variable is set.
61    Api,
62    /// A client tool run as a process; present when its executable is on `PATH`.
63    Cli,
64    /// A model runtime on this machine; present when its executable is on `PATH`.
65    LocalRuntime,
66    /// A linked mesh runtime carrying the review feature; present while linked.
67    Peer,
68}
69
70impl AdvisorTransport {
71    /// Whether consulting through this transport leaves the machine: an API, a client
72    /// tool (which calls its own service) and a mesh peer do; a local runtime does not.
73    /// `offline` mode admits only transports that are not remote.
74    ///
75    /// ```
76    /// use majordomus_cli::reasoning::catalogue::AdvisorTransport;
77    ///
78    /// assert!(AdvisorTransport::Api.is_remote());
79    /// assert!(AdvisorTransport::Peer.is_remote());
80    /// assert!(!AdvisorTransport::LocalRuntime.is_remote());
81    /// ```
82    pub fn is_remote(self) -> bool {
83        matches!(self, Self::Api | Self::Cli | Self::Peer)
84    }
85}
86
87/// One advisory capability word of the declared vocabulary: what a plan requests and an
88/// advisor offers. A word outside the vocabulary is a catalogue finding.
89///
90/// ```
91/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, AdvisoryCapability};
92///
93/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
94///     "version: 1\ncapabilities:\n  - id: code_review\n    description: Critique of a change.\n",
95/// ).unwrap();
96/// let word: &AdvisoryCapability = &c.capabilities[0];
97/// assert_eq!((word.id.as_str(), word.description.as_str()), ("code_review", "Critique of a change."));
98/// assert!(c.knows_capability("code_review"));
99/// ```
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields)]
102pub struct AdvisoryCapability {
103    /// The word, e.g. `independent_reasoning`.
104    pub id: String,
105    /// What asking for it means.
106    pub description: String,
107}
108
109/// One declared advisor: its id, how it is reached and through which adapter, what it
110/// references in the provider and model tables, the executable that says it is
111/// installed, and the advisory capabilities it offers.
112///
113/// ```
114/// use majordomus_cli::reasoning::catalogue::{AdvisorDeclaration, AdvisorTransport};
115///
116/// let a: AdvisorDeclaration = majordomus_cli::metadata::yaml::parse_into(
117///     "id: b\ntitle: B\ntransport: cli\nadapter: b-cli\nexecutable: b\ncapabilities: [code_review]\n",
118/// ).unwrap();
119/// assert_eq!(a.transport, AdvisorTransport::Cli);
120/// assert_eq!(a.executable.as_deref(), Some("b"));
121/// assert!(a.vendor.is_none() && a.model.is_none());
122/// ```
123#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
124#[serde(deny_unknown_fields)]
125pub struct AdvisorDeclaration {
126    /// The advisor id, unique in the catalogue: what records and configuration name.
127    pub id: String,
128    /// The human name.
129    pub title: String,
130    /// How it is reached.
131    pub transport: AdvisorTransport,
132    /// The transport adapter that speaks to it: a module `scripts/lib/advisors/<adapter>.mjs`.
133    pub adapter: String,
134    /// The provider it runs as (`share/providers.yaml`), for a client tool.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub provider: Option<String>,
137    /// The vendor that serves it (`share/models.yaml`), for an API or a local runtime.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub vendor: Option<String>,
140    /// The model it asks (`share/models.yaml`), when the catalogue declares it.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub model: Option<String>,
143    /// The executable whose presence on `PATH` says it is installed.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub executable: Option<String>,
146    /// The advisory capabilities it offers, from the declared vocabulary.
147    #[serde(default)]
148    pub capabilities: Vec<String>,
149    /// One line a person needs when choosing.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub note: Option<String>,
152}
153
154/// How linked mesh runtimes become advisors: those carrying `feature`, with these
155/// capabilities, through this adapter.
156///
157/// ```
158/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, PeerAdvisors};
159///
160/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
161///     "version: 1\npeers:\n  feature: reviews\n  adapter: mesh-review\n  capabilities: [code_review]\n",
162/// ).unwrap();
163/// let peers: &PeerAdvisors = c.peers.as_ref().unwrap();
164/// assert_eq!(peers.feature, "reviews");
165/// // a capability outside the (here empty) vocabulary is a finding
166/// assert!(c.diagnostics(&Default::default()).iter().any(|f| f.contains("'code_review'")));
167/// ```
168#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct PeerAdvisors {
171    /// The link feature a runtime must carry.
172    pub feature: String,
173    /// The adapter that carries a request to it.
174    pub adapter: String,
175    /// The advisory capabilities a reviewing peer offers.
176    #[serde(default)]
177    pub capabilities: Vec<String>,
178}
179
180/// The whole catalogue, as `share/advisors.yaml` declares it: the capability vocabulary,
181/// the advisors in preference order, how mesh peers become advisors, and the claims no
182/// document may assert. The default is the empty catalogue a share without the file has.
183///
184/// ```
185/// use majordomus_cli::reasoning::catalogue::AdvisorCatalogue;
186///
187/// let empty = AdvisorCatalogue::default();
188/// assert!(empty.advisors.is_empty() && empty.get("anything").is_none());
189/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
190///     "version: 1\nforbidden_claims: [consensus guarantees correctness]\n",
191/// ).unwrap();
192/// assert_eq!(c.forbidden_claims, ["consensus guarantees correctness"]);
193/// ```
194#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
195#[serde(deny_unknown_fields)]
196pub struct AdvisorCatalogue {
197    /// The format version.
198    pub version: u32,
199    /// The advisory capability vocabulary.
200    #[serde(default)]
201    pub capabilities: Vec<AdvisoryCapability>,
202    /// The advisors, in preference order.
203    #[serde(default)]
204    pub advisors: Vec<AdvisorDeclaration>,
205    /// Linked mesh runtimes as advisors, when declared.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub peers: Option<PeerAdvisors>,
208    /// Phrases no document of the repository may assert: the claims this design
209    /// refuses ("consensus guarantees correctness"), checked by `reasoning.check`.
210    #[serde(default)]
211    pub forbidden_claims: Vec<String>,
212}
213
214/// The names the catalogue's references must resolve against: the provider ids of
215/// `share/providers.yaml` and the model catalogue of `share/models.yaml`.
216///
217/// ```
218/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
219///
220/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
221///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: cli\n    adapter: a-cli\n    provider: tool\n    executable: a\n    capabilities: [code_review]\n",
222/// ).unwrap();
223/// assert!(!c.diagnostics(&References::default()).is_empty());
224/// let refs = References { providers: vec!["tool".into()], ..Default::default() };
225/// assert!(c.diagnostics(&refs).is_empty());
226/// ```
227#[derive(Debug, Clone, Default)]
228pub struct References {
229    /// Provider ids of `share/providers.yaml`.
230    pub providers: Vec<String>,
231    /// The model catalogue.
232    pub models: crate::models::ModelCatalogue,
233}
234
235impl AdvisorCatalogue {
236    /// Read `<share>/advisors.yaml`. An absent file is an empty catalogue: a distribution
237    /// that declares no advisors has none, and reasoning works without them. A file that
238    /// does not parse is an error naming it.
239    ///
240    /// ```
241    /// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, ADVISORS_FILE};
242    ///
243    /// let share = tempfile::tempdir().unwrap();
244    /// assert!(AdvisorCatalogue::load(share.path()).unwrap().advisors.is_empty());
245    /// std::fs::write(share.path().join(ADVISORS_FILE), "version: one\n").unwrap();
246    /// let refused = AdvisorCatalogue::load(share.path()).unwrap_err();
247    /// assert!(refused.contains(ADVISORS_FILE));
248    /// ```
249    pub fn load(share: &std::path::Path) -> Result<Self, String> {
250        let path = share.join(ADVISORS_FILE);
251        match std::fs::read_to_string(&path) {
252            Ok(text) => crate::metadata::yaml::parse_into(&text)
253                .map_err(|reason| format!("{}: {reason}", path.display())),
254            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
255            Err(e) => Err(format!("{}: {e}", path.display())),
256        }
257    }
258
259    /// The references the catalogue resolves against: the provider ids the index already
260    /// read, and the model catalogue of the same share (empty when it cannot be read).
261    ///
262    /// ```
263    /// use majordomus_cli::reasoning::catalogue::AdvisorCatalogue;
264    /// use majordomus_cli::share::{ProviderDeclaration, ProviderDeclarations};
265    ///
266    /// let share = tempfile::tempdir().unwrap();
267    /// let providers = ProviderDeclarations {
268    ///     providers: vec![ProviderDeclaration { id: "tool".into(), ..Default::default() }],
269    ///     ..Default::default()
270    /// };
271    /// let refs = AdvisorCatalogue::references(share.path(), &providers);
272    /// assert_eq!(refs.providers, ["tool"]);
273    /// assert!(refs.models.vendors.is_empty());
274    /// ```
275    pub fn references(
276        share: &std::path::Path,
277        providers: &crate::share::ProviderDeclarations,
278    ) -> References {
279        References {
280            providers: providers.providers.iter().map(|p| p.id.clone()).collect(),
281            models: crate::models::ModelCatalogue::load(share).unwrap_or_default(),
282        }
283    }
284
285    /// The declared advisor of an id; `None` for an id the catalogue does not declare,
286    /// which includes every `peer:` id.
287    ///
288    /// ```
289    /// use majordomus_cli::reasoning::catalogue::AdvisorCatalogue;
290    ///
291    /// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
292    ///     "version: 1\nadvisors:\n  - id: a\n    title: A\n    transport: cli\n    adapter: a-cli\n",
293    /// ).unwrap();
294    /// assert_eq!(c.get("a").map(|a| a.title.as_str()), Some("A"));
295    /// assert!(c.get("peer:n1-r1").is_none());
296    /// ```
297    pub fn get(&self, id: &str) -> Option<&AdvisorDeclaration> {
298        self.advisors.iter().find(|a| a.id == id)
299    }
300
301    /// Whether a word is in the declared capability vocabulary: what `reasoning.plan`
302    /// checks a requested capability against before it plans.
303    ///
304    /// ```
305    /// use majordomus_cli::reasoning::catalogue::AdvisorCatalogue;
306    ///
307    /// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
308    ///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\n",
309    /// ).unwrap();
310    /// assert!(c.knows_capability("code_review"));
311    /// assert!(!c.knows_capability("telepathy"));
312    /// ```
313    pub fn knows_capability(&self, word: &str) -> bool {
314        self.capabilities.iter().any(|c| c.id == word)
315    }
316
317    /// The credential variable whose presence says an API advisor is configured: its
318    /// vendor's, as the model catalogue declares it. Never a value, only a name. `None`
319    /// for an advisor with no vendor, or whose vendor declares no variable.
320    ///
321    /// ```
322    /// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
323    ///
324    /// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into("version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: api\n    adapter: a-api\n    vendor: acme\n    capabilities: [code_review]\n  - id: b\n    title: B\n    transport: cli\n    adapter: b-cli\n    executable: b\n    capabilities: [code_review]\n",
325    /// ).unwrap();
326    /// let refs = References {
327    ///     models: majordomus_cli::metadata::yaml::parse_into("version: 1\nvendors:\n  - id: acme\n    title: Acme\n    credential_env: ACME_KEY\nmodels: []\n").unwrap(),
328    ///     ..Default::default()
329    /// };
330    /// assert_eq!(c.credential_of(c.get("a").unwrap(), &refs), Some("ACME_KEY"));
331    /// assert_eq!(c.credential_of(c.get("b").unwrap(), &refs), None);
332    /// ```
333    pub fn credential_of<'a>(
334        &self,
335        advisor: &AdvisorDeclaration,
336        refs: &'a References,
337    ) -> Option<&'a str> {
338        let vendor = advisor.vendor.as_deref()?;
339        refs.models
340            .vendors
341            .iter()
342            .find(|v| v.id == vendor)
343            .and_then(|v| v.credential_env.as_deref())
344    }
345
346    /// The catalogue's own consistency: duplicate ids, words outside the vocabulary, a
347    /// reference resolving to nothing, an advisor whose presence cannot be probed.
348    /// Reported, never repaired; empty is the ordinary case.
349    ///
350    /// ```
351    /// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
352    ///
353    /// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
354    ///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: cli\n    adapter: a-cli\n    provider: nowhere\n    executable: a\n    capabilities: [code_review, telepathy]\n",
355    /// ).unwrap();
356    /// let found = c.diagnostics(&References::default());
357    /// assert!(found.iter().any(|f| f.contains("provider 'nowhere'")));
358    /// assert!(found.iter().any(|f| f.contains("'telepathy'")));
359    /// ```
360    pub fn diagnostics(&self, refs: &References) -> Vec<String> {
361        let mut out = Vec::new();
362        let mut seen = std::collections::BTreeSet::new();
363        for a in &self.advisors {
364            if !seen.insert(a.id.as_str()) {
365                out.push(format!("advisor '{}' is declared twice", a.id));
366            }
367            if a.capabilities.is_empty() {
368                out.push(format!("advisor '{}' offers no capability", a.id));
369            }
370            for word in &a.capabilities {
371                if !self.knows_capability(word) {
372                    out.push(format!(
373                        "advisor '{}' offers '{word}', which the vocabulary does not declare",
374                        a.id
375                    ));
376                }
377            }
378            if let Some(p) = &a.provider {
379                if !refs.providers.iter().any(|known| known == p) {
380                    out.push(format!(
381                        "advisor '{}' names provider '{p}', which share/providers.yaml does not declare",
382                        a.id
383                    ));
384                }
385            }
386            if let Some(v) = &a.vendor {
387                if !refs.models.vendors.iter().any(|known| &known.id == v) {
388                    out.push(format!(
389                        "advisor '{}' names vendor '{v}', which share/models.yaml does not declare",
390                        a.id
391                    ));
392                }
393            }
394            if let Some(m) = &a.model {
395                match refs.models.resolve(m) {
396                    None => out.push(format!(
397                        "advisor '{}' names model '{m}', which share/models.yaml does not declare",
398                        a.id
399                    )),
400                    Some(entry) if a.vendor.as_deref().is_some_and(|v| v != entry.vendor) => out
401                        .push(format!(
402                            "advisor '{}' names model '{m}' of vendor '{}', not of its own vendor",
403                            a.id, entry.vendor
404                        )),
405                    Some(_) => {}
406                }
407            }
408            match a.transport {
409                AdvisorTransport::Api => {
410                    if self.credential_of(a, refs).is_none() {
411                        out.push(format!(
412                            "advisor '{}' is an API advisor whose vendor declares no credential variable, so its presence cannot be probed",
413                            a.id
414                        ));
415                    }
416                }
417                AdvisorTransport::Cli | AdvisorTransport::LocalRuntime => {
418                    if a.executable.is_none() {
419                        out.push(format!(
420                            "advisor '{}' declares no executable, so its presence cannot be probed",
421                            a.id
422                        ));
423                    }
424                }
425                AdvisorTransport::Peer => out.push(format!(
426                    "advisor '{}' is declared with transport peer; peers are discovered from the mesh, never listed",
427                    a.id
428                )),
429            }
430        }
431        if let Some(p) = &self.peers {
432            for word in &p.capabilities {
433                if !self.knows_capability(word) {
434                    out.push(format!(
435                        "peer advisors offer '{word}', which the vocabulary does not declare"
436                    ));
437                }
438            }
439        }
440        out
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn catalogue(yaml: &str) -> AdvisorCatalogue {
449        crate::metadata::yaml::parse_into(yaml).unwrap()
450    }
451
452    #[test]
453    fn an_absent_file_is_an_empty_catalogue_and_a_present_one_is_read() {
454        let share = tempfile::tempdir().unwrap();
455        let empty = AdvisorCatalogue::load(share.path()).unwrap();
456        assert!(empty.advisors.is_empty() && empty.diagnostics(&References::default()).is_empty());
457        std::fs::write(
458            share.path().join(ADVISORS_FILE),
459            "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: local_runtime\n    adapter: a-rt\n    executable: rt\n    capabilities: [code_review]\n",
460        )
461        .unwrap();
462        let read = AdvisorCatalogue::load(share.path()).unwrap();
463        assert_eq!(
464            read.get("a").map(|a| a.transport),
465            Some(AdvisorTransport::LocalRuntime)
466        );
467        assert!(read.diagnostics(&References::default()).is_empty());
468    }
469
470    #[test]
471    fn an_unknown_field_is_refused_rather_than_ignored() {
472        let share = tempfile::tempdir().unwrap();
473        std::fs::write(share.path().join(ADVISORS_FILE), "version: 1\nquorum: 2\n").unwrap();
474        assert!(AdvisorCatalogue::load(share.path()).is_err());
475    }
476
477    #[test]
478    fn diagnostics_name_every_unprobeable_or_unresolved_advisor() {
479        let c = catalogue(
480            "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: api\n    adapter: a-api\n    vendor: ghost\n    capabilities: [code_review]\n  - id: a\n    title: A again\n    transport: cli\n    adapter: a-cli\n    capabilities: []\n  - id: p\n    title: P\n    transport: peer\n    adapter: mesh\n    capabilities: [code_review]\n",
481        );
482        let found = c.diagnostics(&References::default());
483        for needle in [
484            "vendor 'ghost'",
485            "declares no credential variable",
486            "'a' is declared twice",
487            "'a' offers no capability",
488            "'a' declares no executable",
489            "transport peer",
490        ] {
491            assert!(
492                found.iter().any(|f| f.contains(needle)),
493                "{needle} missing from {found:?}"
494            );
495        }
496    }
497
498    #[test]
499    fn a_model_of_another_vendor_is_a_finding() {
500        let c = catalogue(
501            "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: api\n    adapter: a-api\n    vendor: acme\n    model: other-large\n    capabilities: [code_review]\n",
502        );
503        let refs = References {
504            providers: vec![],
505            models: crate::metadata::yaml::parse_into(
506                "version: 1\nvendors:\n  - id: acme\n    title: Acme\n    credential_env: ACME_KEY\n  - id: other\n    title: Other\nmodels:\n  - id: other-large\n    vendor: other\n    native_id: other-large-1\n    context_window: 1000\n    capabilities: [text]\n",
507            )
508            .unwrap(),
509        };
510        let found = c.diagnostics(&refs);
511        assert_eq!(found.len(), 1, "{found:?}");
512        assert!(found[0].contains("of vendor 'other'"));
513    }
514}