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}