1use 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
40pub const DEVCONTEXT_POLICY_URI: &str = "majordomus://devcontext/policy";
42
43#[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#[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
110fn 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
142pub 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 #[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 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 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}