Skip to main content

majordomus_cli/capability/builtin/
plan.rs

1//! The `plan` module: the milestone and issue model's *derivations*, projected.
2//!
3//! The records themselves have always been served — `majordomus://issue/I0001` returns the
4//! file through `objects.get` — and that is the least interesting half of the model. What
5//! makes it worth having is everything nobody authored: which issues are READY, which are
6//! BLOCKED and on what, the topological waves the graph allows to run at once, the roadmap
7//! order the milestone graph implies, the milestone a worker is on and the one issue to
8//! take next. Until this module those lived only in `lib/plan.sh`, so an agent asking the
9//! shared MCP server what to work on could be handed 184 documents and no answer.
10//!
11//! Every capability here reads [`crate::plan::Plan`], built on the call out of records the
12//! index already holds. Nothing is cached: the plan changes under the process — a
13//! transition writes a lifecycle marker into a record between two calls — and a `next`
14//! that answered from a snapshot would send two workers to one issue.
15//!
16//! One capability here writes: `plan.transition` moves an issue through `start`, `verify`
17//! and `done`. It is the first development operation of the runtime, and it is here because
18//! [ADR 0040] decided that development semantics are capabilities of this registry and every
19//! surface a consumer of them — not because the registry's read-only habit was an accident.
20//! Until it landed, an agent could be told what to work on and had no way to say it had
21//! started; the derivation and the transition were in two programs, and only one of them was
22//! reachable from MCP, HTTP or the Cockpit.
23//!
24//! What it does not do is own the semantics. The guards are [`crate::plan::check`], the
25//! resulting status is computed by the one `derive` every reader uses, and the record it
26//! writes is the same `.ai/repo/project/issues/<id>.yaml` `lib/plan.sh` writes, to the byte.
27//! `plan evidence` stays a command-line operation for now: it appends a block rather than
28//! setting a field, and it is the next transition to converge, not this one.
29//!
30//! [ADR 0040]: https://github.com/korczis/prismatic-majordomus/blob/master/.ai/repo/adrs/0040-development-semantics-are-capabilities-of-one-runtime.md
31
32use std::path::Path;
33
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36
37use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
38use crate::capability::handler::{CapabilityError, Context};
39use crate::capability::model::CapabilityKind;
40use crate::capability::model::{
41    BenchmarkPolicy, BenchmarkPrecondition, CachePolicy, Exposure, McpExposure, McpResource,
42    Stability,
43};
44use crate::capability::module::ModuleDescriptor;
45use crate::plan::{
46    Plan, PlanCounts, PlanFinding, PlanIssue, PlanMilestone, PlanProject, PlanVocabulary, PlanWave,
47    Transition, TransitionError,
48};
49use crate::{capability, module};
50
51use super::{get, mcp, post, Empty};
52
53/// The URI under which the whole derived plan is read as an MCP resource.
54pub const PLAN_URI: &str = "majordomus://plan";
55
56// ---------------------------------------------------------------- the plan, from a context
57
58/// The derived plan of the repository this process serves. Built on the call: the plan
59/// changes under the process.
60///
61/// A repository with no `.ai/repo/project/` answers with an empty plan rather than a
62/// refusal. The shell tool exits 12 there because it is telling a person where the model
63/// would live; a projection is answering what this repository holds, and "no milestones and
64/// no issues" is that answer. Only a named record that does not exist is a `not found`.
65fn plan_of(ctx: &Context) -> Result<Plan, CapabilityError> {
66    Ok(Plan::build(&ctx.index))
67}
68
69// ---------------------------------------------------------------- inputs
70
71/// The milestone the benchmark cases name, declared once because three case sets need it.
72///
73/// A repository with no plan still has to be timed on these capabilities — the coverage
74/// gate counts targets, not repositories — and its OpenAPI operation still has to show an
75/// example of every parameter it declares, because the examples *are* the cases and case
76/// 92 refuses a parameter no case ever sets. So the fallback names a milestone that exists
77/// nowhere: the empty answer is the operation being measured there, and it is the answer a
78/// caller filtering on a milestone that does not exist gets.
79fn case_milestone(plan: &Plan) -> String {
80    plan.milestones
81        .first()
82        .map_or_else(|| "M000".to_string(), |m| m.id.clone())
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
86#[serde(deny_unknown_fields)]
87/// Which issues to answer with.
88pub struct PlanIssueFilter {
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    /// Only issues of this milestone. Default: every issue of the plan.
91    pub milestone: Option<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    /// Only issues in this derived status — `READY` for the ready set, `BLOCKED` for the
94    /// blocked set. The vocabulary travels with every answer, so a caller never has to know
95    /// which statuses exist.
96    pub status: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    /// Only issues in this execution wave.
99    pub wave: Option<u32>,
100}
101
102impl BenchmarkCases for PlanIssueFilter {
103    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
104        vec![
105            NamedCase::new("all", PlanIssueFilter::default()),
106            NamedCase::new(
107                "ready",
108                PlanIssueFilter {
109                    status: Some("READY".into()),
110                    ..PlanIssueFilter::default()
111                },
112            ),
113            NamedCase::new(
114                "one-milestone",
115                PlanIssueFilter {
116                    milestone: Some(case_milestone(&Plan::build(ctx.index))),
117                    ..PlanIssueFilter::default()
118                },
119            ),
120        ]
121    }
122}
123
124#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126/// Which part of the plan to answer about.
127pub struct PlanMilestoneFilter {
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    /// Restrict the answer to one milestone. Default: the whole plan, and for `next` the
130    /// active milestone with the rest of the plan as the fallback.
131    pub milestone: Option<String>,
132}
133
134impl BenchmarkCases for PlanMilestoneFilter {
135    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
136        vec![
137            NamedCase::new("whole-plan", PlanMilestoneFilter::default()),
138            NamedCase::new(
139                "one-milestone",
140                PlanMilestoneFilter {
141                    milestone: Some(case_milestone(&Plan::build(ctx.index))),
142                },
143            ),
144        ]
145    }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
149#[serde(deny_unknown_fields)]
150/// One record of the plan.
151pub struct PlanRecordInput {
152    /// The id of a milestone or an issue, as its file is named (`I0001`, `work-graph-github`).
153    pub id: String,
154}
155
156impl BenchmarkCases for PlanRecordInput {
157    /// The fallback of `case_milestone`, for the issue half too: a repository with no
158    /// plan still has to be timed on this capability, and the refusal is the operation
159    /// being measured there.
160    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
161        let plan = Plan::build(ctx.index);
162        vec![
163            NamedCase::new(
164                "issue",
165                PlanRecordInput {
166                    id: plan
167                        .issues
168                        .first()
169                        .map_or_else(|| "I0001".to_string(), |i| i.id.clone()),
170                },
171            ),
172            NamedCase::new(
173                "milestone",
174                PlanRecordInput {
175                    id: case_milestone(&plan),
176                },
177            ),
178        ]
179    }
180}
181
182// ---------------------------------------------------------------- outputs
183
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
185/// Issues matching a filter, with the vocabulary that names their statuses.
186pub struct PlanIssueList {
187    /// The matching issues, in id order.
188    pub issues: Vec<PlanIssue>,
189    /// How many matched.
190    pub total: usize,
191    /// The declared status vocabularies.
192    pub statuses: PlanVocabulary,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
196/// One milestone's progress, without its prose.
197pub struct PlanMilestoneProgress {
198    /// The identity.
199    pub id: String,
200    /// The derived status.
201    pub status: String,
202    /// One line naming the outcome.
203    pub title: String,
204    /// Its issues, counted by derived status.
205    pub counts: PlanCounts,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
209/// Where the plan stands: every milestone's progress, the milestone being executed, and the
210/// one issue to take next.
211pub struct PlanStatusReport {
212    /// The plan's header, with the active milestone derived.
213    pub project: PlanProject,
214    /// The declared status vocabularies.
215    pub statuses: PlanVocabulary,
216    /// Every milestone, in id order.
217    pub milestones: Vec<PlanMilestoneProgress>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    /// The next ready issue, when there is one.
220    pub next_ready: Option<PlanIssue>,
221    /// Every issue of the plan, counted by derived status.
222    pub counts: PlanCounts,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
226/// The execution waves, with the overlaps that serialise issues the graph would let run
227/// together.
228pub struct PlanWaveReport {
229    /// The waves, lowest first, with the issues of each.
230    pub waves: Vec<PlanWaveView>,
231    /// Scope overlaps between two issues of one wave. Two issues sharing a wave is a
232    /// necessary condition for running them at once, not a sufficient one: overlapping
233    /// scope serialises them, and the overlap is reported here rather than left for two
234    /// workers to discover in a conflict.
235    pub serialised_by_scope: Vec<PlanFinding>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
239/// One wave, with the issues in it.
240pub struct PlanWaveView {
241    /// The layer, from zero.
242    pub wave: u32,
243    /// The issues in it, in id order.
244    pub issues: Vec<PlanIssue>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
248/// The one issue a worker should take now.
249pub struct PlanNextIssue {
250    #[serde(skip_serializing_if = "Option::is_none")]
251    /// The issue, when the plan has one that is executable.
252    pub issue: Option<PlanIssue>,
253    /// The milestone the search started in.
254    pub active_milestone: String,
255    /// Why there is none, when there is none: what to run to see what is in the way.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub reason: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
261/// The milestones in derived order, with the one being executed and the one after it.
262pub struct PlanRoadmap {
263    /// The milestones ordered by rank, then order, then id. The sequence is derived from
264    /// the milestone graph; no list of versions is maintained anywhere.
265    pub milestones: Vec<PlanMilestone>,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    /// The first unfinished, unblocked milestone in that sequence.
268    pub now: Option<String>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    /// The one after it, blocked or not.
271    pub next: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
275/// The model's own validation: what the graph refuses and what it merely warns about.
276pub struct PlanValidation {
277    /// Whether the model is valid: no finding is a failure.
278    pub valid: bool,
279    /// How many milestones the plan holds.
280    pub milestones: usize,
281    /// How many issues.
282    pub issues: usize,
283    /// How many findings are failures.
284    pub failures: usize,
285    /// How many are warnings.
286    pub warnings: usize,
287    /// Every finding, in derivation order.
288    pub findings: Vec<PlanFinding>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
292/// One record of the plan, milestone or issue, with everything derived about it.
293#[serde(untagged)]
294pub enum PlanRecord {
295    /// A milestone.
296    PlanMilestone {
297        /// The milestone, with its counts, its rank and both directions of its graph.
298        milestone: Box<PlanMilestone>,
299        /// Its issues in full, in id order.
300        issues: Vec<PlanIssue>,
301    },
302    /// An issue.
303    PlanIssue {
304        /// The issue, with its status, its wave and both directions of its graph.
305        issue: Box<PlanIssue>,
306        /// The issues it waits on, in full.
307        depends_on: Vec<PlanIssue>,
308    },
309}
310
311// ---------------------------------------------------------------- handlers
312
313fn plan_model(ctx: &Context, _: Empty) -> Result<Plan, CapabilityError> {
314    plan_of(ctx)
315}
316
317fn plan_status(
318    ctx: &Context,
319    input: PlanMilestoneFilter,
320) -> Result<PlanStatusReport, CapabilityError> {
321    let plan = plan_of(ctx)?;
322    let only = input.milestone.as_deref();
323    if let Some(m) = only {
324        if plan.milestone(m).is_none() {
325            return Err(CapabilityError::NotFound(format!("no milestone '{m}'")));
326        }
327    }
328    let mut counts = PlanCounts {
329        total: 0,
330        required: 0,
331        by_status: plan.statuses.issue.iter().map(|s| (s.clone(), 0)).collect(),
332    };
333    for i in &plan.issues {
334        if only.is_some_and(|m| i.milestone != m) {
335            continue;
336        }
337        counts.total += 1;
338        *counts.by_status.entry(i.status.clone()).or_insert(0) += 1;
339    }
340    counts.required = counts.total - counts.by_status.get("CANCELLED").copied().unwrap_or(0);
341    Ok(PlanStatusReport {
342        milestones: plan
343            .milestones
344            .iter()
345            .filter(|m| only.is_none_or(|only| m.id == only))
346            .map(|m| PlanMilestoneProgress {
347                id: m.id.clone(),
348                status: m.status.clone(),
349                title: m.title.clone(),
350                counts: m.counts.clone(),
351            })
352            .collect(),
353        next_ready: plan.next_ready(only).cloned(),
354        project: plan.project.clone(),
355        statuses: plan.statuses.clone(),
356        counts,
357    })
358}
359
360fn plan_issues(ctx: &Context, input: PlanIssueFilter) -> Result<PlanIssueList, CapabilityError> {
361    let plan = plan_of(ctx)?;
362    if let Some(s) = input.status.as_deref() {
363        if !plan.statuses.issue.iter().any(|v| v == s) {
364            return Err(CapabilityError::InvalidInput(format!(
365                "'{s}' is not an issue status; the vocabulary is {}",
366                plan.statuses.issue.join(", ")
367            )));
368        }
369    }
370    let issues: Vec<PlanIssue> = plan
371        .issues
372        .iter()
373        .filter(|i| input.milestone.as_deref().is_none_or(|m| i.milestone == m))
374        .filter(|i| input.status.as_deref().is_none_or(|s| i.status == s))
375        .filter(|i| input.wave.is_none_or(|w| i.wave == w))
376        .cloned()
377        .collect();
378    Ok(PlanIssueList {
379        total: issues.len(),
380        issues,
381        statuses: plan.statuses,
382    })
383}
384
385fn plan_waves(
386    ctx: &Context,
387    input: PlanMilestoneFilter,
388) -> Result<PlanWaveReport, CapabilityError> {
389    let plan = plan_of(ctx)?;
390    let only = input.milestone.as_deref();
391    let view = |w: &PlanWave| PlanWaveView {
392        wave: w.wave,
393        issues: plan
394            .issues
395            .iter()
396            .filter(|i| w.issues.contains(&i.id))
397            .filter(|i| only.is_none_or(|m| i.milestone == m))
398            .cloned()
399            .collect(),
400    };
401    Ok(PlanWaveReport {
402        waves: plan
403            .waves
404            .iter()
405            .map(view)
406            .filter(|w| !w.issues.is_empty())
407            .collect(),
408        serialised_by_scope: plan
409            .findings
410            .iter()
411            .filter(|f| f.code == "scope_conflict")
412            .cloned()
413            .collect(),
414    })
415}
416
417fn plan_next(ctx: &Context, input: PlanMilestoneFilter) -> Result<PlanNextIssue, CapabilityError> {
418    let plan = plan_of(ctx)?;
419    let issue = plan.next_ready(input.milestone.as_deref()).cloned();
420    // "nothing is ready" and "there is nothing" are different answers: a repository whose
421    // plan declares no issue was told that every one of its issues was waiting
422    let declared = plan
423        .issues
424        .iter()
425        .any(|i| input.milestone.as_deref().is_none_or(|m| i.milestone == m));
426    Ok(PlanNextIssue {
427        reason: issue.is_none().then(|| {
428            if declared {
429                "no issue is READY; every one of them waits on a dependency or on its milestone's gate"
430            } else {
431                "the plan declares no issue to hand out"
432            }
433            .to_string()
434        }),
435        active_milestone: input
436            .milestone
437            .unwrap_or_else(|| plan.project.active_milestone.clone()),
438        issue,
439    })
440}
441
442fn plan_roadmap(ctx: &Context, _: Empty) -> Result<PlanRoadmap, CapabilityError> {
443    let plan = plan_of(ctx)?;
444    let ordered: Vec<PlanMilestone> = plan.roadmap().into_iter().cloned().collect();
445    let mut open = ordered
446        .iter()
447        .filter(|m| !matches!(m.status.as_str(), "DONE" | "CANCELLED" | "SUPERSEDED"));
448    let now = open
449        .next()
450        .filter(|m| m.blocked_by.is_empty())
451        .map(|m| m.id.clone());
452    let next = if now.is_some() {
453        open.next().map(|m| m.id.clone())
454    } else {
455        // The first open milestone is itself blocked: it is not "now", it is "next".
456        ordered
457            .iter()
458            .find(|m| !matches!(m.status.as_str(), "DONE" | "CANCELLED" | "SUPERSEDED"))
459            .map(|m| m.id.clone())
460    };
461    Ok(PlanRoadmap {
462        milestones: ordered,
463        now,
464        next,
465    })
466}
467
468fn plan_validate(ctx: &Context, _: Empty) -> Result<PlanValidation, CapabilityError> {
469    let plan = plan_of(ctx)?;
470    Ok(PlanValidation {
471        valid: plan.failures() == 0,
472        milestones: plan.milestones.len(),
473        issues: plan.issues.len(),
474        failures: plan.failures(),
475        warnings: plan.warnings(),
476        findings: plan.findings,
477    })
478}
479
480fn plan_record(ctx: &Context, input: PlanRecordInput) -> Result<PlanRecord, CapabilityError> {
481    let plan = plan_of(ctx)?;
482    if let Some(m) = plan.milestone(&input.id) {
483        let issues = plan
484            .issues
485            .iter()
486            .filter(|i| i.milestone == m.id)
487            .cloned()
488            .collect();
489        return Ok(PlanRecord::PlanMilestone {
490            milestone: Box::new(m.clone()),
491            issues,
492        });
493    }
494    if let Some(i) = plan.issue(&input.id) {
495        let depends_on = i
496            .depends_on
497            .iter()
498            .filter_map(|d| plan.issue(d))
499            .cloned()
500            .collect();
501        return Ok(PlanRecord::PlanIssue {
502            issue: Box::new(i.clone()),
503            depends_on,
504        });
505    }
506    Err(CapabilityError::NotFound(format!(
507        "no milestone or issue '{}'",
508        input.id
509    )))
510}
511
512// ---------------------------------------------------------------- the module
513
514/// The `plan` module: the derivations of the milestone and issue model, projected once.
515pub fn module() -> ModuleDescriptor {
516    module! {
517        id: "plan",
518        title: "The plan and its derivations",
519        description: "The milestone and issue model of this repository, and everything derived from it that nobody authored: the status of each record, the dependency graphs above and below the milestone boundary, the topological execution waves, the roadmap order, the milestone being executed and the one issue to take next. Status is never stored — a record says what happened to it and the status follows from that and from the state of its dependencies — so no file can contradict the graph. This capability reads; writing a lifecycle marker into a record is `plan.transition`, which declares that it writes the repository.",
520        stability: Stability::BehaviorallyVerified,
521        capabilities: [
522            capability! {
523                id: "plan.model",
524                title: "The whole derived plan",
525                description: "Every milestone and issue with its derived status, wave, rank, both directions of its graph and its counts; the execution waves; both dependency graphs as edges; every validation finding; and the plan's header with the active milestone derived. The one value every other capability of this module answers out of. Derived on every call: a transition writes a lifecycle marker into a record between two calls, and a plan answered from a snapshot would send two workers to one issue.",
526                input: Empty,
527                output: Plan,
528                stability: Stability::BehaviorallyVerified,
529                exposure: Exposure {
530                    mcp: Some(McpExposure {
531                        tool: Some("majordomus_plan".into()),
532                        resource: Some(McpResource { uri: PLAN_URI.into(), name: "plan".into() }),
533                    }),
534                    http: get("/api/v1/plan"),
535                    cli: None,
536                },
537                tags: ["plan", "project", "graph", "introspection"],
538                cache: CachePolicy::Disabled,
539                handler: plan_model,
540            },
541            capability! {
542                id: "plan.status",
543                title: "Where the plan stands",
544                description: "Every milestone with its derived status and its issues counted by status, the milestone a worker is executing now, the next ready issue in full, and the plan's own totals. The counts are keyed by the declared vocabulary, which travels with the answer, so a status added to the engine appears here without anything being edited.",
545                input: PlanMilestoneFilter,
546                output: PlanStatusReport,
547                stability: Stability::BehaviorallyVerified,
548                exposure: Exposure {
549                    mcp: mcp("majordomus_plan_status"),
550                    http: get("/api/v1/plan/status"),
551                    cli: None,
552                },
553                tags: ["plan", "project", "status"],
554                cache: CachePolicy::Generation { max_entries: 16 },
555                handler: plan_status,
556            },
557            capability! {
558                id: "plan.issues",
559                title: "The issues, filtered by what the graph derived",
560                description: "One record per issue with its derived status, its wave, the dependencies it declares, the ones that are not DONE (plus `milestone:<id>` when the gate holds the whole outcome back), the issues that depend on it, the paths it touches and its evidence tally. Filtering by `status: READY` is the ready set and by `status: BLOCKED` the blocked set; nothing here is a separate derivation.",
561                input: PlanIssueFilter,
562                output: PlanIssueList,
563                stability: Stability::BehaviorallyVerified,
564                exposure: Exposure {
565                    mcp: mcp("majordomus_plan_issues"),
566                    http: get("/api/v1/plan/issues"),
567                    cli: None,
568                },
569                tags: ["plan", "project", "issues"],
570                cache: CachePolicy::Generation { max_entries: 16 },
571                handler: plan_issues,
572            },
573            capability! {
574                id: "plan.waves",
575                title: "What may run at the same time",
576                description: "The topological layering of the issue graph: an issue enters a wave only once every dependency has left it, so its wave is one past the longest path to it. Sharing a wave is a necessary condition for running two issues at once, not a sufficient one — overlapping scope serialises them, and every such overlap is reported beside the waves rather than left for two workers to discover in a merge conflict.",
577                input: PlanMilestoneFilter,
578                output: PlanWaveReport,
579                stability: Stability::BehaviorallyVerified,
580                exposure: Exposure {
581                    mcp: mcp("majordomus_plan_waves"),
582                    http: get("/api/v1/plan/waves"),
583                    cli: None,
584                },
585                tags: ["plan", "project", "graph", "waves"],
586                cache: CachePolicy::Disabled,
587                handler: plan_waves,
588            },
589            capability! {
590                id: "plan.next",
591                title: "The one issue to take now",
592                description: "The lowest-wave READY issue of the active milestone, highest priority first, then id. The active milestone can have nothing ready while another one does — one waiting on its own acceptance evidence, for instance — so the search widens to the whole plan rather than answering `none` and sending a worker away from work that is genuinely executable. This is what an agent asks before it starts.",
593                input: PlanMilestoneFilter,
594                output: PlanNextIssue,
595                stability: Stability::BehaviorallyVerified,
596                exposure: Exposure {
597                    mcp: mcp("majordomus_plan_next"),
598                    http: get("/api/v1/plan/next"),
599                    cli: None,
600                },
601                tags: ["plan", "project", "next"],
602                cache: CachePolicy::Generation { max_entries: 16 },
603                handler: plan_next,
604            },
605            capability! {
606                id: "plan.roadmap",
607                title: "The milestones in derived order",
608                description: "The milestone graph laid out by rank, with `order` breaking ties inside a rank only, and the first unblocked unfinished milestone as `now` and the one after it as `next`. Nothing in the sequence is authored: a milestone whose prerequisites are not real cannot be nominated, which is what makes `each step is gated by the previous one being real` an invariant rather than a sentence.",
609                input: Empty,
610                output: PlanRoadmap,
611                stability: Stability::BehaviorallyVerified,
612                exposure: Exposure {
613                    mcp: mcp("majordomus_plan_roadmap"),
614                    http: get("/api/v1/plan/roadmap"),
615                    cli: None,
616                },
617                tags: ["plan", "project", "roadmap"],
618                cache: CachePolicy::Generation { max_entries: 4 },
619                handler: plan_roadmap,
620            },
621            capability! {
622                id: "plan.validate",
623                title: "What the model refuses",
624                description: "Every finding the derivation produced, in the order it produced them: a dependency on something that is not an issue, a cycle, an issue executing ahead of its dependencies or of its milestone's gate, an issue with no acceptance criteria, evidence missing under a completion date, a lifecycle stamp its transition did not write (no seal of its plan_* event beside it), a milestone whose graph contradicts itself, two issues of one wave sharing a path. A failure means the model is invalid; a warning means it is legal and worth reading.",
625                input: Empty,
626                output: PlanValidation,
627                stability: Stability::BehaviorallyVerified,
628                exposure: Exposure {
629                    mcp: mcp("majordomus_plan_validate"),
630                    http: get("/api/v1/plan/validate"),
631                    cli: None,
632                },
633                tags: ["plan", "project", "validation"],
634                cache: CachePolicy::Disabled,
635                handler: plan_validate,
636            },
637            capability! {
638                id: "plan.record",
639                title: "One milestone or issue, with everything derived about it",
640                description: "A milestone with its issues in full, or an issue with the issues it waits on in full. The record's own prose stays where it has always been — `majordomus://issue/<id>` returns the file — and this answers what the file cannot say about itself: what its status is, where it sits in the graph, and what is between it and being executable.",
641                input: PlanRecordInput,
642                output: PlanRecord,
643                stability: Stability::BehaviorallyVerified,
644                exposure: Exposure {
645                    mcp: mcp("majordomus_plan_record"),
646                    http: get("/api/v1/plan/record"),
647                    cli: None,
648                },
649                tags: ["plan", "project", "issues", "milestones"],
650                cache: CachePolicy::Disabled,
651                handler: plan_record,
652            },
653            capability! {
654                id: "plan.transition",
655                kind: CapabilityKind::Command,
656                title: "Move one issue through the lifecycle",
657                description: "Record that execution of an issue began (`start`), that implementation is complete with evidence outstanding (`verify`), or that it is finished (`done`). One field of the issue's own record is stamped, the seal of its event is written beside it, and the event is appended to the ledger; a stamp without that seal moves no status. The move is refused when the model says it is illegal — an issue that is not READY cannot start, one that was never ACTIVE cannot be verified, and one that was never started, whose dependencies are unfinished or whose required evidence is absent cannot be done — and the refusal names what is in the way. The status that comes back is derived from the record afterwards, not announced by the move: a `done` whose evidence is missing leaves the issue in VERIFY and says so.",
658                input: PlanTransitionInput,
659                output: PlanTransitionResult,
660                stability: Stability::BehaviorallyVerified,
661                exposure: Exposure {
662                    mcp: mcp("majordomus_plan_transition"),
663                    http: post("/api/v1/plan/transition"),
664                    cli: None,
665                },
666                tags: ["plan", "project", "issues", "lifecycle"],
667                // Every case is a refusal chosen from an issue's own status (see the cases
668                // below), so a plan with no issue has no case to give: there the only answer
669                // is a lookup miss, which times an error path rather than the operation. The
670                // requirement is therefore declared conditional here, where it lives, and a
671                // plan that holds an issue and yields no case is still `missing`.
672                cache: CachePolicy::Disabled,
673                benchmark: BenchmarkPolicy::RequiredWhen {
674                    precondition: BenchmarkPrecondition::PlanHoldsAnIssue,
675                },
676                handler: plan_transition,
677            }
678            // The one thing its kind cannot say: this writes a tracked record. The exposure
679            // ceiling of every surface is derived from the effect, so a transition that
680            // claimed only this process's memory would be projected wherever a peer
681            // announcement is, which is not the same permission.
682            .writes_repository(),
683        ],
684    }
685}
686
687// ---------------------------------------------------------------- plan.transition
688
689#[derive(Debug, Serialize, Deserialize, JsonSchema)]
690#[serde(deny_unknown_fields)]
691/// The input of `plan.transition`: which issue to move, and which way.
692///
693/// Two fields and no third. There is no timestamp — the move is stamped with the moment it
694/// happened, never with one the caller supplies, because a lifecycle a caller can backdate
695/// is not a record of what happened. There is no force or reason field either: a transition
696/// the model refuses is refused, and the way to make it legal is to satisfy the guard rather
697/// than to ask past it.
698///
699/// ```
700/// use majordomus_cli::capability::builtin::plan::PlanTransitionInput;
701/// use majordomus_cli::plan::Transition;
702///
703/// // The wire form an MCP client or an HTTP POST sends.
704/// let input: PlanTransitionInput =
705///     serde_json::from_str(r#"{"issue":"I0001","transition":"done"}"#).unwrap();
706/// assert_eq!(input.issue, "I0001");
707/// assert_eq!(input.transition, Transition::Done);
708/// assert_eq!(input.transition.field(), "completed_at");
709/// assert_eq!(input.transition.event(), "plan_done");
710///
711/// // Unknown fields are refused rather than ignored: a caller that misspells `transition`
712/// // is told so instead of having its move silently dropped.
713/// assert!(serde_json::from_str::<PlanTransitionInput>(
714///     r#"{"issue":"I0001","transition":"done","force":true}"#).is_err());
715/// ```
716pub struct PlanTransitionInput {
717    /// The issue to move, by its id — which is also its file name.
718    pub issue: String,
719    /// Which move: `start`, `verify` or `done`.
720    pub transition: Transition,
721}
722
723#[derive(Debug, Serialize, Deserialize, JsonSchema)]
724/// What one transition did.
725///
726/// `from` and `to` are both derived statuses, read before and after the write. They are
727/// reported rather than assumed because the move does not determine the status on its own:
728/// `done` on a record whose evidence is incomplete leaves it in VERIFY, and the caller is
729/// told that rather than told it succeeded.
730///
731/// ```
732/// use majordomus_cli::capability::builtin::plan::PlanTransitionResult;
733///
734/// // What a `done` looks like when the record's required evidence is not all present: the
735/// // call succeeded, the field was written, and the issue is still in VERIFY. A caller that
736/// // read only the absence of an error would report this as finished; a caller that reads
737/// // `to` knows better, which is why the field is here.
738/// let held: PlanTransitionResult = serde_json::from_str(r#"{
739///     "issue":"I0001","transition":"done","from":"VERIFY","to":"VERIFY",
740///     "field":"completed_at","at":"2026-09-11T12:00:00Z","event":"plan_done"
741/// }"#).unwrap();
742/// assert_eq!(held.to, "VERIFY", "the status is derived from the record, not from the move");
743///
744/// // `next_ready` is absent when the plan has none left to hand out, which is ordinary
745/// // rather than an error, so it is omitted rather than spelled as an empty string.
746/// assert!(held.next_ready.is_none());
747/// ```
748pub struct PlanTransitionResult {
749    /// The issue that moved.
750    pub issue: String,
751    /// The move that was performed.
752    pub transition: Transition,
753    /// The status the issue was in before.
754    pub from: String,
755    /// The status it is in now, derived from the record as written.
756    pub to: String,
757    /// The field that was stamped.
758    pub field: String,
759    /// The timestamp written into it, and into `updated_at`.
760    pub at: String,
761    /// The ledger event appended.
762    pub event: String,
763    /// The issue a worker should take next, derived after the move. Absent when the plan
764    /// has none — which after a `start` is the ordinary case, because the issue just taken
765    /// is no longer READY.
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub next_ready: Option<String>,
768}
769
770/// The benchmark cases of the one capability here that writes.
771///
772/// Every case is a refusal, and that is deliberate rather than a gap. A case is *executed* —
773/// by `majordomus bench` against this repository, and by the HTTP and MCP suites against a
774/// fixture — so a case that moved an issue would change the plan every time the suite ran,
775/// and a measurement whose cost is a changed record is not a measurement. The runner states
776/// the principle that makes this sound: *"a refusal is an answer, and answering is the work
777/// a benchmark exists to measure"*, and only `Internal` is fatal.
778///
779/// The refusal path is also the honest thing to time: it builds the plan, resolves the issue
780/// and evaluates the guard, which is everything the write path does except the two syscalls
781/// at the end.
782///
783/// The refusal must be a *refusal* and not a lookup miss. An earlier version named an issue
784/// that could not exist, which answered 404 rather than 422, and `tests/http_serve.rs` holds
785/// every route to answering its own cases — 200, or 422 for a command that turns a caller
786/// down. Choosing the move from the record keeps the case inside that contract.
787///
788/// So a plan with no issue yields no case, and the handler is not bent to make one: naming
789/// an issue the plan does not have is `NotFound` whether or not the plan holds other issues,
790/// and a 422 that appeared only when the plan is empty would make the same request's answer
791/// depend on records it never named. The descriptor declares the requirement conditional on
792/// `BenchmarkPrecondition::PlanHoldsAnIssue` instead, and coverage reports it inapplicable in
793/// such a repository — and missing, as before, in one whose plan holds an issue.
794impl BenchmarkCases for PlanTransitionInput {
795    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
796        let plan = Plan::build(ctx.index);
797        // The move that this issue's own status refuses. Every issue has one: a READY issue
798        // cannot be verified, and an issue in any other status cannot be started. So the
799        // case is chosen from the record rather than from a status this repository happens
800        // to hold today, and it stays a refusal whatever the plan looks like when it runs.
801        let refused_move = |i: &PlanIssue| {
802            if i.status == "READY" {
803                Transition::Verify
804            } else {
805                Transition::Start
806            }
807        };
808        plan.issues
809            .first()
810            .map(|i| {
811                vec![NamedCase::new(
812                    "refused-by-status",
813                    PlanTransitionInput {
814                        issue: i.id.clone(),
815                        transition: refused_move(i),
816                    },
817                )]
818            })
819            .unwrap_or_default()
820    }
821}
822
823fn plan_transition(
824    ctx: &Context,
825    input: PlanTransitionInput,
826) -> Result<PlanTransitionResult, CapabilityError> {
827    let plan = Plan::build(&ctx.index);
828    let before = plan
829        .issue(&input.issue)
830        .ok_or_else(|| {
831            CapabilityError::NotFound(format!(
832                "no issue '{}'; plan.issues lists every one",
833                input.issue
834            ))
835        })?
836        .status
837        .clone();
838
839    // The record's own file, as the index recorded it. Located here rather than composed
840    // from the id so that there is one answer to where a record lives.
841    let rel = ctx
842        .index
843        .objects
844        .iter()
845        .find(|o| o.kind == crate::plan::ISSUE && o.identity == input.issue)
846        .map(|o| o.provenance.path.clone())
847        .ok_or_else(|| {
848            CapabilityError::NotFound(format!(
849                "issue '{}' is in the plan but no object carries its file",
850                input.issue
851            ))
852        })?;
853
854    let root = Path::new(&ctx.index.repository.root);
855    let events = ctx
856        .index
857        .share
858        .as_ref()
859        .map(|s| s.join("events.yaml"))
860        .ok_or_else(|| {
861            CapabilityError::Refused(
862                "this index was built without a share directory, so the event vocabulary cannot be read; a transition may not write an event it cannot validate".into(),
863            )
864        })?;
865    let vocabulary = crate::ledger::Vocabulary::load(&events)
866        .map_err(|e| CapabilityError::Refused(e.to_string()))?;
867
868    let at = crate::ledger::now();
869    crate::plan::transition(
870        root,
871        &root.join(&rel),
872        &ctx.index,
873        &plan,
874        &input.issue,
875        input.transition,
876        &at,
877        &vocabulary,
878        &ctx.index.repository.git,
879    )
880    .map_err(|e| match e {
881        TransitionError::NoSuchIssue(_) => CapabilityError::NotFound(e.to_string()),
882        TransitionError::Refused(_) => CapabilityError::Refused(e.to_string()),
883        TransitionError::Write { .. } => CapabilityError::Refused(e.to_string()),
884    })?;
885
886    // What the plan says now, through the same derivation every reader uses.
887    let after = Plan::after(&ctx.index, &input.issue, input.transition, &at);
888    Ok(PlanTransitionResult {
889        to: after
890            .issue(&input.issue)
891            .map(|i| i.status.clone())
892            .unwrap_or_default(),
893        next_ready: after.next_ready(None).map(|i| i.id.clone()),
894        issue: input.issue,
895        transition: input.transition,
896        from: before,
897        field: input.transition.field().to_string(),
898        at,
899        event: input.transition.event().to_string(),
900    })
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906
907    /// The declaration is the only place these names exist; every projection derives from
908    /// it. This is the assertion a refactor that dropped an exposure would fail.
909    #[test]
910    fn the_declaration_yields_the_projections_it_claims() {
911        let m = module();
912        assert_eq!(m.id.as_str(), "plan");
913        let expected: &[(&str, &str, &str)] = &[
914            ("plan.model", "majordomus_plan", "/api/v1/plan"),
915            (
916                "plan.status",
917                "majordomus_plan_status",
918                "/api/v1/plan/status",
919            ),
920            (
921                "plan.issues",
922                "majordomus_plan_issues",
923                "/api/v1/plan/issues",
924            ),
925            ("plan.waves", "majordomus_plan_waves", "/api/v1/plan/waves"),
926            ("plan.next", "majordomus_plan_next", "/api/v1/plan/next"),
927            (
928                "plan.roadmap",
929                "majordomus_plan_roadmap",
930                "/api/v1/plan/roadmap",
931            ),
932            (
933                "plan.validate",
934                "majordomus_plan_validate",
935                "/api/v1/plan/validate",
936            ),
937            (
938                "plan.record",
939                "majordomus_plan_record",
940                "/api/v1/plan/record",
941            ),
942            (
943                "plan.transition",
944                "majordomus_plan_transition",
945                "/api/v1/plan/transition",
946            ),
947        ];
948        let ids: Vec<&str> = m
949            .capabilities
950            .iter()
951            .map(|e| e.capability.id.as_str())
952            .collect();
953        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
954        assert_eq!(ids, want);
955        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
956            let exposure = &executable.capability.exposure;
957            assert_eq!(
958                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
959                Some(*tool),
960                "{id} lost or renamed its MCP tool"
961            );
962            assert_eq!(
963                exposure.http.as_ref().map(|h| h.path.as_str()),
964                Some(*path),
965                "{id} lost or renamed its HTTP route"
966            );
967            // a transition changes the plan between two calls; it is a write, and a write
968            // makes the next reading of the repository a new generation, so an answer is
969            // kept, if at all, per generation and never across one
970            assert!(
971                matches!(
972                    executable.capability.cache,
973                    CachePolicy::Disabled | CachePolicy::Generation { .. }
974                ),
975                "{id} may cache only per generation: a transition changes the plan between two calls"
976            );
977        }
978        let resource = m.capabilities[0]
979            .capability
980            .exposure
981            .mcp
982            .as_ref()
983            .and_then(|m| m.resource.as_ref());
984        assert_eq!(resource.map(|r| r.uri.as_str()), Some(PLAN_URI));
985    }
986
987    /// Exactly one capability of the module writes, and it is named here.
988    ///
989    /// This assertion used to read "every capability is a query", which was the registry's
990    /// contract until [ADR 0040] made development semantics capabilities of this runtime. It
991    /// is kept rather than deleted, and inverted rather than loosened: a module that reads
992    /// the plan and one that changes it are different things to depend on, and the next
993    /// capability to gain a write should have to say so here.
994    ///
995    /// [ADR 0040]: https://github.com/korczis/prismatic-majordomus/blob/master/.ai/repo/adrs/0040-development-semantics-are-capabilities-of-one-runtime.md
996    #[test]
997    fn only_the_transition_writes() {
998        let m = module();
999        let writers: Vec<&str> = m
1000            .capabilities
1001            .iter()
1002            .filter(|e| !e.capability.kind.is_read_only())
1003            .map(|e| e.capability.id.as_str())
1004            .collect();
1005        assert_eq!(writers, ["plan.transition"]);
1006    }
1007
1008    /// A transition is an HTTP POST, never a GET.
1009    ///
1010    /// Stated separately because the projection is what a caller meets: a mutating
1011    /// capability reachable by GET would be followed by every crawler, prefetcher and
1012    /// link-checker that ever met the Cockpit.
1013    #[test]
1014    fn the_transition_is_not_reachable_by_get() {
1015        let e = module()
1016            .capabilities
1017            .into_iter()
1018            .find(|e| e.capability.id.as_str() == "plan.transition")
1019            .expect("the module declares plan.transition");
1020        let http = e.capability.exposure.http.expect("it is exposed over HTTP");
1021        assert_eq!(http.method.as_str(), "POST");
1022    }
1023
1024    /// "Nothing is ready" and "there is nothing" are different answers: a plan with no
1025    /// issue says it declares none, and a plan whose every issue is taken says none is READY.
1026    #[test]
1027    fn nothing_ready_is_not_nothing_declared() {
1028        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
1029        let next = plan_next(&repo.context().unwrap(), PlanMilestoneFilter::default()).unwrap();
1030        assert!(next.issue.is_none());
1031        assert_eq!(
1032            next.reason.as_deref(),
1033            Some("the plan declares no issue to hand out")
1034        );
1035
1036        // the synthetic repository declares no plan classes; this one does, as ours does
1037        let sources = repo.root().join(".ai/repo/knowledge/sources.yaml");
1038        let mut text = std::fs::read_to_string(&sources).unwrap();
1039        for (class, pathspec) in [
1040            ("project", ".ai/repo/project/project.yaml"),
1041            ("milestone", ".ai/repo/project/milestones/*.yaml"),
1042            ("issue", ".ai/repo/project/issues/*.yaml"),
1043        ] {
1044            text.push_str(&format!(
1045                "  - id: {class}\n    kind: {class}\n    discovery: vcs\n    \
1046                 pathspec: ':(glob){pathspec}'\n    required: false\n"
1047            ));
1048        }
1049        std::fs::write(&sources, text).unwrap();
1050        let project = repo.root().join(".ai/repo/project");
1051        for dir in ["milestones", "issues"] {
1052            std::fs::create_dir_all(project.join(dir)).unwrap();
1053        }
1054        std::fs::write(
1055            project.join("project.yaml"),
1056            "schema_version: 1\nname: P\nrepository: o/r\ndefault_branch: master\n",
1057        )
1058        .unwrap();
1059        std::fs::write(
1060            project.join("milestones/M000.yaml"),
1061            "id: M000\ntitle: m\nslug: m\norder: 0\npriority: p1\nproblem: p\noutcome: o\n\
1062             acceptance_criteria:\n  - it works\nvalidation:\n  - \"true\"\n\
1063             evidence_required:\n  - proof\n",
1064        )
1065        .unwrap();
1066        // the only issue is ACTIVE: declared, and not READY
1067        let at = "2026-09-01T00:00:00Z";
1068        std::fs::write(
1069            project.join("issues/I0001.yaml"),
1070            format!(
1071                "id: I0001\nmilestone: M000\ntitle: t\nslug: t\npriority: p1\n\
1072                 profile: implementation\nobjective: o\nscope:\n  - src\n\
1073                 acceptance_criteria:\n  - it works\nvalidation:\n  - \"true\"\n\
1074                 evidence_required:\n  - proof\nstarted_at: {at}\n\
1075                 unsealed_stamps:\n  - started_at {at}\n"
1076            ),
1077        )
1078        .unwrap();
1079        let ctx = repo.context().unwrap();
1080        let plan = plan_of(&ctx).unwrap();
1081        assert_eq!(
1082            plan.issue("I0001").map(|i| i.status.as_str()),
1083            Some("ACTIVE")
1084        );
1085        let next = plan_next(&ctx, PlanMilestoneFilter::default()).unwrap();
1086        assert!(next.issue.is_none());
1087        assert_eq!(
1088            next.reason.as_deref(),
1089            Some(
1090                "no issue is READY; every one of them waits on a dependency or on its \
1091                 milestone's gate"
1092            )
1093        );
1094    }
1095}