Skip to main content

majordomus_cli/capability/builtin/
devcontext.rs

1//! The `devcontext` module: the context compiler as a capability.
2//!
3//! Three questions, answered from the index and the composed graph this process already
4//! holds: what context does this piece of work need, why is any one thing in or out of that
5//! answer, and what are the compiler's own rules. The MCP tools, the resource, the HTTP
6//! routes, the OpenAPI operations, the command line and the Cockpit's pages are projections
7//! of these three declarations; none of them holds a selection rule of its own, and there is
8//! no Cockpit-specific implementation to hold one in.
9//!
10//! The work is [`crate::devcontext`]; this file is only the declaration. That split is
11//! deliberate: the compiler is a domain model with its own tests, and a capability module
12//! that carried the model would make the registry the place selection rules live.
13//!
14//! # Why `compile` is cached for the life of the process
15//!
16//! The answer is a pure function of the index and the request, and the executor's cache key
17//! is the canonical id, the input in canonical form and *the registry fingerprint* — which
18//! is a sha-256 over the index fingerprint, itself over every object's path and content
19//! (ADR 0004). So a cached answer is invalidated by an actual change to the repository
20//! rather than by a clock, and a time-to-live would only throw away answers that are still
21//! true. Every answer also carries the fingerprint it was compiled from, so a caller can
22//! tell two trees apart without asking a second question.
23
24use std::borrow::Cow;
25
26use schemars::{JsonSchema, Schema, SchemaGenerator};
27use serde::{Deserialize, Serialize};
28
29use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
30use crate::capability::handler::{CapabilityError, Context};
31use crate::capability::model::{
32    CachePolicy, CliExposure, Exposure, McpExposure, McpResource, Stability,
33};
34use crate::capability::module::ModuleDescriptor;
35use crate::devcontext::{self, CompiledContext, CompilerPolicy, Explanation};
36use crate::{capability, module};
37
38use super::{get, mcp, Empty};
39
40/// The URI under which the compiler's own rules are read as an MCP resource.
41pub const DEVCONTEXT_POLICY_URI: &str = "majordomus://devcontext/policy";
42
43/// The input of `devcontext.compile`: what to compile a context about.
44///
45/// A newtype over [`devcontext::CompileInput`] so that the schema component the OpenAPI
46/// document carries is named for the operation rather than for the domain type, the way
47/// every other module's input is.
48///
49/// Its schema is the wrapped type's own object under this type's name, never a reference
50/// to it: a tool's input schema is an object at its root, and a client that reads a bare
51/// `$ref` there offers none of the server's tools.
52#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
53#[serde(transparent)]
54pub struct CompileInput(pub devcontext::CompileInput);
55
56impl JsonSchema for CompileInput {
57    fn schema_name() -> Cow<'static, str> {
58        "DevContextInput".into()
59    }
60
61    fn schema_id() -> Cow<'static, str> {
62        concat!(module_path!(), "::DevContextInput").into()
63    }
64
65    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
66        named_object::<devcontext::CompileInput>(
67            generator,
68            "The input of `devcontext.compile`: what to compile a context about.",
69        )
70    }
71}
72
73impl BenchmarkCases for CompileInput {
74    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
75        devcontext::CompileInput::benchmark_cases(ctx)
76            .into_iter()
77            .map(|c| NamedCase {
78                name: c.name,
79                input: CompileInput(c.input),
80            })
81            .collect()
82    }
83}
84
85/// The input of `devcontext.explain`: one identifier, and the request to judge it under.
86///
87/// Its schema is the wrapped type's own object under this type's name, for the reason
88/// [`CompileInput`] gives.
89#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
90#[serde(transparent)]
91pub struct ExplainInput(pub devcontext::ExplainInput);
92
93impl JsonSchema for ExplainInput {
94    fn schema_name() -> Cow<'static, str> {
95        "DevContextExplainInput".into()
96    }
97
98    fn schema_id() -> Cow<'static, str> {
99        concat!(module_path!(), "::DevContextExplainInput").into()
100    }
101
102    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
103        named_object::<devcontext::ExplainInput>(
104            generator,
105            "The input of `devcontext.explain`: one identifier, and the request to judge it under.",
106        )
107    }
108}
109
110/// The schema of `T` itself, described as the operation's input: what a newtype over `T`
111/// publishes, so that its root is `T`'s object and not a reference to `T`.
112fn named_object<T: JsonSchema>(generator: &mut SchemaGenerator, description: &str) -> Schema {
113    let mut schema = T::json_schema(generator);
114    schema.insert("description".into(), description.into());
115    schema
116}
117
118impl BenchmarkCases for ExplainInput {
119    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
120        devcontext::ExplainInput::benchmark_cases(ctx)
121            .into_iter()
122            .map(|c| NamedCase {
123                name: c.name,
124                input: ExplainInput(c.input),
125            })
126            .collect()
127    }
128}
129
130fn compile(ctx: &Context, input: CompileInput) -> Result<CompiledContext, CapabilityError> {
131    devcontext::compile(ctx, input.0)
132}
133
134fn explain(ctx: &Context, input: ExplainInput) -> Result<Explanation, CapabilityError> {
135    devcontext::explain(ctx, input.0)
136}
137
138fn policy(ctx: &Context, _: Empty) -> Result<CompilerPolicy, CapabilityError> {
139    devcontext::policy(ctx)
140}
141
142/// The module.
143pub fn module() -> ModuleDescriptor {
144    module! {
145        id: "devcontext",
146        title: "Development context",
147        description: "The context a development session should be given, derived from the repository rather than composed by hand: given an issue, a milestone, an intent or a set of paths, the canonical objects that bear on the work, why each one is in the answer, what was left out and why, what collapsed into what, and the whole cost against a budget. The selection is structured — every entry keeps its identifier, its provenance, the selector that reached it and its confidence — because rendering a prompt is a projection of the selection and not the selection itself.",
148        stability: Stability::BehaviorallyVerified,
149        capabilities: [
150            capability! {
151                id: "devcontext.compile",
152                title: "Compile the context for a piece of work",
153                description: "The objects a session working on this issue, milestone, intent or set of paths should be given: each with its canonical identifier, the index's own provenance, every discovery path that reached it with the reason and the confidence, its version, its tier and its cost in estimated tokens — followed by everything reached and not given with the reason for each, everything that collapsed into one entry, every pair that does not agree, and the per-tier spend against the budget. Deterministic for a given tree and request; the index fingerprint it was compiled from is in the answer.",
154                input: CompileInput,
155                output: CompiledContext,
156                stability: Stability::BehaviorallyVerified,
157                exposure: Exposure {
158                    mcp: mcp("majordomus_devcontext"),
159                    http: get("/api/v1/devcontext"),
160                    cli: Some(CliExposure { path: vec!["devcontext".into(), "compile".into()] }),
161                },
162                tags: ["context", "session", "planning", "introspection"],
163                cache: CachePolicy::Process { max_entries: 16, ttl_seconds: None },
164                handler: compile,
165            },
166            capability! {
167                id: "devcontext.explain",
168                title: "Why one thing is or is not in a compiled context",
169                description: "One canonical identifier judged under a request: whether it was selected, reached and excluded, folded into another identifier, held by the index and never reached, or unknown — with the entry, the exclusion or the collapse itself, and the budget the judgement was made under so that `excluded for budget` can be acted on without a second call.",
170                input: ExplainInput,
171                output: Explanation,
172                stability: Stability::BehaviorallyVerified,
173                exposure: Exposure {
174                    mcp: mcp("majordomus_devcontext_explain"),
175                    http: get("/api/v1/devcontext/explain"),
176                    cli: Some(CliExposure { path: vec!["devcontext".into(), "explain".into()] }),
177                },
178                tags: ["context", "provenance", "introspection"],
179                cache: CachePolicy::Process { max_entries: 8, ttl_seconds: None },
180                handler: explain,
181            },
182            capability! {
183                id: "devcontext.policy",
184                title: "The compiler's own rules",
185                description: "What the compiler decides and how: the tiers in the order the budget spends in with the kinds that land in each, every edge kind the composed graph declares with the relevance multiplier the compiler follows it by in each direction — or the reason it refuses to follow it at all — the selectors with which of them infer rather than read, and the defaults for the budget, the depth and the relevance floor. Read this rather than inferring the policy from an answer.",
186                input: Empty,
187                output: CompilerPolicy,
188                stability: Stability::BehaviorallyVerified,
189                exposure: Exposure {
190                    mcp: Some(McpExposure {
191                        tool: Some("majordomus_devcontext_policy".into()),
192                        resource: Some(McpResource { uri: DEVCONTEXT_POLICY_URI.into(), name: "devcontext-policy".into() }),
193                    }),
194                    http: get("/api/v1/devcontext/policy"),
195                    cli: Some(CliExposure { path: vec!["devcontext".into(), "policy".into()] }),
196                },
197                tags: ["context", "introspection"],
198                handler: policy,
199            },
200        ],
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    /// A newtype's derived schema is a reference to the type it wraps. These two publish
209    /// the wrapped object itself, under the operation's name, and the wire shape — serde's
210    /// transparent one — is the wrapped type's.
211    #[test]
212    fn an_input_publishes_the_object_it_wraps_and_not_a_reference_to_it() {
213        let compile = schemars::schema_for!(CompileInput).to_value();
214        assert_eq!(compile["type"], "object", "{compile}");
215        assert_eq!(compile["title"], "DevContextInput");
216        assert!(compile.get("$ref").is_none(), "{compile}");
217        assert_eq!(
218            compile["description"],
219            "The input of `devcontext.compile`: what to compile a context about."
220        );
221        let wrapped = schemars::schema_for!(devcontext::CompileInput).to_value();
222        assert_eq!(compile["properties"], wrapped["properties"]);
223        assert_eq!(compile["required"], wrapped["required"]);
224
225        let explain = schemars::schema_for!(ExplainInput).to_value();
226        assert_eq!(explain["type"], "object", "{explain}");
227        assert_eq!(explain["title"], "DevContextExplainInput");
228        assert!(explain.get("$ref").is_none(), "{explain}");
229        assert!(explain["properties"]["uri"].is_object(), "{explain}");
230
231        // two names, two identities: a generator that met both keeps both
232        assert_ne!(CompileInput::schema_id(), ExplainInput::schema_id());
233        assert_ne!(
234            CompileInput::schema_id(),
235            devcontext::CompileInput::schema_id()
236        );
237
238        assert_eq!(
239            serde_json::to_value(CompileInput::default()).unwrap(),
240            serde_json::to_value(devcontext::CompileInput::default()).unwrap()
241        );
242    }
243
244    #[test]
245    fn the_declaration_yields_the_projections_it_claims() {
246        let m = module();
247        assert_eq!(m.id.as_str(), "devcontext");
248        let expected: &[(&str, &str, &str, &[&str])] = &[
249            (
250                "devcontext.compile",
251                "majordomus_devcontext",
252                "/api/v1/devcontext",
253                &["devcontext", "compile"],
254            ),
255            (
256                "devcontext.explain",
257                "majordomus_devcontext_explain",
258                "/api/v1/devcontext/explain",
259                &["devcontext", "explain"],
260            ),
261            (
262                "devcontext.policy",
263                "majordomus_devcontext_policy",
264                "/api/v1/devcontext/policy",
265                &["devcontext", "policy"],
266            ),
267        ];
268        let ids: Vec<&str> = m
269            .capabilities
270            .iter()
271            .map(|e| e.capability.id.as_str())
272            .collect();
273        let want: Vec<&str> = expected.iter().map(|(id, _, _, _)| *id).collect();
274        assert_eq!(ids, want);
275        for (executable, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
276            let exposure = &executable.capability.exposure;
277            assert_eq!(
278                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
279                Some(*tool),
280                "{id} lost its MCP tool"
281            );
282            assert_eq!(
283                exposure.http.as_ref().map(|h| h.path.as_str()),
284                Some(*path),
285                "{id} lost its HTTP route"
286            );
287            assert_eq!(
288                exposure
289                    .cli
290                    .as_ref()
291                    .map(|c| c.path.iter().map(String::as_str).collect::<Vec<_>>())
292                    .as_deref(),
293                Some(*cli),
294                "{id} lost its command line"
295            );
296        }
297        // the compiler's rules are readable as a resource, because they need no input
298        let resource = m.capabilities[2]
299            .capability
300            .exposure
301            .mcp
302            .as_ref()
303            .and_then(|m| m.resource.as_ref())
304            .expect("the policy is readable as a resource");
305        assert_eq!(resource.uri, DEVCONTEXT_POLICY_URI);
306    }
307
308    #[test]
309    fn the_answer_is_cached_against_the_tree_and_not_against_a_clock() {
310        let m = module();
311        for e in &m.capabilities {
312            match e.capability.cache {
313                CachePolicy::Process { ttl_seconds, .. } => assert_eq!(
314                    ttl_seconds, None,
315                    "{} expires by time; the cache key already carries the index fingerprint",
316                    e.capability.id
317                ),
318                CachePolicy::Disabled
319                | CachePolicy::Generation { .. }
320                | CachePolicy::Checkout { .. } => {}
321            }
322        }
323    }
324
325    #[test]
326    fn every_capability_has_a_benchmark_case_over_this_repository() {
327        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
328        let index = repo.index().expect("an index");
329        let ctx = CaseContext { index: &index };
330        assert!(!CompileInput::benchmark_cases(&ctx).is_empty());
331        assert!(!ExplainInput::benchmark_cases(&ctx).is_empty());
332        assert!(!Empty::benchmark_cases(&ctx).is_empty());
333    }
334}