1use 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
53pub const PLAN_URI: &str = "majordomus://plan";
55
56fn plan_of(ctx: &Context) -> Result<Plan, CapabilityError> {
66 Ok(Plan::build(&ctx.index))
67}
68
69fn 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)]
87pub struct PlanIssueFilter {
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub milestone: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub status: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 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)]
126pub struct PlanMilestoneFilter {
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 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)]
150pub struct PlanRecordInput {
152 pub id: String,
154}
155
156impl BenchmarkCases for PlanRecordInput {
157 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
185pub struct PlanIssueList {
187 pub issues: Vec<PlanIssue>,
189 pub total: usize,
191 pub statuses: PlanVocabulary,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
196pub struct PlanMilestoneProgress {
198 pub id: String,
200 pub status: String,
202 pub title: String,
204 pub counts: PlanCounts,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
209pub struct PlanStatusReport {
212 pub project: PlanProject,
214 pub statuses: PlanVocabulary,
216 pub milestones: Vec<PlanMilestoneProgress>,
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub next_ready: Option<PlanIssue>,
221 pub counts: PlanCounts,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
226pub struct PlanWaveReport {
229 pub waves: Vec<PlanWaveView>,
231 pub serialised_by_scope: Vec<PlanFinding>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
239pub struct PlanWaveView {
241 pub wave: u32,
243 pub issues: Vec<PlanIssue>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
248pub struct PlanNextIssue {
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub issue: Option<PlanIssue>,
253 pub active_milestone: String,
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub reason: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
261pub struct PlanRoadmap {
263 pub milestones: Vec<PlanMilestone>,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub now: Option<String>,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub next: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
275pub struct PlanValidation {
277 pub valid: bool,
279 pub milestones: usize,
281 pub issues: usize,
283 pub failures: usize,
285 pub warnings: usize,
287 pub findings: Vec<PlanFinding>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
292#[serde(untagged)]
294pub enum PlanRecord {
295 PlanMilestone {
297 milestone: Box<PlanMilestone>,
299 issues: Vec<PlanIssue>,
301 },
302 PlanIssue {
304 issue: Box<PlanIssue>,
306 depends_on: Vec<PlanIssue>,
308 },
309}
310
311fn 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 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 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
512pub 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 cache: CachePolicy::Disabled,
673 benchmark: BenchmarkPolicy::RequiredWhen {
674 precondition: BenchmarkPrecondition::PlanHoldsAnIssue,
675 },
676 handler: plan_transition,
677 }
678 .writes_repository(),
683 ],
684 }
685}
686
687#[derive(Debug, Serialize, Deserialize, JsonSchema)]
690#[serde(deny_unknown_fields)]
691pub struct PlanTransitionInput {
717 pub issue: String,
719 pub transition: Transition,
721}
722
723#[derive(Debug, Serialize, Deserialize, JsonSchema)]
724pub struct PlanTransitionResult {
749 pub issue: String,
751 pub transition: Transition,
753 pub from: String,
755 pub to: String,
757 pub field: String,
759 pub at: String,
761 pub event: String,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub next_ready: Option<String>,
768}
769
770impl BenchmarkCases for PlanTransitionInput {
795 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
796 let plan = Plan::build(ctx.index);
797 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 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 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 #[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 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 #[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 #[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 #[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 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 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}