1use std::borrow::Cow;
76use std::sync::Arc;
77use std::time::Duration;
78
79use crate::audit::AuditEvent;
80use crate::compare::{CompareRequest, CompareWeights};
81use crate::earned_value::{
82 BaselineRequest, BaselineTaken, Calendar, SnapshotFormat, SnapshotRequest,
83};
84use crate::edits::GraphEdit;
85use crate::lint::{LintFinding, Severity};
86use crate::llm::jev::JevJudge;
87use crate::llm::{ConfigError, JudgmentModel, LlmConfig};
88use crate::monte_carlo::MonteCarloRequest;
89use crate::plan::{
90 AcceptRequest, AcquireRequest, CallerId, Cohort, ComparePlansRequest, DeliverableStatus,
91 ForceReleaseRequest, ForkRequest, HeartbeatRequest, MarkStatusRequest, PlanDefinition,
92 PlanGraph, PlanId, PlanStatus, PlannerError, ReviseRequest, SyncRequest,
93};
94use crate::ports::Planner;
95use crate::project::ProjectRoot;
96use crate::resource_schedule::{ScheduleRequest, resource_schedule};
97use crate::review::{Judge, ReviewReport, ReviewRequest};
98use crate::simulate::SimulateRequest;
99use chrono::{DateTime, Utc};
100use rmcp::ErrorData as McpError;
101use rmcp::model::{
102 CallToolRequestParams, CallToolResponse, CallToolResult, Implementation,
103 InitializeRequestParams, InitializeResult, ListToolsResult, PaginatedRequestParams,
104 ProtocolVersion, ServerCapabilities, ServerConfig, Tool,
105};
106use rmcp::service::{NotificationContext, RequestContext, RoleServer};
107use rmcp::transport::stdio;
108use rmcp::{ServerHandler, ServiceExt};
109use serde::{Deserialize, Serialize};
110use serde_json::{Value, json};
111
112use crate::BasicCpmPlanner;
113
114pub const TOOL_SUBMIT: &str = "plan.submit";
118pub const TOOL_ACQUIRE_COHORT: &str = "plan.acquire_cohort";
119pub const TOOL_HEARTBEAT: &str = "plan.heartbeat";
120pub const TOOL_MARK_STATUS: &str = "plan.mark_status";
121pub const TOOL_STATUS: &str = "plan.status";
122pub const TOOL_GET: &str = "plan.get";
123pub const TOOL_FORCE_RELEASE: &str = "plan.force_release";
124pub const TOOL_ACCEPT: &str = "plan.accept";
125pub const TOOL_LINT: &str = "plan.lint";
126pub const TOOL_SCHEDULE: &str = "plan.schedule";
127pub const TOOL_SIMULATE: &str = "plan.simulate";
128pub const TOOL_SYNC: &str = "plan.sync";
129pub const TOOL_LIST: &str = "plan.list";
130pub const TOOL_EXPORT: &str = "plan.export";
131pub const TOOL_REVISE: &str = "plan.revise";
132pub const TOOL_FORK: &str = "plan.fork";
133pub const TOOL_SELECT: &str = "plan.select";
134pub const TOOL_ARCHIVE: &str = "plan.archive";
135pub const TOOL_COMPARE: &str = "plan.compare";
136pub const TOOL_BASELINE: &str = "plan.baseline";
137pub const TOOL_EV: &str = "plan.ev";
138pub const TOOL_SNAPSHOT: &str = "plan.snapshot";
139pub const TOOL_REVIEW: &str = "plan.review";
140
141pub const MAX_CONCURRENT_REVIEWS: usize = 2;
144
145pub const PLAN_TOOL_NAMES: &[&str] = &[
147 TOOL_SUBMIT,
148 TOOL_ACQUIRE_COHORT,
149 TOOL_HEARTBEAT,
150 TOOL_MARK_STATUS,
151 TOOL_STATUS,
152 TOOL_GET,
153 TOOL_FORCE_RELEASE,
154 TOOL_ACCEPT,
155 TOOL_LINT,
156 TOOL_SCHEDULE,
157 TOOL_SIMULATE,
158 TOOL_SYNC,
159 TOOL_LIST,
160 TOOL_EXPORT,
161 TOOL_REVISE,
162 TOOL_FORK,
163 TOOL_SELECT,
164 TOOL_ARCHIVE,
165 TOOL_COMPARE,
166 TOOL_BASELINE,
167 TOOL_EV,
168 TOOL_SNAPSHOT,
169 TOOL_REVIEW,
170];
171
172#[derive(Debug, Deserialize)]
181#[serde(deny_unknown_fields)]
182struct SubmitArgs {
183 graph: PlanGraph,
184 #[serde(default)]
185 project: Option<String>,
186 #[serde(default)]
187 name: Option<String>,
188 #[serde(default)]
189 variant: Option<String>,
190}
191
192#[derive(Debug, Deserialize)]
193#[serde(deny_unknown_fields)]
194struct AcquireCohortArgs {
195 plan_id: String,
196 caller_id: String,
197 max_count: usize,
198 #[serde(default)]
199 ids: Option<Vec<String>>,
200 #[serde(default)]
201 filter: Option<AcquireFilter>,
202 #[serde(default)]
203 ttl_seconds: Option<u64>,
204}
205
206#[derive(Debug, Deserialize)]
207#[serde(deny_unknown_fields)]
208struct AcquireFilter {
209 #[serde(default)]
210 metadata: Option<serde_json::Map<String, Value>>,
211}
212
213#[derive(Debug, Deserialize)]
214#[serde(deny_unknown_fields)]
215struct AcceptArgs {
216 plan_id: String,
217 deliverable_id: String,
218 accepted_by: String,
219 evidence: String,
220 #[serde(default)]
221 override_lock: bool,
222}
223
224#[derive(Debug, Deserialize)]
225#[serde(deny_unknown_fields)]
226struct HeartbeatArgs {
227 plan_id: String,
228 deliverable_id: String,
229 caller_id: String,
230 #[serde(default)]
231 ttl_seconds: Option<u64>,
232}
233
234#[derive(Debug, Deserialize)]
235#[serde(deny_unknown_fields)]
236struct MarkStatusArgs {
237 plan_id: String,
238 deliverable_id: String,
239 caller_id: String,
240 status: DeliverableStatus,
241 #[serde(default)]
244 earned_pct: Option<f64>,
245 #[serde(default)]
246 actual_effort_hours: Option<f64>,
247 #[serde(default)]
248 evidence: Option<String>,
249}
250
251#[derive(Debug, Deserialize)]
252#[serde(deny_unknown_fields)]
253struct StatusArgs {
254 plan_id: String,
255}
256
257#[derive(Debug, Deserialize)]
258#[serde(deny_unknown_fields)]
259struct GetArgs {
260 plan_id: String,
261}
262
263#[derive(Debug, Deserialize)]
264#[serde(deny_unknown_fields)]
265struct ForceReleaseArgs {
266 plan_id: String,
267 deliverable_id: String,
268 reason: String,
269 #[serde(default)]
270 reset_counters: bool,
271}
272
273#[derive(Debug, Deserialize)]
276#[serde(deny_unknown_fields)]
277struct GraphOrPlanIdArgs {
278 #[serde(default)]
279 graph: Option<PlanGraph>,
280 #[serde(default)]
281 plan_id: Option<String>,
282 #[serde(default)]
283 path: Option<String>,
284}
285
286#[derive(Debug, Deserialize)]
290#[serde(deny_unknown_fields)]
291struct ScheduleToolArgs {
292 #[serde(default)]
293 graph: Option<PlanGraph>,
294 #[serde(default)]
295 plan_id: Option<String>,
296 capacities: std::collections::BTreeMap<String, u32>,
297 #[serde(default = "crate::resource_schedule::default_resource_key")]
298 resource_key: String,
299 #[serde(default = "crate::resource_schedule::default_buffer_pct")]
300 project_buffer_pct: f32,
301}
302
303#[derive(Debug, Deserialize)]
306#[serde(deny_unknown_fields)]
307struct SimulateToolArgs {
308 #[serde(default)]
309 graph: Option<PlanGraph>,
310 #[serde(default)]
311 plan_id: Option<String>,
312 #[serde(default)]
313 path: Option<String>,
314 #[serde(default)]
315 schedule: Option<ScheduleRequest>,
316 #[serde(default)]
317 monte_carlo: Option<MonteCarloRequest>,
318}
319
320#[derive(Debug, Deserialize)]
325#[serde(deny_unknown_fields)]
326struct SyncArgs {
327 #[serde(default)]
328 path: Option<String>,
329 #[serde(default)]
330 graph: Option<PlanGraph>,
331 #[serde(default)]
332 project: Option<String>,
333 #[serde(default)]
334 name: Option<String>,
335 #[serde(default)]
336 variant: Option<String>,
337 #[serde(default)]
338 force: bool,
339}
340
341#[derive(Debug, Deserialize)]
343#[serde(deny_unknown_fields)]
344struct ListArgs {
345 #[serde(default)]
346 project: Option<String>,
347 #[serde(default)]
348 include_archived: bool,
349}
350
351#[derive(Debug, Deserialize)]
353#[serde(deny_unknown_fields)]
354struct ExportArgs {
355 plan_id: String,
356 #[serde(default)]
357 path: Option<String>,
358 #[serde(default)]
359 force: bool,
360}
361
362#[derive(Debug, Deserialize)]
364#[serde(deny_unknown_fields)]
365struct ReviseArgs {
366 plan_id: String,
367 graph: PlanGraph,
368 #[serde(default)]
369 force: bool,
370}
371
372#[derive(Debug, Deserialize)]
375#[serde(deny_unknown_fields)]
376struct ForkArgs {
377 plan_id: String,
378 variant: String,
379 #[serde(default)]
380 edits: Vec<GraphEdit>,
381}
382
383#[derive(Debug, Deserialize)]
385#[serde(deny_unknown_fields)]
386struct SelectArgs {
387 plan_id: String,
388 #[serde(default)]
389 force: bool,
390}
391
392#[derive(Debug, Deserialize)]
394#[serde(deny_unknown_fields)]
395struct ArchiveArgs {
396 name: String,
397 #[serde(default)]
398 variant: Option<String>,
399 #[serde(default)]
400 project: Option<String>,
401 #[serde(default = "default_archived")]
402 archived: bool,
403 #[serde(default)]
404 force: bool,
405}
406
407fn default_archived() -> bool {
408 true
409}
410
411#[derive(Debug, Deserialize)]
413#[serde(deny_unknown_fields)]
414struct CompareArgs {
415 #[serde(default)]
416 plan_ids: Option<Vec<String>>,
417 #[serde(default)]
418 plan: Option<String>,
419 #[serde(default)]
420 project: Option<String>,
421 #[serde(default)]
422 schedule: Option<ScheduleRequest>,
423 #[serde(default)]
424 monte_carlo: Option<MonteCarloRequest>,
425 #[serde(default)]
426 weights: CompareWeights,
427}
428
429#[derive(Debug, Deserialize)]
433#[serde(deny_unknown_fields)]
434struct BaselineArgs {
435 plan_id: String,
436 #[serde(default)]
437 start: Option<DateTime<Utc>>,
438 #[serde(default)]
439 calendar: Option<Calendar>,
440 #[serde(default)]
441 reason: Option<String>,
442 #[serde(default)]
443 backfill_from: Option<DateTime<Utc>>,
444}
445
446#[derive(Debug, Deserialize)]
448#[serde(deny_unknown_fields)]
449struct EvArgs {
450 plan_id: String,
451 #[serde(default)]
452 as_of: Option<DateTime<Utc>>,
453}
454
455#[derive(Debug, Deserialize)]
457#[serde(deny_unknown_fields)]
458struct SnapshotArgs {
459 plan_id: String,
460 #[serde(default)]
461 as_of: Option<DateTime<Utc>>,
462 #[serde(default)]
463 format: SnapshotFormat,
464}
465
466#[derive(Debug, Deserialize)]
469#[serde(deny_unknown_fields)]
470struct ReviewToolArgs {
471 #[serde(default)]
472 graph: Option<PlanGraph>,
473 #[serde(default)]
474 plan_id: Option<String>,
475 #[serde(default)]
476 path: Option<String>,
477 #[serde(default)]
478 capacities: Option<std::collections::BTreeMap<String, u32>>,
479 #[serde(default)]
480 resource_key: Option<String>,
481 #[serde(default)]
482 project_buffer_pct: Option<f32>,
483 #[serde(default)]
484 max_questions: Option<u16>,
485}
486
487#[derive(Debug, Serialize)]
492struct SubmitResponse {
493 plan_id: String,
494}
495
496#[derive(Debug, Serialize)]
497struct OkResponse {
498 ok: bool,
499 #[serde(skip_serializing_if = "Option::is_none")]
500 baseline_taken: Option<BaselineTaken>,
501}
502
503impl OkResponse {
504 fn new() -> Self {
505 Self {
506 ok: true,
507 baseline_taken: None,
508 }
509 }
510
511 fn with_baseline_taken(baseline_taken: Option<BaselineTaken>) -> Self {
512 Self {
513 ok: true,
514 baseline_taken,
515 }
516 }
517}
518
519#[derive(Debug, Serialize)]
520struct ExportResponse {
521 path: String,
522}
523
524#[derive(Debug, Serialize)]
525struct ReviseResponse {
526 revision: u32,
527 diff: crate::revise::RevisionDiff,
528}
529
530fn graph_schema() -> Value {
540 json!({
541 "type": "object",
542 "properties": {
543 "deliverables": {
544 "type": "array",
545 "maxItems": 5000,
546 "items": {
547 "type": "object",
548 "properties": {
549 "id": { "type": "string" },
550 "owned_files": { "type": "array", "description": "Each item is a path string (exclusive) or an object {path, mode?: exclusive|append}. Append claims on the same path may be leased together.", "items": { "oneOf": [
551 { "type": "string" },
552 { "type": "object", "properties": {
553 "path": { "type": "string" },
554 "mode": { "type": "string", "enum": ["exclusive", "append"] }
555 }, "required": ["path"], "additionalProperties": false }
556 ] } },
557 "prerequisites": { "type": "array", "description": "Each item is a deliverable id string, or an object {id, consumes?, kind?: artifact|interface, lag_hours?}.", "items": { "oneOf": [
558 { "type": "string" },
559 { "type": "object", "properties": {
560 "id": { "type": "string" },
561 "consumes": { "type": "string" },
562 "kind": { "type": "string", "enum": ["artifact", "interface"] },
563 "lag_hours": { "type": "number", "minimum": 0, "maximum": 1000000 }
564 }, "required": ["id"], "additionalProperties": false }
565 ] } },
566 "estimated_effort_hours": { "type": "number", "minimum": 0, "maximum": 1000000, "description": "Effort in hours: the cost basis, and the scheduled length when duration_hours is absent." },
567 "duration_hours": { "type": "number", "minimum": 0, "maximum": 1000000, "description": "Calendar time on the schedule; replaces effort as the scheduled length. Effort stays the cost basis." },
568 "estimate": { "type": "object", "description": "Optional three-point effort estimate (0 <= optimistic <= likely <= pessimistic <= 1000000). Scheduled-length precedence: duration_hours > estimated_effort_hours > estimate.likely > 0 for a milestone > estimator default. Monte Carlo samples the estimate only when neither duration_hours nor estimated_effort_hours is set.", "properties": {
569 "optimistic": { "type": "number", "minimum": 0, "maximum": 1000000 },
570 "likely": { "type": "number", "minimum": 0, "maximum": 1000000 },
571 "pessimistic": { "type": "number", "minimum": 0, "maximum": 1000000 }
572 }, "required": ["optimistic", "likely", "pessimistic"], "additionalProperties": false },
573 "milestone": { "type": "boolean", "description": "Acceptance point; reported in plan.status milestones with its own critical path." },
574 "earning_rule": { "type": "string", "enum": ["zero_hundred", "fifty_fifty", "weighted"], "description": "How earned value credits partial progress: zero_hundred (default; 100% only when complete), fifty_fifty (50% once in progress or any earned_pct is reported), weighted (the reported earned_pct). Part of the plan's identity." },
575 "metadata": {}
576 },
577 "required": ["id", "owned_files", "prerequisites"]
578 }
579 },
580 "max_chained_dispatch": { "type": ["integer", "null"] }
581 },
582 "required": ["deliverables"]
583 })
584}
585
586fn graph_selector_schema() -> Value {
591 let mut schema = graph_schema();
592 if let Some(obj) = schema.as_object_mut() {
593 obj.insert(
594 "description".to_string(),
595 json!(
596 "An inline plan graph to analyse. Provide exactly one of graph, plan_id, or path."
597 ),
598 );
599 }
600 schema
601}
602
603fn plan_id_schema() -> Value {
604 json!({
605 "type": "string",
606 "description": "A stored plan (from plan.submit/plan.sync) to analyse instead of an inline graph. Provide exactly one of graph, plan_id, or path."
607 })
608}
609
610fn path_schema() -> Value {
611 json!({
612 "type": "string",
613 "description": "A plan file path of the form .cpm-planner/plans/<name>/<variant>.json, relative to the project root. Provide exactly one of graph, plan_id, or path."
614 })
615}
616
617fn edits_schema() -> Value {
618 json!({
619 "type": "array",
620 "description": "Structured graph edits applied in order. Each edit is an object tagged by `op`: remove_edge {from,to}, add_edge {from,to,consumes?}, set_effort {id,hours}, set_duration {id,hours?}, set_estimate {id,estimate?}, set_metadata {id,key,value}, remove_deliverable {id}, add_deliverable {deliverable}.",
621 "items": { "type": "object" }
622 })
623}
624
625fn weights_schema() -> Value {
626 let criterion = || json!({ "type": "number", "minimum": 0 });
627 json!({
628 "type": "object",
629 "description": "Per-criterion weights for the combined score (lower is better). Each must be finite and >= 0.",
630 "properties": {
631 "makespan": criterion(),
632 "p80": criterion(),
633 "criticality_risk": criterion(),
634 "total_effort": criterion(),
635 "peak_load": criterion()
636 },
637 "additionalProperties": false
638 })
639}
640
641fn calendar_schema() -> Value {
642 json!({
643 "type": "object",
644 "description": "Working-time calendar: only working hours advance the PV clock. Omitted: wall-clock hours. A workday contributes hours_per_day hours starting at 00:00 local time.",
645 "properties": {
646 "hours_per_day": { "type": "number", "exclusiveMinimum": 0, "maximum": 24, "description": "Default 8." },
647 "workdays": { "type": "array", "minItems": 1, "items": { "type": "string", "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] }, "description": "Default mon..fri." },
648 "utc_offset_minutes": { "type": "integer", "minimum": -1440, "maximum": 1440, "description": "Local offset from UTC; default 0." }
649 },
650 "additionalProperties": false
651 })
652}
653
654fn capacities_schema() -> Value {
655 json!({
656 "type": "object",
657 "description": "Units available per resource name. Every resource that carries work (a deliverable with nonzero scheduled length) needs a count of at least 1; a missing or zero entry for such a resource is INVALID_CAPACITIES. A resource with only milestones or zero-length work may be omitted or 0.",
658 "additionalProperties": { "type": "integer", "minimum": 0 }
659 })
660}
661
662pub fn plan_tool_definitions() -> Vec<Tool> {
673 vec![
674 Tool::new(
675 Cow::Borrowed(TOOL_SUBMIT),
676 Cow::Borrowed(
677 "Submit a plan graph and receive a plan_id. \
678 Idempotent: identical graphs return the same plan_id. \
679 With `name` (and optional `project`/`variant`, variant \
680 defaults to \"main\"), registers a named variant instead: \
681 project defaults to the discovered project root.",
682 ),
683 schema_object(json!({
684 "type": "object",
685 "properties": {
686 "graph": graph_schema(),
687 "project": { "type": "string", "description": "Project key; required with `name` when no project root is discovered." },
688 "name": { "type": "string", "description": "Plan line name; when present the graph is synced as a named variant." },
689 "variant": { "type": "string", "description": "Variant name of a named plan (default \"main\")." }
690 },
691 "required": ["graph"],
692 "additionalProperties": false
693 })),
694 ),
695 Tool::new(
696 Cow::Borrowed(TOOL_ACQUIRE_COHORT),
697 Cow::Borrowed(
698 "Acquire up to max_count ready deliverables with no conflicting file claims \
699 atomically. Returns the cohort plus per-deliverable locks. \
700 A deliverable explicitly marked failed 3 times is \
701 circuit-broken to failed instead of re-leased; leases lost \
702 environmentally (TTL lapse, no terminal mark) never trip \
703 that breaker but are bounded separately (LAPSE_LIMIT at 10). \
704 Optional ttl_seconds sets the lease TTL (clamped to the \
705 server maximum). Optional ids targets specific deliverables \
706 and filter.metadata narrows by metadata equality; \
707 deliverables with metadata.kind = \"manual\" are never \
708 leased. ids that do not match filter.metadata are ignored \
709 (not reported in blocked). The response carries blocked \
710 [{id, code, reason}], blocked_count, and needs_operator \
711 (true when any blocked code is LAPSE_LIMIT: clear with \
712 plan.force_release {reset_counters: true}).",
713 ),
714 schema_object(json!({
715 "type": "object",
716 "properties": {
717 "plan_id": { "type": "string" },
718 "caller_id": { "type": "string" },
719 "max_count": { "type": "integer", "minimum": 1 },
720 "ids": { "type": "array", "minItems": 1, "items": { "type": "string" } },
721 "ttl_seconds": { "type": "integer", "minimum": 1 },
722 "filter": {
723 "type": "object",
724 "properties": { "metadata": { "type": "object" } },
725 "additionalProperties": false
726 }
727 },
728 "required": ["plan_id", "caller_id", "max_count"]
729 })),
730 ),
731 Tool::new(
732 Cow::Borrowed(TOOL_HEARTBEAT),
733 Cow::Borrowed(
734 "Refresh the TTL on a held lock; LOCK_NOT_HELD or LOCK_EXPIRED on failure. \
735 Optional ttl_seconds sets the new TTL (clamped to the server \
736 maximum); without it the lease is never shortened.",
737 ),
738 schema_object(json!({
739 "type": "object",
740 "properties": {
741 "plan_id": { "type": "string" },
742 "deliverable_id": { "type": "string" },
743 "caller_id": { "type": "string" },
744 "ttl_seconds": { "type": "integer", "minimum": 1 }
745 },
746 "required": ["plan_id", "deliverable_id", "caller_id"]
747 })),
748 ),
749 Tool::new(
750 Cow::Borrowed(TOOL_MARK_STATUS),
751 Cow::Borrowed(
752 "Set a deliverable's status. Complete/Failed releases the lock; \
753 caller_id mismatch yields LOCK_NOT_HELD. Without a lock, \
754 Complete, Ready or InProgress requires all prerequisites \
755 complete (PREREQUISITES_INCOMPLETE) and is audited; other \
756 lockless marks are audited too, and an already-complete deliverable \
757 cannot be changed (LOCK_NOT_HELD). A lockless in_progress persists \
758 across restarts and is not leasable; hand it back with a lockless \
759 ready (or other status) mark. plan.force_release does not affect it. \
760 Optional earned-value progress: \
761 earned_pct (0..100, only with in_progress; ignored with complete), \
762 actual_effort_hours (total so far, 0..1000000; replaces leased hours \
763 as actual cost) and evidence (<= 2048 chars, appended to a list of \
764 at most 100); \
765 violations are INVALID_ACTUALS. Every lease that ends adds its hours \
766 to the deliverable's leased hours.",
767 ),
768 schema_object(json!({
769 "type": "object",
770 "properties": {
771 "plan_id": { "type": "string" },
772 "deliverable_id": { "type": "string" },
773 "caller_id": { "type": "string" },
774 "status": {
775 "type": "object",
776 "description": "Internally-tagged: {\"status\":\"pending|ready|in_progress|complete\"} or {\"status\":\"failed\",\"reason\":\"...\"}"
777 },
778 "earned_pct": {
779 "type": "integer",
780 "minimum": 0,
781 "maximum": 100,
782 "description": "Percent complete (used by the weighted earning rule). Only with status in_progress; accepted and ignored with complete."
783 },
784 "actual_effort_hours": {
785 "type": "number",
786 "minimum": 0,
787 "maximum": 1000000,
788 "description": "Total effort spent so far, in hours; replaces the stored value and takes precedence over leased hours for actual cost."
789 },
790 "evidence": {
791 "type": "string",
792 "maxLength": 2048,
793 "description": "One evidence note, appended to the deliverable's evidence list (at most 100 entries)."
794 }
795 },
796 "required": ["plan_id", "deliverable_id", "caller_id", "status"]
797 })),
798 ),
799 Tool::new(
800 Cow::Borrowed(TOOL_STATUS),
801 Cow::Borrowed(
802 "Read-only snapshot: per-deliverable [id, status, attempt_count, \
803 failure_count, lapse_count] rows, critical_path (one real chain, always from __start__ to __finish__), \
804 critical_ids, per-deliverable schedule (es/ef/ls/lf/float, hours; \
805 synthetic __start__/__finish__ rows have synthetic=true), \
806 the ready set in float-ascending order (smallest float first, then smallest latest start, then id), plan_complete, milestones (per milestone: id, critical_path from __start__, hours, complete), and held locks.",
807 ),
808 schema_object(json!({
809 "type": "object",
810 "properties": {
811 "plan_id": { "type": "string" }
812 },
813 "required": ["plan_id"]
814 })),
815 ),
816 Tool::new(
817 Cow::Borrowed(TOOL_GET),
818 Cow::Borrowed(
819 "Return the stored PlanGraph (deliverables, estimates, files, \
820 metadata) for a plan_id.",
821 ),
822 schema_object(json!({
823 "type": "object",
824 "properties": {
825 "plan_id": { "type": "string" }
826 },
827 "required": ["plan_id"]
828 })),
829 ),
830 Tool::new(
831 Cow::Borrowed(TOOL_FORCE_RELEASE),
832 Cow::Borrowed(
833 "Operator escape hatch — release a lock regardless of caller. \
834 Emits an audit event carrying `reason`.",
835 ),
836 schema_object(json!({
837 "type": "object",
838 "properties": {
839 "plan_id": { "type": "string" },
840 "deliverable_id": { "type": "string" },
841 "reason": { "type": "string" },
842 "reset_counters": {
843 "type": "boolean",
844 "description": "Also clear lapse and failure counters (revives a circuit-broken deliverable)."
845 }
846 },
847 "required": ["plan_id", "deliverable_id", "reason"]
848 })),
849 ),
850 Tool::new(
851 Cow::Borrowed(TOOL_ACCEPT),
852 Cow::Borrowed(
853 "Manager/owner acceptance: mark a deliverable Complete without \
854 holding its lease. Requires all prerequisites Complete and \
855 evidence; audited. A live lease held by someone else is refused \
856 (LOCK_HELD) unless override_lock is true.",
857 ),
858 schema_object(json!({
859 "type": "object",
860 "properties": {
861 "plan_id": { "type": "string" },
862 "deliverable_id": { "type": "string" },
863 "accepted_by": { "type": "string" },
864 "evidence": { "type": "string" },
865 "override_lock": {
866 "type": "boolean",
867 "description": "Take over a live lease held by another caller."
868 }
869 },
870 "required": ["plan_id", "deliverable_id", "accepted_by", "evidence"]
871 })),
872 ),
873 Tool::new(
874 Cow::Borrowed(TOOL_LINT),
875 Cow::Borrowed(
876 "Lint a graph, stored plan, or plan file without creating one: cycles (with the \
877 loop), redundant edges, edges without rationale, interface edges \
878 not targeting a contract, deliverables feeding no milestone, and \
879 unordered file overlaps. Linting by plan_id also adds NO_BASELINE \
880 (warning) when the plan is executable and has recorded progress but no \
881 earned-value baseline. Provide exactly one of graph, plan_id, or path.",
882 ),
883 schema_object(json!({
884 "type": "object",
885 "properties": {
886 "graph": graph_selector_schema(),
887 "plan_id": plan_id_schema(),
888 "path": path_schema()
889 },
890 "additionalProperties": false
891 })),
892 ),
893 Tool::new(
894 Cow::Borrowed(TOOL_SCHEDULE),
895 Cow::Borrowed(
896 "Level a graph or stored plan against resource capacities \
897 (`metadata.owner` by default): makespan, per-deliverable \
898 start/finish, per-resource load, the driving chain (dependency vs \
899 resource waits), and project/feeding buffers. Provide exactly one \
900 of graph or plan_id. capacities is required: every resource that \
901 carries work needs at least 1 unit (otherwise INVALID_CAPACITIES: \
902 lists the missing resources). plan.schedule rejects what \
903 plan.submit rejects (INVALID_GRAPH:).",
904 ),
905 schema_object(json!({
906 "type": "object",
907 "properties": {
908 "graph": graph_selector_schema(),
909 "plan_id": plan_id_schema(),
910 "capacities": capacities_schema(),
911 "resource_key": {
912 "type": "string",
913 "description": "Metadata key naming a deliverable's resource (default \"owner\")."
914 },
915 "project_buffer_pct": {
916 "type": "number",
917 "minimum": 0,
918 "maximum": 100,
919 "description": "Project buffer as a percentage of the driving chain (default 25)."
920 }
921 },
922 "required": ["capacities"],
923 "additionalProperties": false
924 })),
925 ),
926 Tool::new(
927 Cow::Borrowed(TOOL_SIMULATE),
928 Cow::Borrowed(
929 "Read-only what-if for a graph, stored plan, or plan file (nothing is \
930 persisted): lint, critical path, schedule, milestones, optional \
931 resource schedule and Monte Carlo, and the scorecard. Provide exactly \
932 one of graph, plan_id, or path. schedule takes the plan.schedule inputs \
933 (capacities required; INVALID_CAPACITIES: when a working resource \
934 has none). monte_carlo takes iterations (1..50000, default 2000) \
935 and seed (default 0xC0FFEE); iterations × (deliverables + \
936 prerequisite edges) must not exceed 200000000. plan.simulate \
937 rejects what plan.submit rejects; lint errors or an invalid graph \
938 are INVALID_GRAPH:.",
939 ),
940 schema_object(json!({
941 "type": "object",
942 "properties": {
943 "graph": graph_selector_schema(),
944 "plan_id": plan_id_schema(),
945 "path": path_schema(),
946 "schedule": {
947 "type": "object",
948 "description": "Level the plan (same inputs as plan.schedule).",
949 "properties": {
950 "capacities": capacities_schema(),
951 "resource_key": { "type": "string", "description": "Metadata key naming a deliverable's resource (default \"owner\")." },
952 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100, "description": "Project buffer as a percentage of the driving chain (default 25)." }
953 },
954 "required": ["capacities"],
955 "additionalProperties": false
956 },
957 "monte_carlo": {
958 "type": "object",
959 "description": "Seeded Monte Carlo over three-point estimates. iterations × (deliverables + prerequisite edges) must not exceed 200000000.",
960 "properties": {
961 "iterations": { "type": "integer", "minimum": 1, "maximum": 50000, "description": "Simulated schedules (default 2000)." },
962 "seed": { "type": "integer", "minimum": 0, "description": "RNG seed (default 12648430 = 0xC0FFEE); the same seed reproduces the output on the same platform and build." }
963 },
964 "additionalProperties": false
965 }
966 },
967 "additionalProperties": false
968 })),
969 ),
970 Tool::new(
971 Cow::Borrowed(TOOL_SYNC),
972 Cow::Borrowed(
973 "Register or update one variant of a named plan line from a plan \
974 file (`path`) or an inline `graph`. A path is read from \
975 .cpm-planner/plans/<name>/<variant>.json and tracked by content hash \
976 for drift detection. An inline graph requires `name`; `variant` \
977 defaults to \"main\". `project` defaults to the discovered project \
978 root. `force` releases live locks of removed deliverables.",
979 ),
980 schema_object(json!({
981 "type": "object",
982 "properties": {
983 "path": path_schema(),
984 "graph": graph_schema(),
985 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
986 "name": { "type": "string", "description": "Plan line name (required with an inline graph)." },
987 "variant": { "type": "string", "description": "Variant name (default \"main\" for an inline graph)." },
988 "force": { "type": "boolean", "description": "Release live locks of removed deliverables instead of refusing." }
989 },
990 "additionalProperties": false
991 })),
992 ),
993 Tool::new(
994 Cow::Borrowed(TOOL_LIST),
995 Cow::Borrowed(
996 "List every plan line of `project` (default: the discovered project \
997 root), sorted by name with each line's variants sorted by variant. \
998 Archived lines and variants are omitted unless include_archived.",
999 ),
1000 schema_object(json!({
1001 "type": "object",
1002 "properties": {
1003 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
1004 "include_archived": { "type": "boolean", "description": "Include archived lines and variants (default false)." }
1005 },
1006 "additionalProperties": false
1007 })),
1008 ),
1009 Tool::new(
1010 Cow::Borrowed(TOOL_EXPORT),
1011 Cow::Borrowed(
1012 "Write the head graph of `plan_id` under the project root and return its \
1013 root-relative path: to `path` when given (confined to \
1014 .cpm-planner/plans/<name>/<variant>.json), else to its own variant \
1015 file. Refuses (INVALID_PATH) another variant's tracked plan file, or \
1016 the variant's own file while it holds local edits never synced, \
1017 unless force. The written file is not synced; exporting to the \
1018 variant's own tracked file records its hash, so drift is false.",
1019 ),
1020 schema_object(json!({
1021 "type": "object",
1022 "properties": {
1023 "plan_id": { "type": "string" },
1024 "path": path_schema(),
1025 "force": { "type": "boolean", "description": "Overwrite another variant's tracked file or unsynced local edits." }
1026 },
1027 "required": ["plan_id"],
1028 "additionalProperties": false
1029 })),
1030 ),
1031 Tool::new(
1032 Cow::Borrowed(TOOL_REVISE),
1033 Cow::Borrowed(
1034 "Replace a plan's graph in place, carrying progress over: unchanged \
1035 deliverables keep status and counters; changed or reopened ones are \
1036 re-derived. Removed deliverables holding live locks are refused \
1037 (LOCK_HELD) unless force. Returns the new revision and the diff.",
1038 ),
1039 schema_object(json!({
1040 "type": "object",
1041 "properties": {
1042 "plan_id": { "type": "string" },
1043 "graph": graph_schema(),
1044 "force": { "type": "boolean", "description": "Release live locks of removed deliverables instead of refusing." }
1045 },
1046 "required": ["plan_id", "graph"],
1047 "additionalProperties": false
1048 })),
1049 ),
1050 Tool::new(
1051 Cow::Borrowed(TOOL_FORK),
1052 Cow::Borrowed(
1053 "Copy the head graph of a named variant, apply `edits` in order, and \
1054 register the result as a new draft (not selected) variant of the same \
1055 line. Fails if the variant already exists.",
1056 ),
1057 schema_object(json!({
1058 "type": "object",
1059 "properties": {
1060 "plan_id": { "type": "string", "description": "The named variant to copy." },
1061 "variant": { "type": "string", "description": "Name of the new draft variant." },
1062 "edits": edits_schema()
1063 },
1064 "required": ["plan_id", "variant"],
1065 "additionalProperties": false
1066 })),
1067 ),
1068 Tool::new(
1069 Cow::Borrowed(TOOL_SELECT),
1070 Cow::Borrowed(
1071 "Make the named variant owning `plan_id` its line's selected (the only \
1072 executable) variant. Progress carries over from the previously \
1073 selected variant. Live locks on the previous variant refuse selection \
1074 with LOCK_HELD unless force releases them (audited).",
1075 ),
1076 schema_object(json!({
1077 "type": "object",
1078 "properties": {
1079 "plan_id": { "type": "string" },
1080 "force": { "type": "boolean", "description": "Release live locks on the previously selected variant." }
1081 },
1082 "required": ["plan_id"],
1083 "additionalProperties": false
1084 })),
1085 ),
1086 Tool::new(
1087 Cow::Borrowed(TOOL_ARCHIVE),
1088 Cow::Borrowed(
1089 "Archive (archived defaults to true) or unarchive a whole plan line \
1090 (`variant` omitted) or one variant. Archived variants stay readable \
1091 but are hidden from plan.list unless include_archived, and refuse sync, \
1092 selection and execution (ARCHIVE_REFUSED).",
1093 ),
1094 schema_object(json!({
1095 "type": "object",
1096 "properties": {
1097 "name": { "type": "string", "description": "Plan line name." },
1098 "variant": { "type": "string", "description": "One variant; omitted archives the whole line." },
1099 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
1100 "archived": { "type": "boolean", "description": "False unarchives (default true)." },
1101 "force": { "type": "boolean", "description": "Release live locks on the selected variant when archiving a line." }
1102 },
1103 "required": ["name"],
1104 "additionalProperties": false
1105 })),
1106 ),
1107 Tool::new(
1108 Cow::Borrowed(TOOL_COMPARE),
1109 Cow::Borrowed(
1110 "Compare stored plans on the scorecard, returning the Pareto front, \
1111 weighted rank and a recommended plan. Give exactly one of `plan_ids` \
1112 (2 to 16 distinct plans) or `plan` (a line name in `project`: every \
1113 non-archived variant, at most 16). Read-only. Weights must be finite \
1114 and >= 0. With monte_carlo, iterations × (deliverables + prerequisite \
1115 edges) summed over all variants must not exceed 200000000.",
1116 ),
1117 schema_object(json!({
1118 "type": "object",
1119 "properties": {
1120 "plan_ids": { "type": "array", "minItems": 2, "maxItems": 16, "uniqueItems": true, "items": { "type": "string" } },
1121 "plan": { "type": "string", "description": "Plan line name; compares every live variant." },
1122 "project": { "type": "string", "description": "Project key of `plan`; defaults to the discovered project root." },
1123 "schedule": {
1124 "type": "object",
1125 "description": "Level every variant (same inputs as plan.schedule).",
1126 "properties": {
1127 "capacities": capacities_schema(),
1128 "resource_key": { "type": "string" },
1129 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100 }
1130 },
1131 "required": ["capacities"],
1132 "additionalProperties": false
1133 },
1134 "monte_carlo": {
1135 "type": "object",
1136 "description": "Run Monte Carlo on every variant (P80 then comes from it).",
1137 "properties": {
1138 "iterations": { "type": "integer", "minimum": 1, "maximum": 50000 },
1139 "seed": { "type": "integer", "minimum": 0 }
1140 },
1141 "additionalProperties": false
1142 },
1143 "weights": weights_schema()
1144 },
1145 "additionalProperties": false
1146 })),
1147 ),
1148 Tool::new(
1149 Cow::Borrowed(TOOL_BASELINE),
1150 Cow::Borrowed(
1151 "Freeze the plan's current CPM schedule (earliest start/finish) and budgets \
1152 (effort basis x metadata.cost_rate) as its next numbered baseline for earned \
1153 value. The first baseline is number 1; re-baselining needs a non-blank \
1154 `reason` (INVALID_GRAPH otherwise) and keeps actuals and snapshots. \
1155 Without `calendar`, a re-baseline keeps the previous baseline's calendar \
1156 (the first baseline counts wall-clock hours). `backfill_from` (RFC 3339) \
1157 sets the baseline's `start` retroactively and marks it `backfilled: true` \
1158 (reported by plan.ev); it must not be in the future or before the plan's \
1159 creation time (INVALID_VALUE). Execution-side: the plan must be its line's \
1160 selected, unarchived variant (VARIANT_NOT_SELECTED / ARCHIVE_REFUSED). \
1161 Audited as plan.ev.baselined.",
1162 ),
1163 schema_object(json!({
1164 "type": "object",
1165 "properties": {
1166 "plan_id": { "type": "string" },
1167 "start": { "type": "string", "format": "date-time", "description": "RFC 3339 instant the PV clock starts from; defaults to now." },
1168 "calendar": calendar_schema(),
1169 "reason": { "type": "string", "maxLength": 2048, "description": "Why the plan is re-baselined; required when a baseline exists." },
1170 "backfill_from": { "type": "string", "format": "date-time", "description": "RFC 3339 instant to use as the baseline's start, marking it backfilled; must not be in the future or before the plan's creation time." }
1171 },
1172 "required": ["plan_id"],
1173 "additionalProperties": false
1174 })),
1175 ),
1176 Tool::new(
1177 Cow::Borrowed(TOOL_EV),
1178 Cow::Borrowed(
1179 "Earned-value report against the plan's latest baseline as of `as_of` \
1180 (default now). `as_of` is the PV status date; EV and AC reflect progress \
1181 and actuals recorded up to the moment the call runs. Returns BAC, PV, EV, \
1182 AC, SV, CV, SPI, CPI, EAC, ETC, VAC, TCPI, per-deliverable rows (a \
1183 baselined deliverable later removed by plan.revise has status `removed` \
1184 and keeps the percent it had earned: 100 if complete), critical float \
1185 consumed, SPI_BELOW_0_9 / CPI_BELOW_0_9 alerts from the two latest \
1186 stored non-backfilled snapshots (by as_of) of the current baseline, and \
1187 deliverables added since the baseline. AC counts every recorded hour of \
1188 the plan, including removed and unbaselined deliverables (rate 1 when the \
1189 baseline has no row). A \
1190 ratio whose denominator is 0 is null and explained in `undefined`. \
1191 `backfilled` is true when the baseline was taken with `backfill_from`. \
1192 Read-only; works on any variant. \
1193 NOT_BASELINED before plan.baseline.",
1194 ),
1195 schema_object(json!({
1196 "type": "object",
1197 "properties": {
1198 "plan_id": { "type": "string" },
1199 "as_of": { "type": "string", "format": "date-time", "description": "RFC 3339 PV status date; defaults to now. EV and AC are always as recorded when the call runs." }
1200 },
1201 "required": ["plan_id"],
1202 "additionalProperties": false
1203 })),
1204 ),
1205 Tool::new(
1206 Cow::Borrowed(TOOL_SNAPSHOT),
1207 Cow::Borrowed(
1208 "Compute the earned-value report (as plan.ev) and append it as a \
1209 snapshot. `as_of` is the PV status date; EV and AC are the progress \
1210 recorded when the call runs, so a snapshot whose as_of is more than an \
1211 hour before its taken_at is marked `backfilled: true` and ignored by \
1212 alerts. Returns the snapshot summary (alerts consider this snapshot and \
1213 the latest earlier non-backfilled one by as_of of the current baseline) \
1214 and an export \
1215 of the newest 100 snapshots by as_of, oldest first (an older backfill \
1216 is counted but not listed): a list of \
1217 summaries (format json, default) or a Markdown table with columns \
1218 date, PV, EV, AC, SPI, CPI, EAC (format markdown). \
1219 Execution-side like plan.baseline. NOT_BASELINED before plan.baseline.",
1220 ),
1221 schema_object(json!({
1222 "type": "object",
1223 "properties": {
1224 "plan_id": { "type": "string" },
1225 "as_of": { "type": "string", "format": "date-time", "description": "RFC 3339 PV status date; defaults to now. EV and AC are always as recorded when the call runs." },
1226 "format": { "type": "string", "enum": ["json", "markdown"], "description": "Export format (default json)." }
1227 },
1228 "required": ["plan_id"],
1229 "additionalProperties": false
1230 })),
1231 ),
1232 Tool::new(
1233 Cow::Borrowed(TOOL_REVIEW),
1234 Cow::Borrowed(
1235 "Optional AI review of a graph, stored plan, or plan file (read-only; \
1236 provide exactly one of graph, plan_id, or path). Lint always runs; \
1237 lint errors return status invalid_graph without calling the judge. \
1238 Otherwise one batched call to Jev (calibrated judgments, via \
1239 OpenRouter) asks up to max_questions (1..64, default 64) questions \
1240 about likely false or missing dependencies, split and interface-split \
1241 candidates and crash options; findings carry advisory probabilities. \
1242 Proposals are plan.fork edit lists, each verified by simulate (lint \
1243 clean, makespan shorter) and ranked by hours_saved / max(cost, 1). \
1244 Without an OpenRouter key (or with an unusable LLM configuration) \
1245 status is review_unavailable with a reason, plus lint. The plan graph \
1246 (ids, descriptions, metadata) is sent to OpenRouter.",
1247 ),
1248 schema_object(json!({
1249 "type": "object",
1250 "properties": {
1251 "graph": graph_selector_schema(),
1252 "plan_id": plan_id_schema(),
1253 "path": path_schema(),
1254 "capacities": {
1255 "type": "object",
1256 "description": "Optional: level the plan so makespans account for resources (same rules as plan.schedule capacities).",
1257 "additionalProperties": { "type": "integer", "minimum": 0 }
1258 },
1259 "resource_key": { "type": "string", "description": "Metadata key naming a deliverable's resource (default \"owner\"); requires capacities." },
1260 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100, "description": "Project buffer percentage (default 25); requires capacities." },
1261 "max_questions": { "type": "integer", "minimum": 1, "maximum": 64, "description": "Question cap for the single Jev call (default 64); extra candidates are dropped and truncated is true." }
1262 },
1263 "additionalProperties": false
1264 })),
1265 ),
1266 ]
1267}
1268
1269fn schema_object(value: Value) -> Arc<rmcp::model::JsonObject> {
1272 debug_assert!(
1277 value.is_object(),
1278 "schema_object expects an object literal; got non-object"
1279 );
1280 let obj = match value.as_object() {
1281 Some(o) => o.clone(),
1282 None => serde_json::Map::new(),
1283 };
1284 Arc::new(obj)
1285}
1286
1287#[derive(Clone)]
1293enum ReviewJudge {
1294 Model(Arc<dyn JudgmentModel>),
1295 NotConfigured,
1296 Unusable(String),
1298}
1299
1300impl ReviewJudge {
1301 fn as_judge(&self) -> Judge<'_> {
1302 match self {
1303 Self::Model(model) => Judge::Model(model.as_ref()),
1304 Self::NotConfigured => Judge::NotConfigured,
1305 Self::Unusable(reason) => Judge::ConfigError(reason),
1306 }
1307 }
1308
1309 fn configured_model(&self) -> Option<String> {
1310 match self {
1311 Self::Model(model) => model.model_id(),
1312 Self::NotConfigured | Self::Unusable(_) => None,
1313 }
1314 }
1315}
1316
1317#[derive(Clone)]
1319pub struct PlanServer {
1320 planner: Arc<BasicCpmPlanner>,
1321 server_name: String,
1322 server_version: String,
1323 judge: ReviewJudge,
1324 review_slots: Arc<tokio::sync::Semaphore>,
1326}
1327
1328impl PlanServer {
1329 pub fn new(planner: Arc<BasicCpmPlanner>) -> Self {
1331 Self {
1332 planner,
1333 server_name: "cpm-planner".to_string(),
1334 server_version: env!("CARGO_PKG_VERSION").to_string(),
1335 judge: ReviewJudge::NotConfigured,
1336 review_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REVIEWS)),
1337 }
1338 }
1339
1340 pub fn with_judge(mut self, judge: Option<Arc<dyn JudgmentModel>>) -> Self {
1343 self.judge = judge.map_or(ReviewJudge::NotConfigured, ReviewJudge::Model);
1344 self
1345 }
1346
1347 pub fn with_llm_config(mut self, config: Result<Option<LlmConfig>, ConfigError>) -> Self {
1353 self.judge = match config {
1354 Ok(Some(config)) => {
1355 tracing::info!(
1356 model = config.jev_model(),
1357 endpoint = ?config.jev_endpoint_host(),
1358 "plan.review enabled"
1359 );
1360 ReviewJudge::Model(Arc::new(JevJudge::new(&config)))
1361 }
1362 Ok(None) => {
1363 tracing::info!("no OpenRouter key configured; plan.review is unavailable");
1364 ReviewJudge::NotConfigured
1365 }
1366 Err(err) => {
1367 let reason = err.review_reason();
1370 tracing::warn!(%reason, "LLM configuration unusable; plan.review is unavailable");
1371 ReviewJudge::Unusable(reason)
1372 }
1373 };
1374 self
1375 }
1376
1377 pub fn with_identity(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
1380 self.server_name = name.into();
1381 self.server_version = version.into();
1382 self
1383 }
1384
1385 pub fn planner(&self) -> &Arc<BasicCpmPlanner> {
1388 &self.planner
1389 }
1390
1391 fn project_root(&self) -> Result<&ProjectRoot, McpError> {
1397 self.planner.project_root().ok_or_else(|| {
1398 McpError::internal_error(
1399 "INVALID_PATH: no project root (set CPM_PROJECT_ROOT or run inside a repo)",
1400 None,
1401 )
1402 })
1403 }
1404
1405 fn default_project(&self) -> Result<String, McpError> {
1407 Ok(self.project_root()?.project_key())
1408 }
1409
1410 async fn resolve_graph(
1414 &self,
1415 graph: Option<PlanGraph>,
1416 plan_id: Option<String>,
1417 path: Option<String>,
1418 ) -> Result<PlanGraph, McpError> {
1419 let supplied = [graph.is_some(), plan_id.is_some(), path.is_some()]
1420 .iter()
1421 .filter(|present| **present)
1422 .count();
1423 if supplied != 1 {
1424 return Err(McpError::invalid_params(
1425 "provide exactly one of graph, plan_id, or path",
1426 None,
1427 ));
1428 }
1429 match (graph, plan_id, path) {
1430 (Some(graph), None, None) => Ok(graph),
1431 (None, Some(plan_id), None) => {
1432 let definition = self
1433 .planner
1434 .get_plan(&PlanId(plan_id))
1435 .await
1436 .map_err(planner_error_to_mcp)?;
1437 Ok(definition.graph)
1438 }
1439 (None, None, Some(path)) => {
1440 let root = self.project_root()?;
1441 let file = root
1442 .resolve_plan_file(&path)
1443 .map_err(planner_error_to_mcp)?;
1444 let (graph, _) = root.read_graph(&file).map_err(planner_error_to_mcp)?;
1445 Ok(graph)
1446 }
1447 _ => Err(McpError::invalid_params(
1449 "provide exactly one of graph, plan_id, or path",
1450 None,
1451 )),
1452 }
1453 }
1454
1455 pub async fn serve_stdio(self) -> anyhow::Result<()> {
1462 let service = self.serve(stdio()).await?;
1463 service.waiting().await?;
1464 Ok(())
1465 }
1466
1467 pub async fn dispatch_call(&self, request: CallToolRequestParams) -> Result<Value, McpError> {
1473 let args: Value = request
1474 .arguments
1475 .as_ref()
1476 .map(|m| Value::Object(m.clone()))
1477 .unwrap_or_else(|| json!({}));
1478
1479 match request.name.as_ref() {
1480 TOOL_SUBMIT => self.handle_submit(args).await,
1481 TOOL_ACQUIRE_COHORT => self.handle_acquire_cohort(args).await,
1482 TOOL_HEARTBEAT => self.handle_heartbeat(args).await,
1483 TOOL_MARK_STATUS => self.handle_mark_status(args).await,
1484 TOOL_STATUS => self.handle_status(args).await,
1485 TOOL_GET => self.handle_get(args).await,
1486 TOOL_FORCE_RELEASE => self.handle_force_release(args).await,
1487 TOOL_ACCEPT => self.handle_accept(args).await,
1488 TOOL_LINT => self.handle_lint(args).await,
1489 TOOL_SCHEDULE => self.handle_schedule(args).await,
1490 TOOL_SIMULATE => self.handle_simulate(args).await,
1491 TOOL_SYNC => self.handle_sync(args).await,
1492 TOOL_LIST => self.handle_list(args).await,
1493 TOOL_EXPORT => self.handle_export(args).await,
1494 TOOL_REVISE => self.handle_revise(args).await,
1495 TOOL_FORK => self.handle_fork(args).await,
1496 TOOL_SELECT => self.handle_select(args).await,
1497 TOOL_ARCHIVE => self.handle_archive(args).await,
1498 TOOL_COMPARE => self.handle_compare(args).await,
1499 TOOL_BASELINE => self.handle_baseline(args).await,
1500 TOOL_EV => self.handle_ev(args).await,
1501 TOOL_SNAPSHOT => self.handle_snapshot(args).await,
1502 TOOL_REVIEW => self.handle_review(args).await,
1503 other => Err(McpError::invalid_params(
1504 format!(
1505 "Unknown tool '{other}'. Available: {}.",
1506 PLAN_TOOL_NAMES.join(", ")
1507 ),
1508 None,
1509 )),
1510 }
1511 }
1512
1513 async fn handle_submit(&self, args: Value) -> Result<Value, McpError> {
1518 let parsed: SubmitArgs = parse_args(args)?;
1519 match parsed.name {
1520 None => {
1521 let plan_id = self
1522 .planner
1523 .submit_plan(parsed.graph)
1524 .await
1525 .map_err(planner_error_to_mcp)?;
1526 to_value(&SubmitResponse { plan_id: plan_id.0 })
1527 }
1528 Some(name) => {
1529 let project = match parsed.project {
1530 Some(project) => project,
1531 None => self.default_project()?,
1532 };
1533 let variant = parsed.variant.unwrap_or_else(|| "main".to_string());
1534 let outcome = self
1535 .planner
1536 .sync_plan(SyncRequest::new(project, name, variant, parsed.graph))
1537 .await
1538 .map_err(planner_error_to_mcp)?;
1539 to_value(&outcome)
1540 }
1541 }
1542 }
1543
1544 async fn handle_acquire_cohort(&self, args: Value) -> Result<Value, McpError> {
1545 let parsed: AcquireCohortArgs = parse_args(args)?;
1546 if parsed.ids.as_ref().is_some_and(Vec::is_empty) {
1547 return Err(McpError::invalid_params(
1548 "ids must be non-empty when provided",
1549 None,
1550 ));
1551 }
1552 let mut request = AcquireRequest::new(
1553 PlanId(parsed.plan_id),
1554 CallerId(parsed.caller_id),
1555 parsed.max_count,
1556 );
1557 if let Some(ids) = parsed.ids {
1558 request = request.with_ids(ids);
1559 }
1560 if let Some(filter) = parsed.filter.and_then(|f| f.metadata) {
1561 request = request.with_metadata_filter(filter);
1562 }
1563 if let Some(ttl) = ttl_from_seconds(parsed.ttl_seconds)? {
1564 request = request.with_ttl(ttl);
1565 }
1566 let cohort: Cohort = self
1567 .planner
1568 .acquire_cohort(request)
1569 .await
1570 .map_err(planner_error_to_mcp)?;
1571 let exhausted = cohort.rows.is_empty();
1578 let blocked_count = cohort.blocked.len();
1579 let needs_operator = cohort.blocked.iter().any(|b| b.code == "LAPSE_LIMIT");
1580 let mut value = to_value(&cohort)?;
1581 if let Some(obj) = value.as_object_mut() {
1582 obj.insert("exhausted".to_string(), Value::Bool(exhausted));
1583 obj.insert("blocked_count".to_string(), json!(blocked_count));
1584 obj.insert("needs_operator".to_string(), Value::Bool(needs_operator));
1585 }
1586 Ok(value)
1587 }
1588
1589 async fn handle_heartbeat(&self, args: Value) -> Result<Value, McpError> {
1590 let parsed: HeartbeatArgs = parse_args(args)?;
1591 let mut request = HeartbeatRequest::new(
1592 PlanId(parsed.plan_id),
1593 parsed.deliverable_id,
1594 CallerId(parsed.caller_id),
1595 );
1596 if let Some(ttl) = ttl_from_seconds(parsed.ttl_seconds)? {
1597 request = request.with_ttl(ttl);
1598 }
1599 self.planner
1600 .heartbeat(request)
1601 .await
1602 .map_err(planner_error_to_mcp)?;
1603 to_value(&OkResponse::new())
1604 }
1605
1606 async fn handle_mark_status(&self, args: Value) -> Result<Value, McpError> {
1607 let parsed: MarkStatusArgs = parse_args(args)?;
1608 crate::planner::validate_actuals(
1609 &parsed.status,
1610 parsed.earned_pct,
1611 parsed.actual_effort_hours,
1612 parsed.evidence.as_deref(),
1613 )
1614 .map_err(planner_error_to_mcp)?;
1615 let mut request = MarkStatusRequest::new(
1616 PlanId(parsed.plan_id),
1617 parsed.deliverable_id,
1618 CallerId(parsed.caller_id),
1619 parsed.status,
1620 );
1621 if let Some(pct) = parsed.earned_pct {
1624 request = request.with_earned_pct(pct as u8);
1625 }
1626 if let Some(hours) = parsed.actual_effort_hours {
1627 request = request.with_actual_effort_hours(hours as f32);
1628 }
1629 if let Some(evidence) = parsed.evidence {
1630 request = request.with_evidence(evidence);
1631 }
1632 let baseline_taken = self
1633 .planner
1634 .mark_status(request)
1635 .await
1636 .map_err(planner_error_to_mcp)?;
1637 to_value(&OkResponse::with_baseline_taken(baseline_taken))
1638 }
1639
1640 async fn handle_status(&self, args: Value) -> Result<Value, McpError> {
1641 let parsed: StatusArgs = parse_args(args)?;
1642 let status: PlanStatus = self
1643 .planner
1644 .status(&PlanId(parsed.plan_id))
1645 .await
1646 .map_err(planner_error_to_mcp)?;
1647 to_value(&status)
1648 }
1649
1650 async fn handle_get(&self, args: Value) -> Result<Value, McpError> {
1651 let parsed: GetArgs = parse_args(args)?;
1652 let definition: PlanDefinition = self
1653 .planner
1654 .get_plan(&PlanId(parsed.plan_id))
1655 .await
1656 .map_err(planner_error_to_mcp)?;
1657 to_value(&definition)
1658 }
1659
1660 async fn handle_accept(&self, args: Value) -> Result<Value, McpError> {
1661 let parsed: AcceptArgs = parse_args(args)?;
1662 self.planner
1663 .accept(
1664 AcceptRequest::new(
1665 PlanId(parsed.plan_id),
1666 parsed.deliverable_id,
1667 parsed.accepted_by,
1668 parsed.evidence,
1669 )
1670 .override_lock(parsed.override_lock),
1671 )
1672 .await
1673 .map_err(planner_error_to_mcp)?;
1674 to_value(&OkResponse::new())
1675 }
1676
1677 async fn handle_force_release(&self, args: Value) -> Result<Value, McpError> {
1678 let parsed: ForceReleaseArgs = parse_args(args)?;
1679 self.planner
1680 .force_release(
1681 ForceReleaseRequest::new(
1682 PlanId(parsed.plan_id),
1683 parsed.deliverable_id,
1684 parsed.reason,
1685 )
1686 .reset_counters(parsed.reset_counters),
1687 )
1688 .await
1689 .map_err(planner_error_to_mcp)?;
1690 to_value(&OkResponse::new())
1691 }
1692
1693 async fn handle_lint(&self, args: Value) -> Result<Value, McpError> {
1694 let parsed: GraphOrPlanIdArgs = parse_args(args)?;
1695 let stored_plan = parsed.plan_id.clone();
1696 let graph = self
1697 .resolve_graph(parsed.graph, parsed.plan_id, parsed.path)
1698 .await?;
1699 let mut report = run_blocking(move || Ok(crate::lint::lint(&graph))).await?;
1700 if let Some(plan_id) = stored_plan {
1701 let plan_id = PlanId(plan_id);
1702 let status = self
1703 .planner
1704 .status(&plan_id)
1705 .await
1706 .map_err(planner_error_to_mcp)?;
1707 let applicable = status.selected != Some(false)
1708 && self
1709 .planner
1710 .has_progress_without_baseline(&plan_id)
1711 .map_err(planner_error_to_mcp)?;
1712 if applicable {
1713 let label = match (&status.name, &status.variant) {
1714 (Some(name), Some(variant)) => format!("{name}/{variant}"),
1715 _ => plan_id.0.clone(),
1716 };
1717 crate::lint::push_finding(
1718 &mut report,
1719 LintFinding {
1720 code: "NO_BASELINE".to_string(),
1721 severity: Severity::Warning,
1722 message: format!(
1723 "plan '{label}' has recorded progress but no earned-value baseline; \
1724 call plan.baseline"
1725 ),
1726 ids: Vec::new(),
1727 path: None,
1728 },
1729 );
1730 }
1731 }
1732 to_value(&report)
1733 }
1734
1735 async fn handle_schedule(&self, args: Value) -> Result<Value, McpError> {
1736 let parsed: ScheduleToolArgs = parse_args(args)?;
1737 let request = ScheduleRequest {
1738 capacities: parsed.capacities,
1739 resource_key: parsed.resource_key,
1740 project_buffer_pct: parsed.project_buffer_pct,
1741 };
1742 check_schedule_params(&request)?;
1743 let graph = self
1744 .resolve_graph(parsed.graph, parsed.plan_id, None)
1745 .await?;
1746 let schedule = run_blocking(move || resource_schedule(&graph, &request)).await?;
1747 to_value(&schedule)
1748 }
1749
1750 async fn handle_simulate(&self, args: Value) -> Result<Value, McpError> {
1751 let parsed: SimulateToolArgs = parse_args(args)?;
1752 let request = SimulateRequest {
1753 schedule: parsed.schedule,
1754 monte_carlo: parsed.monte_carlo,
1755 };
1756 if let Some(schedule) = &request.schedule {
1757 check_schedule_params(schedule)?;
1758 }
1759 if let Some(mc) = &request.monte_carlo {
1760 crate::monte_carlo::check_iterations(mc.iterations)
1761 .map_err(|reason| McpError::invalid_params(reason, None))?;
1762 }
1763 let graph = self
1764 .resolve_graph(parsed.graph, parsed.plan_id, parsed.path)
1765 .await?;
1766 let result = run_blocking(move || crate::simulate::simulate(&graph, &request)).await?;
1767 to_value(&result)
1768 }
1769
1770 async fn handle_sync(&self, args: Value) -> Result<Value, McpError> {
1773 let parsed: SyncArgs = parse_args(args)?;
1774 let (graph, source) = match (parsed.path, parsed.graph) {
1775 (Some(_), Some(_)) => {
1776 return Err(McpError::invalid_params(
1777 "provide exactly one of path or graph",
1778 None,
1779 ));
1780 }
1781 (Some(path), None) => {
1782 let root = self.project_root()?;
1783 let file = root
1784 .resolve_plan_file(&path)
1785 .map_err(planner_error_to_mcp)?;
1786 let (graph, hash) = root.read_graph(&file).map_err(planner_error_to_mcp)?;
1787 (graph, Some((file, hash)))
1788 }
1789 (None, Some(graph)) => (graph, None),
1790 (None, None) => {
1791 return Err(McpError::invalid_params(
1792 "plan.sync requires exactly one of path or graph",
1793 None,
1794 ));
1795 }
1796 };
1797 let project = match parsed.project {
1798 Some(project) => project,
1799 None => self.default_project()?,
1800 };
1801 let (name, variant) = match (&source, parsed.name, parsed.variant) {
1802 (Some((file, _)), name, variant) => {
1803 let root_key = self.project_root()?.project_key();
1806 if name.as_ref().is_some_and(|n| *n != file.name)
1807 || variant.as_ref().is_some_and(|v| *v != file.variant)
1808 || project != root_key
1809 {
1810 return Err(planner_error_to_mcp(PlannerError::InvalidPath {
1811 reason: "name/variant/project must match the plan file path".to_string(),
1812 }));
1813 }
1814 (file.name.clone(), file.variant.clone())
1815 }
1816 (None, Some(name), variant) => (name, variant.unwrap_or_else(|| "main".to_string())),
1817 (None, None, _) => {
1818 return Err(McpError::invalid_params(
1819 "an inline graph requires name",
1820 None,
1821 ));
1822 }
1823 };
1824 let mut request = SyncRequest::new(project, name, variant, graph).force(parsed.force);
1825 if let Some((file, hash)) = source {
1826 request = request
1827 .with_source_path(file.rel_path)
1828 .with_content_hash(hash);
1829 }
1830 let outcome = self
1831 .planner
1832 .sync_plan(request)
1833 .await
1834 .map_err(planner_error_to_mcp)?;
1835 to_value(&outcome)
1836 }
1837
1838 async fn handle_list(&self, args: Value) -> Result<Value, McpError> {
1839 let parsed: ListArgs = parse_args(args)?;
1840 let project = match parsed.project {
1841 Some(project) => project,
1842 None => self.default_project()?,
1843 };
1844 let lines = self
1845 .planner
1846 .list_plans(&project, parsed.include_archived)
1847 .await
1848 .map_err(planner_error_to_mcp)?;
1849 to_value(&lines)
1850 }
1851
1852 async fn handle_export(&self, args: Value) -> Result<Value, McpError> {
1853 let parsed: ExportArgs = parse_args(args)?;
1854 let root = self.project_root()?;
1855 let path = self
1856 .planner
1857 .export_plan(
1858 &PlanId(parsed.plan_id),
1859 root,
1860 parsed.path.as_deref(),
1861 parsed.force,
1862 )
1863 .await
1864 .map_err(planner_error_to_mcp)?;
1865 to_value(&ExportResponse { path })
1866 }
1867
1868 async fn handle_revise(&self, args: Value) -> Result<Value, McpError> {
1869 let parsed: ReviseArgs = parse_args(args)?;
1870 let (revision, diff) = self
1871 .planner
1872 .revise_plan(
1873 ReviseRequest::new(PlanId(parsed.plan_id), parsed.graph).force(parsed.force),
1874 )
1875 .await
1876 .map_err(planner_error_to_mcp)?;
1877 to_value(&ReviseResponse { revision, diff })
1878 }
1879
1880 async fn handle_fork(&self, args: Value) -> Result<Value, McpError> {
1881 let parsed: ForkArgs = parse_args(args)?;
1882 let outcome = self
1883 .planner
1884 .fork_plan(
1885 ForkRequest::new(PlanId(parsed.plan_id), parsed.variant).with_edits(parsed.edits),
1886 )
1887 .await
1888 .map_err(planner_error_to_mcp)?;
1889 to_value(&outcome)
1890 }
1891
1892 async fn handle_select(&self, args: Value) -> Result<Value, McpError> {
1893 let parsed: SelectArgs = parse_args(args)?;
1894 let outcome = self
1895 .planner
1896 .select_variant(&PlanId(parsed.plan_id), parsed.force)
1897 .await
1898 .map_err(planner_error_to_mcp)?;
1899 to_value(&outcome)
1900 }
1901
1902 async fn handle_archive(&self, args: Value) -> Result<Value, McpError> {
1903 let parsed: ArchiveArgs = parse_args(args)?;
1904 let project = match parsed.project {
1905 Some(project) => project,
1906 None => self.default_project()?,
1907 };
1908 self.planner
1909 .archive(
1910 &project,
1911 &parsed.name,
1912 parsed.variant.as_deref(),
1913 parsed.archived,
1914 parsed.force,
1915 )
1916 .await
1917 .map_err(planner_error_to_mcp)?;
1918 to_value(&OkResponse::new())
1919 }
1920
1921 async fn handle_compare(&self, args: Value) -> Result<Value, McpError> {
1922 let parsed: CompareArgs = parse_args(args)?;
1923 check_compare_weights(&parsed.weights)?;
1924 if let Some(schedule) = &parsed.schedule {
1925 check_schedule_params(schedule)?;
1926 }
1927 if let Some(mc) = &parsed.monte_carlo {
1928 crate::monte_carlo::check_iterations(mc.iterations)
1929 .map_err(|reason| McpError::invalid_params(reason, None))?;
1930 }
1931 let request = CompareRequest {
1932 schedule: parsed.schedule,
1933 monte_carlo: parsed.monte_carlo,
1934 weights: parsed.weights,
1935 };
1936 let compare = match (parsed.plan_ids, parsed.plan) {
1937 (Some(ids), None) => {
1938 let distinct: std::collections::HashSet<&str> =
1939 ids.iter().map(String::as_str).collect();
1940 if distinct.len() != ids.len() {
1941 return Err(McpError::invalid_params("plan_ids must be distinct", None));
1942 }
1943 ComparePlansRequest::by_ids(ids.into_iter().map(PlanId).collect(), request)
1944 }
1945 (None, Some(name)) => {
1946 let project = match parsed.project {
1947 Some(project) => project,
1948 None => self.default_project()?,
1949 };
1950 ComparePlansRequest::by_plan(project, name, request)
1951 }
1952 _ => {
1953 return Err(McpError::invalid_params(
1954 "provide exactly one of plan_ids or plan",
1955 None,
1956 ));
1957 }
1958 };
1959 let inputs = self
1961 .planner
1962 .compare_inputs(&compare)
1963 .map_err(planner_error_to_mcp)?;
1964 let request = compare.request;
1965 let comparison = run_blocking(move || crate::compare::compare(&inputs, &request)).await?;
1966 to_value(&comparison)
1967 }
1968
1969 async fn handle_baseline(&self, args: Value) -> Result<Value, McpError> {
1972 let parsed: BaselineArgs = parse_args(args)?;
1973 let mut request = BaselineRequest::new(PlanId(parsed.plan_id));
1974 request.start = parsed.start;
1975 request.calendar = parsed.calendar;
1976 request.reason = parsed.reason;
1977 request.backfill_from = parsed.backfill_from;
1978 let outcome = self
1979 .planner
1980 .baseline(request)
1981 .await
1982 .map_err(planner_error_to_mcp)?;
1983 to_value(&outcome)
1984 }
1985
1986 async fn handle_ev(&self, args: Value) -> Result<Value, McpError> {
1987 let parsed: EvArgs = parse_args(args)?;
1988 let planner = Arc::clone(&self.planner);
1989 let plan_id = PlanId(parsed.plan_id);
1990 let report = run_blocking_planner("ev", async move {
1991 Planner::ev(&*planner, &plan_id, parsed.as_of).await
1992 })
1993 .await?;
1994 to_value(&report)
1995 }
1996
1997 async fn handle_snapshot(&self, args: Value) -> Result<Value, McpError> {
1998 let parsed: SnapshotArgs = parse_args(args)?;
1999 let mut request = SnapshotRequest::new(PlanId(parsed.plan_id)).with_format(parsed.format);
2000 request.as_of = parsed.as_of;
2001 let planner = Arc::clone(&self.planner);
2002 let outcome = run_blocking_planner("snapshot", async move {
2003 Planner::snapshot(&*planner, request).await
2004 })
2005 .await?;
2006 to_value(&outcome)
2007 }
2008
2009 async fn handle_review(&self, args: Value) -> Result<Value, McpError> {
2018 let ReviewToolArgs {
2019 graph,
2020 plan_id,
2021 path,
2022 capacities,
2023 resource_key,
2024 project_buffer_pct,
2025 max_questions,
2026 } = parse_args(args)?;
2027 let request = review_request(capacities, resource_key, project_buffer_pct, max_questions)?;
2028 let audited_plan_id = plan_id.clone();
2029 let graph = match self.resolve_graph(graph, plan_id, path).await {
2030 Ok(graph) => graph,
2031 Err(err) => {
2032 if err.code != rmcp::model::ErrorCode::INVALID_PARAMS {
2035 let event =
2036 review_audit_event(Err(mcp_error_code(&err)), audited_plan_id, None);
2037 self.planner.record_audit(event).await;
2038 }
2039 return Err(err);
2040 }
2041 };
2042 let judge = self.judge.clone();
2043 let configured_model = judge.configured_model();
2044 let slots = self.review_slots.clone();
2045 let runtime = tokio::runtime::Handle::current();
2046 let outcome = spawn_analysis("review", move || {
2047 let calls_judge = matches!(judge, ReviewJudge::Model(_))
2052 && !crate::lint::lint(&graph)
2053 .findings
2054 .iter()
2055 .any(|f| f.severity == crate::lint::Severity::Error);
2056 let _slot = calls_judge
2057 .then(|| runtime.block_on(slots.acquire_owned()).ok())
2058 .flatten();
2059 runtime.block_on(crate::review::review(&graph, &request, judge.as_judge()))
2060 })
2061 .await?;
2062 let audit_outcome = match &outcome {
2063 Ok(report) => Ok(report),
2064 Err(err) => Err(Some(error_code(err))),
2065 };
2066 let event = review_audit_event(audit_outcome, audited_plan_id, configured_model);
2067 self.planner.record_audit(event).await;
2068 to_value(&outcome.map_err(planner_error_to_mcp)?)
2069 }
2070}
2071
2072fn review_request(
2074 capacities: Option<std::collections::BTreeMap<String, u32>>,
2075 resource_key: Option<String>,
2076 project_buffer_pct: Option<f32>,
2077 max_questions: Option<u16>,
2078) -> Result<ReviewRequest, McpError> {
2079 if let Some(cap) = max_questions {
2080 crate::review::check_max_questions(cap)
2081 .map_err(|reason| McpError::invalid_params(reason, None))?;
2082 }
2083 let capacities = match capacities {
2084 Some(capacities) => {
2085 let schedule = ScheduleRequest {
2086 capacities,
2087 resource_key: resource_key
2088 .unwrap_or_else(crate::resource_schedule::default_resource_key),
2089 project_buffer_pct: project_buffer_pct
2090 .unwrap_or_else(crate::resource_schedule::default_buffer_pct),
2091 };
2092 check_schedule_params(&schedule)?;
2093 Some(schedule)
2094 }
2095 None if resource_key.is_some() || project_buffer_pct.is_some() => {
2096 return Err(McpError::invalid_params(
2097 "resource_key and project_buffer_pct require capacities",
2098 None,
2099 ));
2100 }
2101 None => None,
2102 };
2103 Ok(ReviewRequest {
2104 capacities,
2105 max_questions,
2106 })
2107}
2108
2109fn review_audit_event(
2115 outcome: Result<&ReviewReport, Option<String>>,
2116 plan_id: Option<String>,
2117 configured_model: Option<String>,
2118) -> AuditEvent {
2119 let payload = match outcome {
2120 Ok(report) => json!({
2121 "status": report.status,
2122 "code": null,
2123 "failure_class": report.failure_class,
2124 "plan_id": plan_id,
2125 "question_count": report.question_count,
2126 "prompt_hash": report.prompt_hash,
2127 "model": report.model.clone().or(if report.jev_called { configured_model } else { None }),
2128 "endpoint": report.endpoint,
2129 "jev_called": report.jev_called,
2130 }),
2131 Err(code) => json!({
2132 "status": "error",
2133 "code": code,
2134 "failure_class": null,
2135 "plan_id": plan_id,
2136 "question_count": 0,
2137 "prompt_hash": null,
2138 "model": null,
2139 "endpoint": null,
2140 "jev_called": false,
2141 }),
2142 };
2143 AuditEvent::new("plan.review").with_payload(payload)
2144}
2145
2146fn mcp_error_code(err: &McpError) -> Option<String> {
2149 let (prefix, _) = err.message.split_once(':')?;
2150 (!prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_uppercase() || c == '_'))
2151 .then(|| prefix.to_string())
2152}
2153
2154fn error_code(err: &PlannerError) -> String {
2156 let text = err.to_string();
2157 text.split(':')
2158 .next()
2159 .unwrap_or_default()
2160 .trim()
2161 .to_string()
2162}
2163
2164fn check_schedule_params(request: &ScheduleRequest) -> Result<(), McpError> {
2167 crate::resource_schedule::check_buffer_pct(request.project_buffer_pct)
2168 .map_err(|reason| McpError::invalid_params(reason, None))
2169}
2170
2171fn check_compare_weights(weights: &CompareWeights) -> Result<(), McpError> {
2173 let criteria = [
2174 ("makespan", weights.makespan),
2175 ("p80", weights.p80),
2176 ("criticality_risk", weights.criticality_risk),
2177 ("total_effort", weights.total_effort),
2178 ("peak_load", weights.peak_load),
2179 ];
2180 for (name, value) in criteria {
2181 if !value.is_finite() || value < 0.0 {
2182 return Err(McpError::invalid_params(
2183 format!("weights.{name} must be finite and >= 0"),
2184 None,
2185 ));
2186 }
2187 }
2188 Ok(())
2189}
2190
2191async fn run_blocking_planner<T, Fut>(what: &'static str, call: Fut) -> Result<T, McpError>
2198where
2199 T: Send + 'static,
2200 Fut: std::future::Future<Output = Result<T, PlannerError>> + Send + 'static,
2201{
2202 let runtime = tokio::runtime::Handle::current();
2203 spawn_analysis(what, move || runtime.block_on(call))
2204 .await?
2205 .map_err(planner_error_to_mcp)
2206}
2207
2208async fn run_blocking<T, F>(work: F) -> Result<T, McpError>
2210where
2211 T: Send + 'static,
2212 F: FnOnce() -> Result<T, PlannerError> + Send + 'static,
2213{
2214 spawn_analysis("analysis", work)
2215 .await?
2216 .map_err(planner_error_to_mcp)
2217}
2218
2219async fn spawn_analysis<T, F>(what: &'static str, work: F) -> Result<T, McpError>
2223where
2224 T: Send + 'static,
2225 F: FnOnce() -> T + Send + 'static,
2226{
2227 tokio::task::spawn_blocking(work).await.map_err(|e| {
2228 tracing::error!(error = %e, "{what} task failed");
2229 McpError::internal_error(format!("{what} task failed"), None)
2230 })
2231}
2232
2233impl ServerHandler for PlanServer {
2238 fn get_info(&self) -> ServerConfig {
2239 let mut server_info =
2240 Implementation::new(self.server_name.clone(), self.server_version.clone());
2241 server_info.title = Some("cpm-planner".to_string());
2242 server_info.description = Some(
2243 "MCP server exposing the open-source Praxec CPM planner via twenty-three tools."
2244 .to_string(),
2245 );
2246
2247 let mut info = InitializeResult::default();
2248 info.protocol_version = ProtocolVersion::default();
2249 info.capabilities = ServerCapabilities::builder().enable_tools().build();
2250 info.server_info = server_info;
2251 info.instructions = Some(instructions().to_string());
2252 info
2253 }
2254
2255 async fn initialize(
2256 &self,
2257 request: InitializeRequestParams,
2258 context: RequestContext<RoleServer>,
2259 ) -> Result<InitializeResult, McpError> {
2260 if context.peer.peer_info().is_none() {
2261 context.peer.set_peer_info(request);
2262 }
2263 Ok(self.get_info())
2264 }
2265
2266 async fn list_tools(
2267 &self,
2268 _request: Option<PaginatedRequestParams>,
2269 _context: RequestContext<RoleServer>,
2270 ) -> Result<ListToolsResult, McpError> {
2271 Ok(ListToolsResult::with_all_items(plan_tool_definitions()))
2272 }
2273
2274 async fn call_tool(
2275 &self,
2276 request: CallToolRequestParams,
2277 _context: RequestContext<RoleServer>,
2278 ) -> Result<CallToolResponse, McpError> {
2279 self.dispatch_call(request)
2280 .await
2281 .map(|value| CallToolResult::structured(value).into())
2282 }
2283
2284 fn get_tool(&self, name: &str) -> Option<Tool> {
2285 plan_tool_definitions().into_iter().find(|t| t.name == name)
2286 }
2287
2288 async fn on_initialized(&self, _context: NotificationContext<RoleServer>) {
2289 tracing::info!("cpm-planner client initialized");
2290 }
2291}
2292
2293fn parse_args<T: serde::de::DeserializeOwned>(args: Value) -> Result<T, McpError> {
2299 serde_json::from_value(args)
2300 .map_err(|e| McpError::invalid_params(format!("invalid arguments: {e}"), None))
2301}
2302
2303fn ttl_from_seconds(secs: Option<u64>) -> Result<Option<Duration>, McpError> {
2309 match secs {
2310 None => Ok(None),
2311 Some(0) => Err(McpError::invalid_params(
2312 "ttl_seconds must be >= 1 when provided",
2313 None,
2314 )),
2315 Some(s) => Ok(Some(Duration::from_secs(s))),
2316 }
2317}
2318
2319fn to_value<T: Serialize>(value: &T) -> Result<Value, McpError> {
2324 serde_json::to_value(value)
2325 .map_err(|e| McpError::internal_error(format!("response serialisation failed: {e}"), None))
2326}
2327
2328fn planner_error_to_mcp(err: PlannerError) -> McpError {
2337 McpError::internal_error(err.to_string(), None)
2338}
2339
2340fn instructions() -> &'static str {
2343 r#"This is the cpm-planner MCP server — the open-source CPM planner.
2344
2345Tools (twenty-three total, all `plan.<verb>`):
2346 plan.submit — submit a PlanGraph, get a plan_id (idempotent on identical graphs)
2347 a prerequisite is an id string or {id, consumes?, kind?: artifact|interface, lag_hours?}; a deliverable's duration_hours (calendar time; when absent the default is the effort estimate, explicit or estimator-derived) and lag_hours (minimum wait after a prerequisite finishes) drive the schedule
2348 a milestone (milestone: true) is zero-length unless you give it an estimate or duration; it is still an ordinary deliverable someone must complete (accept or mark Complete), and it is not leased if metadata.kind=manual
2349 an optional earning_rule (zero_hundred default: 100% only when complete; fifty_fifty: 50% once in progress or any earned_pct is reported; weighted: the reported earned_pct) sets how earned value credits progress; it is part of the plan's identity
2350 an optional estimate {optimistic, likely, pessimistic} (0 <= optimistic <= likely <= pessimistic) is a three-point effort estimate; scheduled length precedence is duration_hours > estimated_effort_hours > estimate.likely > 0 for a milestone > estimator default, and Monte Carlo samples the estimate only when neither duration_hours nor estimated_effort_hours is set
2351 limits: at most 5000 deliverables; every hour value (effort, duration, lag, estimate) must be finite and between 0 and 1000000 (INVALID_GRAPH)
2352 plan.acquire_cohort — atomically acquire ready deliverables with no conflicting file claims (an owned_files entry may be {path, mode: "append"}: append claims on one path may be co-leased and are listed in the response's shared_paths; exclusive claims never overlap anything at once; plan.submit accepts a shared file only when the claimants are ordered by prerequisites, or all claims are append); a call that leases at least one deliverable is execution-starting: it takes the plan's first earned-value baseline in auto mode and reports baseline_taken {baseline_number, as_of, reason:"first_execution"} (in refuse mode it fails with BASELINE_REQUIRED unless the plan already has one)
2353 plan.heartbeat — refresh a held lock's TTL
2354 plan.mark_status — set a deliverable's status (Complete/Failed releases the lock); lockless Complete/Ready/InProgress requires complete prerequisites (PREREQUISITES_INCOMPLETE) and is audited; a lockless InProgress persists across restarts and is not leasable until handed back with a lockless Ready (plan.force_release does not affect it)
2355 optional earned-value progress: earned_pct (integer 0..100, only with in_progress; accepted and ignored with complete), actual_effort_hours (total so far, finite 0..1000000; replaces leased hours as actual cost), evidence (<= 2048 chars, appended to the deliverable's list, at most 100 entries); violations are INVALID_ACTUALS. plan.select copies the actuals of every deliverable whose Complete status it carries (leased hours add up; the carried deliverable's reported earned_pct and hours win; the newest 100 evidence entries are kept). Every lease that ends (complete, failed, force_release, expiry, accept override, forced revise/select/archive) adds its hours (up to expiry for a lapsed lease) to the deliverable's leased hours; a mark to in_progress or complete is execution-starting: in auto mode it takes the plan's first baseline (reported as baseline_taken) if none exists, in refuse mode it is BASELINE_REQUIRED
2356 plan.status — read-only snapshot ([id, status, attempt_count, failure_count, lapse_count] rows, critical_path (one real chain, always __start__ to __finish__), critical_ids, per-deliverable schedule (es/ef/ls/lf/float, hours; synthetic __start__/__finish__ endpoint rows have synthetic=true and critical=true; critical_ids lists only real deliverables), the ready set in float-ascending order (smallest float first, then smallest latest start, then id; acquire_cohort still leases by longest remaining tail), plan_complete, milestones (one row per `milestone: true` deliverable, or metadata.milestone == true: id, critical_path from __start__ to it, hours = its earliest finish, complete), held locks, baseline (the latest {baseline_number, as_of}, or null before the first); for a named variant also name, variant, selected and definition_drift). __start__ and __finish__ are reserved deliverable ids (INVALID_GRAPH)
2357 definition_drift: null when unknown (no root for the variant's project, no tracked file, file missing/unreadable/over 8 MiB); false when the file matches the last synced/exported bytes or its graph equals the head graph (re-formatting is not drift); true when the file's graph differs from the head or does not parse — so after an inline revise it is true until plan.export or plan.sync {path}
2358 plan.get — return the submitted PlanGraph (deliverables, estimates, files, metadata) for a plan_id
2359 plan.force_release — operator escape hatch; emits audit event with `reason`; optional reset_counters:true also clears lapse/failure counters and revives a circuit-broken deliverable
2360 plan.accept — a manager/owner marks a deliverable Complete without a lease (audited; evidence required; override_lock to take over a live lease)
2361 plan.lint — static checks without submitting: cycles (with the loop), redundant edges, edges without rationale, interface edges not targeting a contract, deliverables feeding no milestone, unordered file overlaps; linting by plan_id also adds NO_BASELINE (warning) when the plan is executable and has progress but no earned-value baseline
2362 plan.schedule — level a graph against resource capacities (`metadata.owner` by default): makespan, per-deliverable start/finish, per-resource load, driving chain (dependency vs resource waits), project and feeding buffers; capacities is required and every resource carrying work needs >= 1 unit (INVALID_CAPACITIES: lists the missing ones); project_buffer_pct 0..100 (default 25)
2363 plan.simulate — read-only what-if: lint, critical path, schedule, milestones, optional resource schedule (schedule: same inputs as plan.schedule) and Monte Carlo (monte_carlo: iterations 1..50000, default 2000; seed, default 0xC0FFEE, reproducible on the same platform and build; iterations × (deliverables + prerequisite edges) must not exceed 200000000), and the scorecard; persists nothing
2364 plan.sync — register or update one variant of a named plan line from a plan file (path: .cpm-planner/plans/<name>/<variant>.json, tracked by content hash for drift; any name/variant/project given must match the path) or an inline graph (requires name; variant defaults to "main"); project defaults to the discovered root; force releases live locks of removed deliverables
2365 plan.list — list every plan line of project (default: discovered root), variants sorted; archived omitted unless include_archived
2366 plan.export — write the head graph of a plan_id to its variant file (or a confined path) and return the root-relative path; refuses another variant's tracked file or unsynced local edits unless force; the file is not synced (exporting to the variant's own tracked file records its hash)
2367 plan.revise — replace a plan's graph in place, carrying progress over; returns the new revision and diff; force releases live locks of removed deliverables
2368 plan.fork — copy a named variant's head graph, apply ordered edits, register it as a new draft (not selected) variant; an archived line is refused (ARCHIVE_REFUSED) before any file is written
2369 plan.select — make a variant its line's selected (only executable) variant, carrying progress over (Complete statuses of identically defined deliverables, with their earned-value actuals); force releases locks on the previous variant; selecting a variant that gains progress is execution-starting, so in auto mode a variant with no baseline takes one (reported as baseline_taken) and in refuse mode it is BASELINE_REQUIRED
2370 plan.archive — archive (archived defaults true) or unarchive a whole line or one variant; archived variants stay readable but refuse sync/select/execute
2371 plan.compare — compare stored plans (plan_ids: 2..16 distinct ids, or plan line name in project, default the discovered root, for every live variant, at most 16) on the scorecard: Pareto front, weighted rank, recommended; weights must be finite and >= 0; the Monte Carlo budget (200000000) is shared across variants
2372 plan.baseline — freeze the plan's CPM schedule (es/ef) and budgets (effort basis x metadata.cost_rate, default 1) as its next numbered earned-value baseline; optional start (RFC 3339, default now) and calendar {hours_per_day (0 < h <= 24, default 8), workdays (default mon..fri), utc_offset_minutes (default 0)} (omitted: wall-clock hours on the first baseline, the previous baseline's calendar on a re-baseline); optional backfill_from (RFC 3339) sets start retroactively and marks the baseline backfilled:true (reported by plan.ev) and must not be in the future or before the plan's creation time (INVALID_VALUE); re-baselining needs a non-blank reason (<= 2048 chars, INVALID_GRAPH otherwise) and keeps actuals and snapshots; baselines, actuals and snapshots belong to one variant, so a newly selected variant takes its own baseline 1 with no reason needed; selected, unarchived variant only; audited as plan.ev.baselined
2373 plan.ev — earned-value report against the latest baseline as of as_of (RFC 3339, default now; as_of is the PV status date, while EV and AC reflect progress and actuals recorded up to the moment the call runs): baseline_number, backfilled (true when plan.baseline was given backfill_from), bac, pv, ev, ac, sv, cv, spi, cpi, eac, etc, vac, tcpi, per-deliverable rows, critical_float_consumed_hours, alerts (SPI_BELOW_0_9 / CPI_BELOW_0_9 when below 0.9 on the two latest stored non-backfilled snapshots by as_of of the current baseline; the current reading is not one of them), excluded_unbaselined; AC sums every recorded hour of the plan (removed and unbaselined deliverables included, at rate 1 when the baseline has no row); a baselined deliverable removed by plan.revise reports status "removed" and keeps its earned percent at removal (100 if complete), and re-adding it restarts its earned percent (hours keep accumulating); a ratio with a zero denominator is null and explained in `undefined` (never NaN); read-only on any variant; NOT_BASELINED before plan.baseline
2374 plan.snapshot — compute the plan.ev report and append it as a snapshot (as_of is the PV status date; EV and AC are as recorded when the call runs, so a snapshot whose as_of is more than an hour before taken_at is marked backfilled: true, raises no alerts and is skipped by later alerts); returns summary (undefined explains each null ratio; alerts consider only readings up to its own position: this snapshot and the latest earlier one by as_of of the current baseline, so a backfill never takes alerts from newer readings), snapshot_count and export of the newest 100 snapshots by as_of (ties by taken_at), oldest first, so a backfilled as_of lands in date order; a backfill older than those 100 is stored and counted but not listed: a list of summaries (format "json", default) or a Markdown table with columns date, PV, EV, AC, SPI, CPI, EAC (format "markdown"); selected, unarchived variant only; NOT_BASELINED before plan.baseline
2375 plan.review — optional AI review (read-only; graph, plan_id or path; optional capacities/resource_key/project_buffer_pct and max_questions 1..64, default 64): lint always runs and lint errors return status invalid_graph with no judge call; otherwise ONE batched call to Jev (TypeSafe's calibrated-judgment model, via OpenRouter) scores likely false dependencies, missing dependencies, split and interface-split candidates and crash options. Probabilities are advisory, not facts. proposals are plan.fork edit lists, each verified by simulate (lints clean, shortens the makespan) and ranked by hours_saved / max(cost, 1). Without an OpenRouter key, or with an unusable LLM setting, status is review_unavailable with a reason, plus lint; a provider failure is review_unavailable with "<class>: <message>". The plan graph is sent to OpenRouter
2376 plan.submit with a `name` (optional `project`/`variant`, variant defaults to "main") registers a named variant instead of an unnamed plan
2377 plan.lint, plan.simulate and plan.review take exactly one of an inline graph, a stored plan_id, or a plan-file path; plan.schedule takes graph or plan_id; plan.schedule and plan.simulate reject what plan.submit rejects, and plan.lint reports it as findings
2378
2379Plan-as-code workflow: author the graph as
2380.cpm-planner/plans/<name>/<variant>.json, check it with plan.lint {path},
2381then register it with plan.sync {path} and execute its plan_id. Design many
2382variants and pick one: plan.fork creates a draft variant from edits,
2383plan.compare scores every live variant, and plan.select makes exactly one
2384executable. Never keep untracked scratch graphs — the files under
2385.cpm-planner/plans/ are the source of truth for definitions.
2386
2387Review workflow: plan.review {path} on a lint-clean plan; read findings as
2388advisory judgments (check them against what you know), apply a proposal you
2389agree with via plan.fork {edits} (or design the edits for split and
2390missing-dependency findings yourself), then plan.compare and plan.select.
2391
2392Leases default to 5 minutes. Pass ttl_seconds (≤ server max, default 8h)
2393on acquire/heartbeat for long-running work, and heartbeat at least every
2394ttl/3.
2395
2396Every plan's first execution takes an earned-value baseline automatically
2397(CPM_BASELINE_MODE=auto, the default), reported as baseline_taken on the
2398acquire/mark/select response; set CPM_BASELINE_MODE=refuse to make those
2399calls fail with BASELINE_REQUIRED until plan.baseline is called. plan.status
2400reports the plan's baseline (or null).
2401
2402Errors carry stable prefixes: LOCK_HELD, LOCK_NOT_HELD, LOCK_EXPIRED,
2403OVERLAP_DETECTED, MISSING_PREREQUISITE, PLAN_NOT_FOUND,
2404DELIVERABLE_NOT_FOUND, LAPSE_LIMIT, PREREQUISITES_INCOMPLETE, INVALID_GRAPH,
2405INVALID_CAPACITIES, VARIANT_NOT_SELECTED, ARCHIVE_REFUSED, INVALID_PATH,
2406INVALID_ACTUALS, BASELINE_REQUIRED, NOT_BASELINED, BACKEND_ERROR.
2407
2408DeliverableStatus is internally tagged on `status`:
2409 {"status":"pending"} | {"status":"ready"} | {"status":"in_progress"} |
2410 {"status":"complete"} | {"status":"failed","reason":"..."}
2411
2412Circuit-breaker (visible in plan.status): every lease increments
2413attempt_count (telemetry). An EXPLICIT mark_status failed increments
2414failure_count — a ready deliverable with 3 failed attempts is auto-failed
2415by the next acquire_cohort (reason "circuit-break: exceeded 3 failed
2416attempts") instead of being re-leased forever. A lease that lapses via
2417TTL with no terminal mark (driver killed/timed out) increments
2418lapse_count instead: environmental losses never trip the failure breaker,
2419but at 10 lapses acquire_cohort stops re-leasing that deliverable: it is
2420skipped (the rest of the plan stays leasable) and reported in the acquire
2421response's `blocked` list as {id, code:"LAPSE_LIMIT", reason}. Fix the
2422environment, then clear it with plan.force_release {reset_counters: true}.
2423The response also carries scalar `blocked_count` and `needs_operator`.
2424exhausted:true with needs_operator:true means the plan is stalled on
2425lapse-limited deliverables (clear with plan.force_release {reset_counters:
2426true}), not drained.
2427
2428Targeted acquire: plan.acquire_cohort accepts optional `ids` (only those
2429deliverables are considered; unknown id -> DELIVERABLE_NOT_FOUND) and
2430`filter: {"metadata": {key: value}}` (only deliverables whose metadata
2431equals every pair; others are silently skipped). ids that do not match
2432filter.metadata are ignored (not reported in blocked). Deliverables with
2433metadata.kind = "manual" are never leased. With `ids`, each requested id
2434that is not leased appears in `blocked` with code MANUAL, NOT_READY,
2435LOCKED, LAPSE_LIMIT, FILE_CONFLICT or MAX_COUNT.
2436"#
2437}