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, PlanRef, 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 #[serde(default)]
196 plan_id: Option<String>,
197 #[serde(default)]
198 plan: Option<String>,
199 #[serde(default)]
200 active: bool,
201 #[serde(default)]
202 project: Option<String>,
203 caller_id: String,
204 max_count: usize,
205 #[serde(default)]
206 ids: Option<Vec<String>>,
207 #[serde(default)]
208 filter: Option<AcquireFilter>,
209 #[serde(default)]
210 ttl_seconds: Option<u64>,
211}
212
213#[derive(Debug, Deserialize)]
214#[serde(deny_unknown_fields)]
215struct AcquireFilter {
216 #[serde(default)]
217 metadata: Option<serde_json::Map<String, Value>>,
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(deny_unknown_fields)]
222struct AcceptArgs {
223 #[serde(default)]
224 plan_id: Option<String>,
225 #[serde(default)]
226 plan: Option<String>,
227 #[serde(default)]
228 active: bool,
229 #[serde(default)]
230 project: Option<String>,
231 deliverable_id: String,
232 accepted_by: String,
233 evidence: String,
234 #[serde(default)]
235 override_lock: bool,
236}
237
238#[derive(Debug, Deserialize)]
239#[serde(deny_unknown_fields)]
240struct HeartbeatArgs {
241 #[serde(default)]
242 plan_id: Option<String>,
243 #[serde(default)]
244 plan: Option<String>,
245 #[serde(default)]
246 active: bool,
247 #[serde(default)]
248 project: Option<String>,
249 deliverable_id: String,
250 caller_id: String,
251 #[serde(default)]
252 ttl_seconds: Option<u64>,
253}
254
255#[derive(Debug, Deserialize)]
256#[serde(deny_unknown_fields)]
257struct MarkStatusArgs {
258 #[serde(default)]
259 plan_id: Option<String>,
260 #[serde(default)]
261 plan: Option<String>,
262 #[serde(default)]
263 active: bool,
264 #[serde(default)]
265 project: Option<String>,
266 deliverable_id: String,
267 caller_id: String,
268 status: DeliverableStatus,
269 #[serde(default)]
272 earned_pct: Option<f64>,
273 #[serde(default)]
274 actual_effort_hours: Option<f64>,
275 #[serde(default)]
276 evidence: Option<String>,
277}
278
279#[derive(Debug, Deserialize)]
280#[serde(deny_unknown_fields)]
281struct StatusArgs {
282 #[serde(default)]
283 plan_id: Option<String>,
284 #[serde(default)]
285 plan: Option<String>,
286 #[serde(default)]
287 active: bool,
288 #[serde(default)]
289 project: Option<String>,
290}
291
292#[derive(Debug, Deserialize)]
293#[serde(deny_unknown_fields)]
294struct GetArgs {
295 #[serde(default)]
296 plan_id: Option<String>,
297 #[serde(default)]
298 plan: Option<String>,
299 #[serde(default)]
300 active: bool,
301 #[serde(default)]
302 project: Option<String>,
303}
304
305#[derive(Debug, Deserialize)]
306#[serde(deny_unknown_fields)]
307struct ForceReleaseArgs {
308 #[serde(default)]
309 plan_id: Option<String>,
310 #[serde(default)]
311 plan: Option<String>,
312 #[serde(default)]
313 active: bool,
314 #[serde(default)]
315 project: Option<String>,
316 deliverable_id: String,
317 reason: String,
318 #[serde(default)]
319 reset_counters: bool,
320}
321
322#[derive(Debug, Deserialize)]
325#[serde(deny_unknown_fields)]
326struct GraphOrPlanIdArgs {
327 #[serde(default)]
328 graph: Option<PlanGraph>,
329 #[serde(default)]
330 plan_id: Option<String>,
331 #[serde(default)]
332 path: Option<String>,
333}
334
335#[derive(Debug, Deserialize)]
339#[serde(deny_unknown_fields)]
340struct ScheduleToolArgs {
341 #[serde(default)]
342 graph: Option<PlanGraph>,
343 #[serde(default)]
344 plan_id: Option<String>,
345 capacities: std::collections::BTreeMap<String, u32>,
346 #[serde(default = "crate::resource_schedule::default_resource_key")]
347 resource_key: String,
348 #[serde(default = "crate::resource_schedule::default_buffer_pct")]
349 project_buffer_pct: f32,
350}
351
352#[derive(Debug, Deserialize)]
355#[serde(deny_unknown_fields)]
356struct SimulateToolArgs {
357 #[serde(default)]
358 graph: Option<PlanGraph>,
359 #[serde(default)]
360 plan_id: Option<String>,
361 #[serde(default)]
362 path: Option<String>,
363 #[serde(default)]
364 schedule: Option<ScheduleRequest>,
365 #[serde(default)]
366 monte_carlo: Option<MonteCarloRequest>,
367}
368
369#[derive(Debug, Deserialize)]
374#[serde(deny_unknown_fields)]
375struct SyncArgs {
376 #[serde(default)]
377 path: Option<String>,
378 #[serde(default)]
379 graph: Option<PlanGraph>,
380 #[serde(default)]
381 project: Option<String>,
382 #[serde(default)]
383 name: Option<String>,
384 #[serde(default)]
385 variant: Option<String>,
386 #[serde(default)]
387 force: bool,
388}
389
390#[derive(Debug, Deserialize)]
392#[serde(deny_unknown_fields)]
393struct ListArgs {
394 #[serde(default)]
395 project: Option<String>,
396 #[serde(default)]
397 include_archived: bool,
398 #[serde(default)]
399 active: bool,
400}
401
402#[derive(Debug, Deserialize)]
404#[serde(deny_unknown_fields)]
405struct ExportArgs {
406 plan_id: String,
407 #[serde(default)]
408 path: Option<String>,
409 #[serde(default)]
410 force: bool,
411}
412
413#[derive(Debug, Deserialize)]
415#[serde(deny_unknown_fields)]
416struct ReviseArgs {
417 plan_id: String,
418 graph: PlanGraph,
419 #[serde(default)]
420 force: bool,
421}
422
423#[derive(Debug, Deserialize)]
426#[serde(deny_unknown_fields)]
427struct ForkArgs {
428 plan_id: String,
429 variant: String,
430 #[serde(default)]
431 edits: Vec<GraphEdit>,
432}
433
434#[derive(Debug, Deserialize)]
436#[serde(deny_unknown_fields)]
437struct SelectArgs {
438 plan_id: String,
439 #[serde(default)]
440 force: bool,
441}
442
443#[derive(Debug, Deserialize)]
445#[serde(deny_unknown_fields)]
446struct ArchiveArgs {
447 name: String,
448 #[serde(default)]
449 variant: Option<String>,
450 #[serde(default)]
451 project: Option<String>,
452 #[serde(default = "default_archived")]
453 archived: bool,
454 #[serde(default)]
455 force: bool,
456}
457
458fn default_archived() -> bool {
459 true
460}
461
462#[derive(Debug, Deserialize)]
464#[serde(deny_unknown_fields)]
465struct CompareArgs {
466 #[serde(default)]
467 plan_ids: Option<Vec<String>>,
468 #[serde(default)]
469 plan: Option<String>,
470 #[serde(default)]
471 project: Option<String>,
472 #[serde(default)]
473 schedule: Option<ScheduleRequest>,
474 #[serde(default)]
475 monte_carlo: Option<MonteCarloRequest>,
476 #[serde(default)]
477 weights: CompareWeights,
478}
479
480#[derive(Debug, Deserialize)]
484#[serde(deny_unknown_fields)]
485struct BaselineArgs {
486 #[serde(default)]
487 plan_id: Option<String>,
488 #[serde(default)]
489 plan: Option<String>,
490 #[serde(default)]
491 active: bool,
492 #[serde(default)]
493 project: Option<String>,
494 #[serde(default)]
495 start: Option<DateTime<Utc>>,
496 #[serde(default)]
497 calendar: Option<Calendar>,
498 #[serde(default)]
499 reason: Option<String>,
500 #[serde(default)]
501 backfill_from: Option<DateTime<Utc>>,
502}
503
504#[derive(Debug, Deserialize)]
506#[serde(deny_unknown_fields)]
507struct EvArgs {
508 #[serde(default)]
509 plan_id: Option<String>,
510 #[serde(default)]
511 plan: Option<String>,
512 #[serde(default)]
513 active: bool,
514 #[serde(default)]
515 project: Option<String>,
516 #[serde(default)]
517 as_of: Option<DateTime<Utc>>,
518}
519
520#[derive(Debug, Deserialize)]
522#[serde(deny_unknown_fields)]
523struct SnapshotArgs {
524 #[serde(default)]
525 plan_id: Option<String>,
526 #[serde(default)]
527 plan: Option<String>,
528 #[serde(default)]
529 active: bool,
530 #[serde(default)]
531 project: Option<String>,
532 #[serde(default)]
533 as_of: Option<DateTime<Utc>>,
534 #[serde(default)]
535 format: SnapshotFormat,
536}
537
538#[derive(Debug, Deserialize)]
541#[serde(deny_unknown_fields)]
542struct ReviewToolArgs {
543 #[serde(default)]
544 graph: Option<PlanGraph>,
545 #[serde(default)]
546 plan_id: Option<String>,
547 #[serde(default)]
548 path: Option<String>,
549 #[serde(default)]
550 capacities: Option<std::collections::BTreeMap<String, u32>>,
551 #[serde(default)]
552 resource_key: Option<String>,
553 #[serde(default)]
554 project_buffer_pct: Option<f32>,
555 #[serde(default)]
556 max_questions: Option<u16>,
557}
558
559#[derive(Debug, Serialize)]
564struct SubmitResponse {
565 plan_id: String,
566}
567
568#[derive(Debug, Serialize)]
569struct OkResponse {
570 ok: bool,
571 #[serde(skip_serializing_if = "Option::is_none")]
572 baseline_taken: Option<BaselineTaken>,
573}
574
575impl OkResponse {
576 fn new() -> Self {
577 Self {
578 ok: true,
579 baseline_taken: None,
580 }
581 }
582
583 fn with_baseline_taken(baseline_taken: Option<BaselineTaken>) -> Self {
584 Self {
585 ok: true,
586 baseline_taken,
587 }
588 }
589}
590
591#[derive(Debug, Serialize)]
592struct ExportResponse {
593 path: String,
594}
595
596#[derive(Debug, Serialize)]
597struct ReviseResponse {
598 revision: u32,
599 diff: crate::revise::RevisionDiff,
600}
601
602fn graph_schema() -> Value {
612 json!({
613 "type": "object",
614 "properties": {
615 "deliverables": {
616 "type": "array",
617 "maxItems": 5000,
618 "items": {
619 "type": "object",
620 "properties": {
621 "id": { "type": "string" },
622 "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": [
623 { "type": "string" },
624 { "type": "object", "properties": {
625 "path": { "type": "string" },
626 "mode": { "type": "string", "enum": ["exclusive", "append"] }
627 }, "required": ["path"], "additionalProperties": false }
628 ] } },
629 "prerequisites": { "type": "array", "description": "Each item is a deliverable id string, or an object {id, consumes?, kind?: artifact|interface, lag_hours?}.", "items": { "oneOf": [
630 { "type": "string" },
631 { "type": "object", "properties": {
632 "id": { "type": "string" },
633 "consumes": { "type": "string" },
634 "kind": { "type": "string", "enum": ["artifact", "interface"] },
635 "lag_hours": { "type": "number", "minimum": 0, "maximum": 1000000 }
636 }, "required": ["id"], "additionalProperties": false }
637 ] } },
638 "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." },
639 "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." },
640 "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": {
641 "optimistic": { "type": "number", "minimum": 0, "maximum": 1000000 },
642 "likely": { "type": "number", "minimum": 0, "maximum": 1000000 },
643 "pessimistic": { "type": "number", "minimum": 0, "maximum": 1000000 }
644 }, "required": ["optimistic", "likely", "pessimistic"], "additionalProperties": false },
645 "milestone": { "type": "boolean", "description": "Acceptance point; reported in plan.status milestones with its own critical path." },
646 "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." },
647 "metadata": {}
648 },
649 "required": ["id", "owned_files", "prerequisites"]
650 }
651 },
652 "max_chained_dispatch": { "type": ["integer", "null"] }
653 },
654 "required": ["deliverables"]
655 })
656}
657
658fn graph_selector_schema() -> Value {
663 let mut schema = graph_schema();
664 if let Some(obj) = schema.as_object_mut() {
665 obj.insert(
666 "description".to_string(),
667 json!(
668 "An inline plan graph to analyse. Provide exactly one of graph, plan_id, or path."
669 ),
670 );
671 }
672 schema
673}
674
675fn plan_id_schema() -> Value {
676 json!({
677 "type": "string",
678 "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."
679 })
680}
681
682fn path_schema() -> Value {
683 json!({
684 "type": "string",
685 "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."
686 })
687}
688
689fn with_plan_selector(mut properties: Value) -> Value {
694 if let Some(props) = properties.as_object_mut() {
695 props.insert(
696 "plan_id".to_string(),
697 json!({
698 "type": "string",
699 "description": "A stored plan id (from plan.submit/plan.sync). Provide exactly one of plan_id, plan, or active."
700 }),
701 );
702 props.insert(
703 "plan".to_string(),
704 json!({
705 "type": "string",
706 "description": "Plan line name; resolves to its selected variant. Use instead of plan_id."
707 }),
708 );
709 props.insert(
710 "active".to_string(),
711 json!({
712 "type": "boolean",
713 "description": "Resolve to the project's one unarchived, incomplete line (NO_ACTIVE_PLAN / AMBIGUOUS_ACTIVE_PLAN otherwise). Use instead of plan_id."
714 }),
715 );
716 props.insert(
717 "project".to_string(),
718 json!({
719 "type": "string",
720 "description": "Project for plan or active; default the discovered root."
721 }),
722 );
723 }
724 properties
725}
726
727fn edits_schema() -> Value {
728 json!({
729 "type": "array",
730 "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}.",
731 "items": { "type": "object" }
732 })
733}
734
735fn weights_schema() -> Value {
736 let criterion = || json!({ "type": "number", "minimum": 0 });
737 json!({
738 "type": "object",
739 "description": "Per-criterion weights for the combined score (lower is better). Each must be finite and >= 0.",
740 "properties": {
741 "makespan": criterion(),
742 "p80": criterion(),
743 "criticality_risk": criterion(),
744 "total_effort": criterion(),
745 "peak_load": criterion()
746 },
747 "additionalProperties": false
748 })
749}
750
751fn calendar_schema() -> Value {
752 json!({
753 "type": "object",
754 "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.",
755 "properties": {
756 "hours_per_day": { "type": "number", "exclusiveMinimum": 0, "maximum": 24, "description": "Default 8." },
757 "workdays": { "type": "array", "minItems": 1, "items": { "type": "string", "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] }, "description": "Default mon..fri." },
758 "utc_offset_minutes": { "type": "integer", "minimum": -1440, "maximum": 1440, "description": "Local offset from UTC; default 0." }
759 },
760 "additionalProperties": false
761 })
762}
763
764fn capacities_schema() -> Value {
765 json!({
766 "type": "object",
767 "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.",
768 "additionalProperties": { "type": "integer", "minimum": 0 }
769 })
770}
771
772pub fn plan_tool_definitions() -> Vec<Tool> {
783 vec![
784 Tool::new(
785 Cow::Borrowed(TOOL_SUBMIT),
786 Cow::Borrowed(
787 "Submit a plan graph and receive a plan_id. \
788 Idempotent: identical graphs return the same plan_id. \
789 With `name` (and optional `project`/`variant`, variant \
790 defaults to \"main\"), registers a named variant instead: \
791 project defaults to the discovered project root.",
792 ),
793 schema_object(json!({
794 "type": "object",
795 "properties": {
796 "graph": graph_schema(),
797 "project": { "type": "string", "description": "Project key; required with `name` when no project root is discovered." },
798 "name": { "type": "string", "description": "Plan line name; when present the graph is synced as a named variant." },
799 "variant": { "type": "string", "description": "Variant name of a named plan (default \"main\")." }
800 },
801 "required": ["graph"],
802 "additionalProperties": false
803 })),
804 ),
805 Tool::new(
806 Cow::Borrowed(TOOL_ACQUIRE_COHORT),
807 Cow::Borrowed(
808 "Acquire up to max_count ready deliverables with no conflicting file claims \
809 atomically. Returns the cohort plus per-deliverable locks. \
810 A deliverable explicitly marked failed 3 times is \
811 circuit-broken to failed instead of re-leased; leases lost \
812 environmentally (TTL lapse, no terminal mark) never trip \
813 that breaker but are bounded separately (LAPSE_LIMIT at 10). \
814 Optional ttl_seconds sets the lease TTL (clamped to the \
815 server maximum). Optional ids targets specific deliverables \
816 and filter.metadata narrows by metadata equality; \
817 deliverables with metadata.kind = \"manual\" are never \
818 leased. ids that do not match filter.metadata are ignored \
819 (not reported in blocked). The response carries blocked \
820 [{id, code, reason}], blocked_count, and needs_operator \
821 (true when any blocked code is LAPSE_LIMIT: clear with \
822 plan.force_release {reset_counters: true}). \
823 Select the plan by plan_id, plan line name, or active: true.",
824 ),
825 schema_object(json!({
826 "type": "object",
827 "properties": with_plan_selector(json!({
828 "caller_id": { "type": "string" },
829 "max_count": { "type": "integer", "minimum": 1 },
830 "ids": { "type": "array", "minItems": 1, "items": { "type": "string" } },
831 "ttl_seconds": { "type": "integer", "minimum": 1 },
832 "filter": {
833 "type": "object",
834 "properties": { "metadata": { "type": "object" } },
835 "additionalProperties": false
836 }
837 })),
838 "required": ["caller_id", "max_count"]
839 })),
840 ),
841 Tool::new(
842 Cow::Borrowed(TOOL_HEARTBEAT),
843 Cow::Borrowed(
844 "Refresh the TTL on a held lock; LOCK_NOT_HELD or LOCK_EXPIRED on failure. \
845 Optional ttl_seconds sets the new TTL (clamped to the server \
846 maximum); without it the lease is never shortened. \
847 Select the plan by plan_id, plan line name, or active: true.",
848 ),
849 schema_object(json!({
850 "type": "object",
851 "properties": with_plan_selector(json!({
852 "deliverable_id": { "type": "string" },
853 "caller_id": { "type": "string" },
854 "ttl_seconds": { "type": "integer", "minimum": 1 }
855 })),
856 "required": ["deliverable_id", "caller_id"]
857 })),
858 ),
859 Tool::new(
860 Cow::Borrowed(TOOL_MARK_STATUS),
861 Cow::Borrowed(
862 "Set a deliverable's status. Complete/Failed releases the lock; \
863 caller_id mismatch yields LOCK_NOT_HELD. Without a lock, \
864 Complete, Ready or InProgress requires all prerequisites \
865 complete (PREREQUISITES_INCOMPLETE) and is audited; other \
866 lockless marks are audited too, and an already-complete deliverable \
867 cannot be changed (LOCK_NOT_HELD). A lockless in_progress persists \
868 across restarts and is not leasable; hand it back with a lockless \
869 ready (or other status) mark. plan.force_release does not affect it. \
870 Optional earned-value progress: \
871 earned_pct (0..100, only with in_progress; ignored with complete), \
872 actual_effort_hours (total so far, 0..1000000; replaces leased hours \
873 as actual cost) and evidence (<= 2048 chars, appended to a list of \
874 at most 100); \
875 violations are INVALID_ACTUALS. Every lease that ends adds its hours \
876 to the deliverable's leased hours. \
877 Select the plan by plan_id, plan line name, or active: true.",
878 ),
879 schema_object(json!({
880 "type": "object",
881 "properties": with_plan_selector(json!({
882 "deliverable_id": { "type": "string" },
883 "caller_id": { "type": "string" },
884 "status": {
885 "type": "object",
886 "description": "Internally-tagged: {\"status\":\"pending|ready|in_progress|complete\"} or {\"status\":\"failed\",\"reason\":\"...\"}"
887 },
888 "earned_pct": {
889 "type": "integer",
890 "minimum": 0,
891 "maximum": 100,
892 "description": "Percent complete (used by the weighted earning rule). Only with status in_progress; accepted and ignored with complete."
893 },
894 "actual_effort_hours": {
895 "type": "number",
896 "minimum": 0,
897 "maximum": 1000000,
898 "description": "Total effort spent so far, in hours; replaces the stored value and takes precedence over leased hours for actual cost."
899 },
900 "evidence": {
901 "type": "string",
902 "maxLength": 2048,
903 "description": "One evidence note, appended to the deliverable's evidence list (at most 100 entries)."
904 }
905 })),
906 "required": ["deliverable_id", "caller_id", "status"]
907 })),
908 ),
909 Tool::new(
910 Cow::Borrowed(TOOL_STATUS),
911 Cow::Borrowed(
912 "Read-only snapshot: per-deliverable [id, status, attempt_count, \
913 failure_count, lapse_count] rows, critical_path (one real chain, always from __start__ to __finish__), \
914 critical_ids, per-deliverable schedule (es/ef/ls/lf/float, hours; \
915 synthetic __start__/__finish__ rows have synthetic=true), \
916 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. \
917 Select the plan by plan_id, plan line name, or active: true.",
918 ),
919 schema_object(json!({
920 "type": "object",
921 "properties": with_plan_selector(json!({})),
922 "additionalProperties": false
923 })),
924 ),
925 Tool::new(
926 Cow::Borrowed(TOOL_GET),
927 Cow::Borrowed(
928 "Return the stored PlanGraph (deliverables, estimates, files, \
929 metadata) for a plan selected by plan_id, plan line name, or \
930 active: true.",
931 ),
932 schema_object(json!({
933 "type": "object",
934 "properties": with_plan_selector(json!({})),
935 "additionalProperties": false
936 })),
937 ),
938 Tool::new(
939 Cow::Borrowed(TOOL_FORCE_RELEASE),
940 Cow::Borrowed(
941 "Operator escape hatch — release a lock regardless of caller. \
942 Emits an audit event carrying `reason`. \
943 Select the plan by plan_id, plan line name, or active: true.",
944 ),
945 schema_object(json!({
946 "type": "object",
947 "properties": with_plan_selector(json!({
948 "deliverable_id": { "type": "string" },
949 "reason": { "type": "string" },
950 "reset_counters": {
951 "type": "boolean",
952 "description": "Also clear lapse and failure counters (revives a circuit-broken deliverable)."
953 }
954 })),
955 "required": ["deliverable_id", "reason"]
956 })),
957 ),
958 Tool::new(
959 Cow::Borrowed(TOOL_ACCEPT),
960 Cow::Borrowed(
961 "Manager/owner acceptance: mark a deliverable Complete without \
962 holding its lease. Requires all prerequisites Complete and \
963 evidence; audited. A live lease held by someone else is refused \
964 (LOCK_HELD) unless override_lock is true. \
965 Select the plan by plan_id, plan line name, or active: true.",
966 ),
967 schema_object(json!({
968 "type": "object",
969 "properties": with_plan_selector(json!({
970 "deliverable_id": { "type": "string" },
971 "accepted_by": { "type": "string" },
972 "evidence": { "type": "string" },
973 "override_lock": {
974 "type": "boolean",
975 "description": "Take over a live lease held by another caller."
976 }
977 })),
978 "required": ["deliverable_id", "accepted_by", "evidence"]
979 })),
980 ),
981 Tool::new(
982 Cow::Borrowed(TOOL_LINT),
983 Cow::Borrowed(
984 "Lint a graph, stored plan, or plan file without creating one: cycles (with the \
985 loop), redundant edges, edges without rationale, interface edges \
986 not targeting a contract, deliverables feeding no milestone, and \
987 unordered file overlaps. Linting by plan_id also adds NO_BASELINE \
988 (warning) when the plan is executable and has recorded progress but no \
989 earned-value baseline. Provide exactly one of graph, plan_id, or path.",
990 ),
991 schema_object(json!({
992 "type": "object",
993 "properties": {
994 "graph": graph_selector_schema(),
995 "plan_id": plan_id_schema(),
996 "path": path_schema()
997 },
998 "additionalProperties": false
999 })),
1000 ),
1001 Tool::new(
1002 Cow::Borrowed(TOOL_SCHEDULE),
1003 Cow::Borrowed(
1004 "Level a graph or stored plan against resource capacities \
1005 (`metadata.owner` by default): makespan, per-deliverable \
1006 start/finish, per-resource load, the driving chain (dependency vs \
1007 resource waits), and project/feeding buffers. Provide exactly one \
1008 of graph or plan_id. capacities is required: every resource that \
1009 carries work needs at least 1 unit (otherwise INVALID_CAPACITIES: \
1010 lists the missing resources). plan.schedule rejects what \
1011 plan.submit rejects (INVALID_GRAPH:).",
1012 ),
1013 schema_object(json!({
1014 "type": "object",
1015 "properties": {
1016 "graph": graph_selector_schema(),
1017 "plan_id": plan_id_schema(),
1018 "capacities": capacities_schema(),
1019 "resource_key": {
1020 "type": "string",
1021 "description": "Metadata key naming a deliverable's resource (default \"owner\")."
1022 },
1023 "project_buffer_pct": {
1024 "type": "number",
1025 "minimum": 0,
1026 "maximum": 100,
1027 "description": "Project buffer as a percentage of the driving chain (default 25)."
1028 }
1029 },
1030 "required": ["capacities"],
1031 "additionalProperties": false
1032 })),
1033 ),
1034 Tool::new(
1035 Cow::Borrowed(TOOL_SIMULATE),
1036 Cow::Borrowed(
1037 "Read-only what-if for a graph, stored plan, or plan file (nothing is \
1038 persisted): lint, critical path, schedule, milestones, optional \
1039 resource schedule and Monte Carlo, and the scorecard. Provide exactly \
1040 one of graph, plan_id, or path. schedule takes the plan.schedule inputs \
1041 (capacities required; INVALID_CAPACITIES: when a working resource \
1042 has none). monte_carlo takes iterations (1..50000, default 2000) \
1043 and seed (default 0xC0FFEE); iterations × (deliverables + \
1044 prerequisite edges) must not exceed 200000000. plan.simulate \
1045 rejects what plan.submit rejects; lint errors or an invalid graph \
1046 are INVALID_GRAPH:.",
1047 ),
1048 schema_object(json!({
1049 "type": "object",
1050 "properties": {
1051 "graph": graph_selector_schema(),
1052 "plan_id": plan_id_schema(),
1053 "path": path_schema(),
1054 "schedule": {
1055 "type": "object",
1056 "description": "Level the plan (same inputs as plan.schedule).",
1057 "properties": {
1058 "capacities": capacities_schema(),
1059 "resource_key": { "type": "string", "description": "Metadata key naming a deliverable's resource (default \"owner\")." },
1060 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100, "description": "Project buffer as a percentage of the driving chain (default 25)." }
1061 },
1062 "required": ["capacities"],
1063 "additionalProperties": false
1064 },
1065 "monte_carlo": {
1066 "type": "object",
1067 "description": "Seeded Monte Carlo over three-point estimates. iterations × (deliverables + prerequisite edges) must not exceed 200000000.",
1068 "properties": {
1069 "iterations": { "type": "integer", "minimum": 1, "maximum": 50000, "description": "Simulated schedules (default 2000)." },
1070 "seed": { "type": "integer", "minimum": 0, "description": "RNG seed (default 12648430 = 0xC0FFEE); the same seed reproduces the output on the same platform and build." }
1071 },
1072 "additionalProperties": false
1073 }
1074 },
1075 "additionalProperties": false
1076 })),
1077 ),
1078 Tool::new(
1079 Cow::Borrowed(TOOL_SYNC),
1080 Cow::Borrowed(
1081 "Register or update one variant of a named plan line from a plan \
1082 file (`path`) or an inline `graph`. A path is read from \
1083 .cpm-planner/plans/<name>/<variant>.json and tracked by content hash \
1084 for drift detection. An inline graph requires `name`; `variant` \
1085 defaults to \"main\". `project` defaults to the discovered project \
1086 root. `force` releases live locks of removed deliverables.",
1087 ),
1088 schema_object(json!({
1089 "type": "object",
1090 "properties": {
1091 "path": path_schema(),
1092 "graph": graph_schema(),
1093 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
1094 "name": { "type": "string", "description": "Plan line name (required with an inline graph)." },
1095 "variant": { "type": "string", "description": "Variant name (default \"main\" for an inline graph)." },
1096 "force": { "type": "boolean", "description": "Release live locks of removed deliverables instead of refusing." }
1097 },
1098 "additionalProperties": false
1099 })),
1100 ),
1101 Tool::new(
1102 Cow::Borrowed(TOOL_LIST),
1103 Cow::Borrowed(
1104 "List every plan line of `project` (default: the discovered project \
1105 root), sorted by name with each line's variants sorted by variant. \
1106 Archived lines and variants are omitted unless include_archived. \
1107 With active: true, return only the project's active lines.",
1108 ),
1109 schema_object(json!({
1110 "type": "object",
1111 "properties": {
1112 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
1113 "include_archived": { "type": "boolean", "description": "Include archived lines and variants (default false)." },
1114 "active": { "type": "boolean", "description": "Return only the project's active lines (unarchived, incomplete); mutually exclusive with include_archived." }
1115 },
1116 "additionalProperties": false
1117 })),
1118 ),
1119 Tool::new(
1120 Cow::Borrowed(TOOL_EXPORT),
1121 Cow::Borrowed(
1122 "Write the head graph of `plan_id` under the project root and return its \
1123 root-relative path: to `path` when given (confined to \
1124 .cpm-planner/plans/<name>/<variant>.json), else to its own variant \
1125 file. Refuses (INVALID_PATH) another variant's tracked plan file, or \
1126 the variant's own file while it holds local edits never synced, \
1127 unless force. The written file is not synced; exporting to the \
1128 variant's own tracked file records its hash, so drift is false.",
1129 ),
1130 schema_object(json!({
1131 "type": "object",
1132 "properties": {
1133 "plan_id": { "type": "string" },
1134 "path": path_schema(),
1135 "force": { "type": "boolean", "description": "Overwrite another variant's tracked file or unsynced local edits." }
1136 },
1137 "required": ["plan_id"],
1138 "additionalProperties": false
1139 })),
1140 ),
1141 Tool::new(
1142 Cow::Borrowed(TOOL_REVISE),
1143 Cow::Borrowed(
1144 "Replace a plan's graph in place, carrying progress over: unchanged \
1145 deliverables keep status and counters; changed or reopened ones are \
1146 re-derived. Removed deliverables holding live locks are refused \
1147 (LOCK_HELD) unless force. Returns the new revision and the diff.",
1148 ),
1149 schema_object(json!({
1150 "type": "object",
1151 "properties": {
1152 "plan_id": { "type": "string" },
1153 "graph": graph_schema(),
1154 "force": { "type": "boolean", "description": "Release live locks of removed deliverables instead of refusing." }
1155 },
1156 "required": ["plan_id", "graph"],
1157 "additionalProperties": false
1158 })),
1159 ),
1160 Tool::new(
1161 Cow::Borrowed(TOOL_FORK),
1162 Cow::Borrowed(
1163 "Copy the head graph of a named variant, apply `edits` in order, and \
1164 register the result as a new draft (not selected) variant of the same \
1165 line. Fails if the variant already exists.",
1166 ),
1167 schema_object(json!({
1168 "type": "object",
1169 "properties": {
1170 "plan_id": { "type": "string", "description": "The named variant to copy." },
1171 "variant": { "type": "string", "description": "Name of the new draft variant." },
1172 "edits": edits_schema()
1173 },
1174 "required": ["plan_id", "variant"],
1175 "additionalProperties": false
1176 })),
1177 ),
1178 Tool::new(
1179 Cow::Borrowed(TOOL_SELECT),
1180 Cow::Borrowed(
1181 "Make the named variant owning `plan_id` its line's selected (the only \
1182 executable) variant. Progress carries over from the previously \
1183 selected variant. Live locks on the previous variant refuse selection \
1184 with LOCK_HELD unless force releases them (audited).",
1185 ),
1186 schema_object(json!({
1187 "type": "object",
1188 "properties": {
1189 "plan_id": { "type": "string" },
1190 "force": { "type": "boolean", "description": "Release live locks on the previously selected variant." }
1191 },
1192 "required": ["plan_id"],
1193 "additionalProperties": false
1194 })),
1195 ),
1196 Tool::new(
1197 Cow::Borrowed(TOOL_ARCHIVE),
1198 Cow::Borrowed(
1199 "Archive (archived defaults to true) or unarchive a whole plan line \
1200 (`variant` omitted) or one variant. Archived variants stay readable \
1201 but are hidden from plan.list unless include_archived, and refuse sync, \
1202 selection and execution (ARCHIVE_REFUSED).",
1203 ),
1204 schema_object(json!({
1205 "type": "object",
1206 "properties": {
1207 "name": { "type": "string", "description": "Plan line name." },
1208 "variant": { "type": "string", "description": "One variant; omitted archives the whole line." },
1209 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
1210 "archived": { "type": "boolean", "description": "False unarchives (default true)." },
1211 "force": { "type": "boolean", "description": "Release live locks on the selected variant when archiving a line." }
1212 },
1213 "required": ["name"],
1214 "additionalProperties": false
1215 })),
1216 ),
1217 Tool::new(
1218 Cow::Borrowed(TOOL_COMPARE),
1219 Cow::Borrowed(
1220 "Compare stored plans on the scorecard, returning the Pareto front, \
1221 weighted rank and a recommended plan. Give exactly one of `plan_ids` \
1222 (2 to 16 distinct plans) or `plan` (a line name in `project`: every \
1223 non-archived variant, at most 16). Read-only. Weights must be finite \
1224 and >= 0. With monte_carlo, iterations × (deliverables + prerequisite \
1225 edges) summed over all variants must not exceed 200000000.",
1226 ),
1227 schema_object(json!({
1228 "type": "object",
1229 "properties": {
1230 "plan_ids": { "type": "array", "minItems": 2, "maxItems": 16, "uniqueItems": true, "items": { "type": "string" } },
1231 "plan": { "type": "string", "description": "Plan line name; compares every live variant." },
1232 "project": { "type": "string", "description": "Project key of `plan`; defaults to the discovered project root." },
1233 "schedule": {
1234 "type": "object",
1235 "description": "Level every variant (same inputs as plan.schedule).",
1236 "properties": {
1237 "capacities": capacities_schema(),
1238 "resource_key": { "type": "string" },
1239 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100 }
1240 },
1241 "required": ["capacities"],
1242 "additionalProperties": false
1243 },
1244 "monte_carlo": {
1245 "type": "object",
1246 "description": "Run Monte Carlo on every variant (P80 then comes from it).",
1247 "properties": {
1248 "iterations": { "type": "integer", "minimum": 1, "maximum": 50000 },
1249 "seed": { "type": "integer", "minimum": 0 }
1250 },
1251 "additionalProperties": false
1252 },
1253 "weights": weights_schema()
1254 },
1255 "additionalProperties": false
1256 })),
1257 ),
1258 Tool::new(
1259 Cow::Borrowed(TOOL_BASELINE),
1260 Cow::Borrowed(
1261 "Freeze the plan's current CPM schedule (earliest start/finish) and budgets \
1262 (effort basis x metadata.cost_rate) as its next numbered baseline for earned \
1263 value. The first baseline is number 1; re-baselining needs a non-blank \
1264 `reason` (INVALID_GRAPH otherwise) and keeps actuals and snapshots. \
1265 Without `calendar`, a re-baseline keeps the previous baseline's calendar \
1266 (the first baseline counts wall-clock hours). `backfill_from` (RFC 3339) \
1267 sets the baseline's `start` retroactively and marks it `backfilled: true` \
1268 (reported by plan.ev); it must not be in the future or before the plan's \
1269 creation time (INVALID_VALUE). Execution-side: the plan must be its line's \
1270 selected, unarchived variant (VARIANT_NOT_SELECTED / ARCHIVE_REFUSED). \
1271 Audited as plan.ev.baselined. \
1272 Select the plan by plan_id, plan line name, or active: true.",
1273 ),
1274 schema_object(json!({
1275 "type": "object",
1276 "properties": with_plan_selector(json!({
1277 "start": { "type": "string", "format": "date-time", "description": "RFC 3339 instant the PV clock starts from; defaults to now." },
1278 "calendar": calendar_schema(),
1279 "reason": { "type": "string", "maxLength": 2048, "description": "Why the plan is re-baselined; required when a baseline exists." },
1280 "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." }
1281 })),
1282 "additionalProperties": false
1283 })),
1284 ),
1285 Tool::new(
1286 Cow::Borrowed(TOOL_EV),
1287 Cow::Borrowed(
1288 "Earned-value report against the plan's latest baseline as of `as_of` \
1289 (default now). `as_of` is the PV status date; EV and AC reflect progress \
1290 and actuals recorded up to the moment the call runs. Returns BAC, PV, EV, \
1291 AC, SV, CV, SPI, CPI, EAC, ETC, VAC, TCPI, per-deliverable rows (a \
1292 baselined deliverable later removed by plan.revise has status `removed` \
1293 and keeps the percent it had earned: 100 if complete), critical float \
1294 consumed, SPI_BELOW_0_9 / CPI_BELOW_0_9 alerts from the two latest \
1295 stored non-backfilled snapshots (by as_of) of the current baseline, and \
1296 deliverables added since the baseline. AC counts every recorded hour of \
1297 the plan, including removed and unbaselined deliverables (rate 1 when the \
1298 baseline has no row). A \
1299 ratio whose denominator is 0 is null and explained in `undefined`. \
1300 `backfilled` is true when the baseline was taken with `backfill_from`. \
1301 Read-only; works on any variant. \
1302 NOT_BASELINED before plan.baseline. \
1303 Select the plan by plan_id, plan line name, or active: true.",
1304 ),
1305 schema_object(json!({
1306 "type": "object",
1307 "properties": with_plan_selector(json!({
1308 "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." }
1309 })),
1310 "additionalProperties": false
1311 })),
1312 ),
1313 Tool::new(
1314 Cow::Borrowed(TOOL_SNAPSHOT),
1315 Cow::Borrowed(
1316 "Compute the earned-value report (as plan.ev) and append it as a \
1317 snapshot. `as_of` is the PV status date; EV and AC are the progress \
1318 recorded when the call runs, so a snapshot whose as_of is more than an \
1319 hour before its taken_at is marked `backfilled: true` and ignored by \
1320 alerts. Returns the snapshot summary (alerts consider this snapshot and \
1321 the latest earlier non-backfilled one by as_of of the current baseline) \
1322 and an export \
1323 of the newest 100 snapshots by as_of, oldest first (an older backfill \
1324 is counted but not listed): a list of \
1325 summaries (format json, default) or a Markdown table with columns \
1326 date, PV, EV, AC, SPI, CPI, EAC (format markdown). \
1327 Execution-side like plan.baseline. NOT_BASELINED before plan.baseline. \
1328 Select the plan by plan_id, plan line name, or active: true.",
1329 ),
1330 schema_object(json!({
1331 "type": "object",
1332 "properties": with_plan_selector(json!({
1333 "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." },
1334 "format": { "type": "string", "enum": ["json", "markdown"], "description": "Export format (default json)." }
1335 })),
1336 "additionalProperties": false
1337 })),
1338 ),
1339 Tool::new(
1340 Cow::Borrowed(TOOL_REVIEW),
1341 Cow::Borrowed(
1342 "Optional AI review of a graph, stored plan, or plan file (read-only; \
1343 provide exactly one of graph, plan_id, or path). Lint always runs; \
1344 lint errors return status invalid_graph without calling the judge. \
1345 Otherwise one batched call to Jev (calibrated judgments, via \
1346 OpenRouter) asks up to max_questions (1..64, default 64) questions \
1347 about likely false or missing dependencies, split and interface-split \
1348 candidates and crash options; findings carry advisory probabilities. \
1349 Proposals are plan.fork edit lists, each verified by simulate (lint \
1350 clean, makespan shorter) and ranked by hours_saved / max(cost, 1). \
1351 Without an OpenRouter key (or with an unusable LLM configuration) \
1352 status is review_unavailable with a reason, plus lint. The plan graph \
1353 (ids, descriptions, metadata) is sent to OpenRouter.",
1354 ),
1355 schema_object(json!({
1356 "type": "object",
1357 "properties": {
1358 "graph": graph_selector_schema(),
1359 "plan_id": plan_id_schema(),
1360 "path": path_schema(),
1361 "capacities": {
1362 "type": "object",
1363 "description": "Optional: level the plan so makespans account for resources (same rules as plan.schedule capacities).",
1364 "additionalProperties": { "type": "integer", "minimum": 0 }
1365 },
1366 "resource_key": { "type": "string", "description": "Metadata key naming a deliverable's resource (default \"owner\"); requires capacities." },
1367 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100, "description": "Project buffer percentage (default 25); requires capacities." },
1368 "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." }
1369 },
1370 "additionalProperties": false
1371 })),
1372 ),
1373 ]
1374}
1375
1376fn schema_object(value: Value) -> Arc<rmcp::model::JsonObject> {
1379 debug_assert!(
1384 value.is_object(),
1385 "schema_object expects an object literal; got non-object"
1386 );
1387 let obj = match value.as_object() {
1388 Some(o) => o.clone(),
1389 None => serde_json::Map::new(),
1390 };
1391 Arc::new(obj)
1392}
1393
1394#[derive(Clone)]
1400enum ReviewJudge {
1401 Model(Arc<dyn JudgmentModel>),
1402 NotConfigured,
1403 Unusable(String),
1405}
1406
1407impl ReviewJudge {
1408 fn as_judge(&self) -> Judge<'_> {
1409 match self {
1410 Self::Model(model) => Judge::Model(model.as_ref()),
1411 Self::NotConfigured => Judge::NotConfigured,
1412 Self::Unusable(reason) => Judge::ConfigError(reason),
1413 }
1414 }
1415
1416 fn configured_model(&self) -> Option<String> {
1417 match self {
1418 Self::Model(model) => model.model_id(),
1419 Self::NotConfigured | Self::Unusable(_) => None,
1420 }
1421 }
1422}
1423
1424#[derive(Clone)]
1426pub struct PlanServer {
1427 planner: Arc<BasicCpmPlanner>,
1428 server_name: String,
1429 server_version: String,
1430 judge: ReviewJudge,
1431 review_slots: Arc<tokio::sync::Semaphore>,
1433}
1434
1435impl PlanServer {
1436 pub fn new(planner: Arc<BasicCpmPlanner>) -> Self {
1438 Self {
1439 planner,
1440 server_name: "cpm-planner".to_string(),
1441 server_version: env!("CARGO_PKG_VERSION").to_string(),
1442 judge: ReviewJudge::NotConfigured,
1443 review_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REVIEWS)),
1444 }
1445 }
1446
1447 pub fn with_judge(mut self, judge: Option<Arc<dyn JudgmentModel>>) -> Self {
1450 self.judge = judge.map_or(ReviewJudge::NotConfigured, ReviewJudge::Model);
1451 self
1452 }
1453
1454 pub fn with_llm_config(mut self, config: Result<Option<LlmConfig>, ConfigError>) -> Self {
1460 self.judge = match config {
1461 Ok(Some(config)) => {
1462 tracing::info!(
1463 model = config.jev_model(),
1464 endpoint = ?config.jev_endpoint_host(),
1465 "plan.review enabled"
1466 );
1467 ReviewJudge::Model(Arc::new(JevJudge::new(&config)))
1468 }
1469 Ok(None) => {
1470 tracing::info!("no OpenRouter key configured; plan.review is unavailable");
1471 ReviewJudge::NotConfigured
1472 }
1473 Err(err) => {
1474 let reason = err.review_reason();
1477 tracing::warn!(%reason, "LLM configuration unusable; plan.review is unavailable");
1478 ReviewJudge::Unusable(reason)
1479 }
1480 };
1481 self
1482 }
1483
1484 pub fn with_identity(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
1487 self.server_name = name.into();
1488 self.server_version = version.into();
1489 self
1490 }
1491
1492 pub fn planner(&self) -> &Arc<BasicCpmPlanner> {
1495 &self.planner
1496 }
1497
1498 fn project_root(&self) -> Result<&ProjectRoot, McpError> {
1504 self.planner.project_root().ok_or_else(|| {
1505 McpError::internal_error(
1506 "INVALID_PATH: no project root (set CPM_PROJECT_ROOT or run inside a repo)",
1507 None,
1508 )
1509 })
1510 }
1511
1512 fn default_project(&self) -> Result<String, McpError> {
1514 Ok(self.project_root()?.project_key())
1515 }
1516
1517 async fn resolve_plan_selector(
1525 &self,
1526 plan_id: Option<String>,
1527 plan: Option<String>,
1528 active: bool,
1529 project: Option<String>,
1530 ) -> Result<PlanId, McpError> {
1531 let supplied = [plan_id.is_some(), plan.is_some(), active]
1532 .iter()
1533 .filter(|present| **present)
1534 .count();
1535 if supplied != 1 {
1536 return Err(McpError::invalid_params(
1537 "provide exactly one of plan_id, plan, or active: true",
1538 None,
1539 ));
1540 }
1541 if plan_id.is_some() && project.is_some() {
1542 return Err(McpError::invalid_params(
1543 "project applies only to plan or active",
1544 None,
1545 ));
1546 }
1547 let reference = match (plan_id, plan, active) {
1548 (Some(plan_id), None, false) => PlanRef::Id(PlanId(plan_id)),
1549 (None, Some(name), false) => PlanRef::Line {
1550 project: self.selector_project(project)?,
1551 name,
1552 },
1553 (None, None, true) => PlanRef::Active {
1554 project: self.selector_project(project)?,
1555 },
1556 _ => {
1558 return Err(McpError::invalid_params(
1559 "provide exactly one of plan_id, plan, or active: true",
1560 None,
1561 ));
1562 }
1563 };
1564 self.planner
1565 .resolve_plan(reference)
1566 .await
1567 .map_err(planner_error_to_mcp)
1568 }
1569
1570 fn selector_project(&self, project: Option<String>) -> Result<String, McpError> {
1573 match project {
1574 Some(project) => Ok(project),
1575 None => self.default_project(),
1576 }
1577 }
1578
1579 async fn resolve_graph(
1583 &self,
1584 graph: Option<PlanGraph>,
1585 plan_id: Option<String>,
1586 path: Option<String>,
1587 ) -> Result<PlanGraph, McpError> {
1588 let supplied = [graph.is_some(), plan_id.is_some(), path.is_some()]
1589 .iter()
1590 .filter(|present| **present)
1591 .count();
1592 if supplied != 1 {
1593 return Err(McpError::invalid_params(
1594 "provide exactly one of graph, plan_id, or path",
1595 None,
1596 ));
1597 }
1598 match (graph, plan_id, path) {
1599 (Some(graph), None, None) => Ok(graph),
1600 (None, Some(plan_id), None) => {
1601 let definition = self
1602 .planner
1603 .get_plan(&PlanId(plan_id))
1604 .await
1605 .map_err(planner_error_to_mcp)?;
1606 Ok(definition.graph)
1607 }
1608 (None, None, Some(path)) => {
1609 let root = self.project_root()?;
1610 let file = root
1611 .resolve_plan_file(&path)
1612 .map_err(planner_error_to_mcp)?;
1613 let (graph, _) = root.read_graph(&file).map_err(planner_error_to_mcp)?;
1614 Ok(graph)
1615 }
1616 _ => Err(McpError::invalid_params(
1618 "provide exactly one of graph, plan_id, or path",
1619 None,
1620 )),
1621 }
1622 }
1623
1624 pub async fn serve_stdio(self) -> anyhow::Result<()> {
1631 let service = self.serve(stdio()).await?;
1632 service.waiting().await?;
1633 Ok(())
1634 }
1635
1636 pub async fn dispatch_call(&self, request: CallToolRequestParams) -> Result<Value, McpError> {
1642 let args: Value = request
1643 .arguments
1644 .as_ref()
1645 .map(|m| Value::Object(m.clone()))
1646 .unwrap_or_else(|| json!({}));
1647
1648 match request.name.as_ref() {
1649 TOOL_SUBMIT => self.handle_submit(args).await,
1650 TOOL_ACQUIRE_COHORT => self.handle_acquire_cohort(args).await,
1651 TOOL_HEARTBEAT => self.handle_heartbeat(args).await,
1652 TOOL_MARK_STATUS => self.handle_mark_status(args).await,
1653 TOOL_STATUS => self.handle_status(args).await,
1654 TOOL_GET => self.handle_get(args).await,
1655 TOOL_FORCE_RELEASE => self.handle_force_release(args).await,
1656 TOOL_ACCEPT => self.handle_accept(args).await,
1657 TOOL_LINT => self.handle_lint(args).await,
1658 TOOL_SCHEDULE => self.handle_schedule(args).await,
1659 TOOL_SIMULATE => self.handle_simulate(args).await,
1660 TOOL_SYNC => self.handle_sync(args).await,
1661 TOOL_LIST => self.handle_list(args).await,
1662 TOOL_EXPORT => self.handle_export(args).await,
1663 TOOL_REVISE => self.handle_revise(args).await,
1664 TOOL_FORK => self.handle_fork(args).await,
1665 TOOL_SELECT => self.handle_select(args).await,
1666 TOOL_ARCHIVE => self.handle_archive(args).await,
1667 TOOL_COMPARE => self.handle_compare(args).await,
1668 TOOL_BASELINE => self.handle_baseline(args).await,
1669 TOOL_EV => self.handle_ev(args).await,
1670 TOOL_SNAPSHOT => self.handle_snapshot(args).await,
1671 TOOL_REVIEW => self.handle_review(args).await,
1672 other => Err(McpError::invalid_params(
1673 format!(
1674 "Unknown tool '{other}'. Available: {}.",
1675 PLAN_TOOL_NAMES.join(", ")
1676 ),
1677 None,
1678 )),
1679 }
1680 }
1681
1682 async fn handle_submit(&self, args: Value) -> Result<Value, McpError> {
1687 let parsed: SubmitArgs = parse_args(args)?;
1688 match parsed.name {
1689 None => {
1690 let plan_id = self
1691 .planner
1692 .submit_plan(parsed.graph)
1693 .await
1694 .map_err(planner_error_to_mcp)?;
1695 to_value(&SubmitResponse { plan_id: plan_id.0 })
1696 }
1697 Some(name) => {
1698 let project = match parsed.project {
1699 Some(project) => project,
1700 None => self.default_project()?,
1701 };
1702 let variant = parsed.variant.unwrap_or_else(|| "main".to_string());
1703 let outcome = self
1704 .planner
1705 .sync_plan(SyncRequest::new(project, name, variant, parsed.graph))
1706 .await
1707 .map_err(planner_error_to_mcp)?;
1708 to_value(&outcome)
1709 }
1710 }
1711 }
1712
1713 async fn handle_acquire_cohort(&self, args: Value) -> Result<Value, McpError> {
1714 let parsed: AcquireCohortArgs = parse_args(args)?;
1715 if parsed.ids.as_ref().is_some_and(Vec::is_empty) {
1716 return Err(McpError::invalid_params(
1717 "ids must be non-empty when provided",
1718 None,
1719 ));
1720 }
1721 let plan_id = self
1722 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
1723 .await?;
1724 let mut request =
1725 AcquireRequest::new(plan_id, CallerId(parsed.caller_id), parsed.max_count);
1726 if let Some(ids) = parsed.ids {
1727 request = request.with_ids(ids);
1728 }
1729 if let Some(filter) = parsed.filter.and_then(|f| f.metadata) {
1730 request = request.with_metadata_filter(filter);
1731 }
1732 if let Some(ttl) = ttl_from_seconds(parsed.ttl_seconds)? {
1733 request = request.with_ttl(ttl);
1734 }
1735 let cohort: Cohort = self
1736 .planner
1737 .acquire_cohort(request)
1738 .await
1739 .map_err(planner_error_to_mcp)?;
1740 let exhausted = cohort.rows.is_empty();
1747 let blocked_count = cohort.blocked.len();
1748 let needs_operator = cohort.blocked.iter().any(|b| b.code == "LAPSE_LIMIT");
1749 let mut value = to_value(&cohort)?;
1750 if let Some(obj) = value.as_object_mut() {
1751 obj.insert("exhausted".to_string(), Value::Bool(exhausted));
1752 obj.insert("blocked_count".to_string(), json!(blocked_count));
1753 obj.insert("needs_operator".to_string(), Value::Bool(needs_operator));
1754 }
1755 Ok(value)
1756 }
1757
1758 async fn handle_heartbeat(&self, args: Value) -> Result<Value, McpError> {
1759 let parsed: HeartbeatArgs = parse_args(args)?;
1760 let plan_id = self
1761 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
1762 .await?;
1763 let mut request =
1764 HeartbeatRequest::new(plan_id, parsed.deliverable_id, CallerId(parsed.caller_id));
1765 if let Some(ttl) = ttl_from_seconds(parsed.ttl_seconds)? {
1766 request = request.with_ttl(ttl);
1767 }
1768 self.planner
1769 .heartbeat(request)
1770 .await
1771 .map_err(planner_error_to_mcp)?;
1772 to_value(&OkResponse::new())
1773 }
1774
1775 async fn handle_mark_status(&self, args: Value) -> Result<Value, McpError> {
1776 let parsed: MarkStatusArgs = parse_args(args)?;
1777 crate::planner::validate_actuals(
1778 &parsed.status,
1779 parsed.earned_pct,
1780 parsed.actual_effort_hours,
1781 parsed.evidence.as_deref(),
1782 )
1783 .map_err(planner_error_to_mcp)?;
1784 let plan_id = self
1785 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
1786 .await?;
1787 let mut request = MarkStatusRequest::new(
1788 plan_id,
1789 parsed.deliverable_id,
1790 CallerId(parsed.caller_id),
1791 parsed.status,
1792 );
1793 if let Some(pct) = parsed.earned_pct {
1796 request = request.with_earned_pct(pct as u8);
1797 }
1798 if let Some(hours) = parsed.actual_effort_hours {
1799 request = request.with_actual_effort_hours(hours as f32);
1800 }
1801 if let Some(evidence) = parsed.evidence {
1802 request = request.with_evidence(evidence);
1803 }
1804 let baseline_taken = self
1805 .planner
1806 .mark_status(request)
1807 .await
1808 .map_err(planner_error_to_mcp)?;
1809 to_value(&OkResponse::with_baseline_taken(baseline_taken))
1810 }
1811
1812 async fn handle_status(&self, args: Value) -> Result<Value, McpError> {
1813 let parsed: StatusArgs = parse_args(args)?;
1814 let plan_id = self
1815 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
1816 .await?;
1817 let status: PlanStatus = self
1818 .planner
1819 .status(&plan_id)
1820 .await
1821 .map_err(planner_error_to_mcp)?;
1822 to_value(&status)
1823 }
1824
1825 async fn handle_get(&self, args: Value) -> Result<Value, McpError> {
1826 let parsed: GetArgs = parse_args(args)?;
1827 let plan_id = self
1828 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
1829 .await?;
1830 let definition: PlanDefinition = self
1831 .planner
1832 .get_plan(&plan_id)
1833 .await
1834 .map_err(planner_error_to_mcp)?;
1835 to_value(&definition)
1836 }
1837
1838 async fn handle_accept(&self, args: Value) -> Result<Value, McpError> {
1839 let parsed: AcceptArgs = parse_args(args)?;
1840 let plan_id = self
1841 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
1842 .await?;
1843 self.planner
1844 .accept(
1845 AcceptRequest::new(
1846 plan_id,
1847 parsed.deliverable_id,
1848 parsed.accepted_by,
1849 parsed.evidence,
1850 )
1851 .override_lock(parsed.override_lock),
1852 )
1853 .await
1854 .map_err(planner_error_to_mcp)?;
1855 to_value(&OkResponse::new())
1856 }
1857
1858 async fn handle_force_release(&self, args: Value) -> Result<Value, McpError> {
1859 let parsed: ForceReleaseArgs = parse_args(args)?;
1860 let plan_id = self
1861 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
1862 .await?;
1863 self.planner
1864 .force_release(
1865 ForceReleaseRequest::new(plan_id, parsed.deliverable_id, parsed.reason)
1866 .reset_counters(parsed.reset_counters),
1867 )
1868 .await
1869 .map_err(planner_error_to_mcp)?;
1870 to_value(&OkResponse::new())
1871 }
1872
1873 async fn handle_lint(&self, args: Value) -> Result<Value, McpError> {
1874 let parsed: GraphOrPlanIdArgs = parse_args(args)?;
1875 let stored_plan = parsed.plan_id.clone();
1876 let graph = self
1877 .resolve_graph(parsed.graph, parsed.plan_id, parsed.path)
1878 .await?;
1879 let mut report = run_blocking(move || Ok(crate::lint::lint(&graph))).await?;
1880 if let Some(plan_id) = stored_plan {
1881 let plan_id = PlanId(plan_id);
1882 let status = self
1883 .planner
1884 .status(&plan_id)
1885 .await
1886 .map_err(planner_error_to_mcp)?;
1887 let applicable = status.selected != Some(false)
1888 && self
1889 .planner
1890 .has_progress_without_baseline(&plan_id)
1891 .map_err(planner_error_to_mcp)?;
1892 if applicable {
1893 let label = match (&status.name, &status.variant) {
1894 (Some(name), Some(variant)) => format!("{name}/{variant}"),
1895 _ => plan_id.0.clone(),
1896 };
1897 crate::lint::push_finding(
1898 &mut report,
1899 LintFinding {
1900 code: "NO_BASELINE".to_string(),
1901 severity: Severity::Warning,
1902 message: format!(
1903 "plan '{label}' has recorded progress but no earned-value baseline; \
1904 call plan.baseline"
1905 ),
1906 ids: Vec::new(),
1907 path: None,
1908 },
1909 );
1910 }
1911 }
1912 to_value(&report)
1913 }
1914
1915 async fn handle_schedule(&self, args: Value) -> Result<Value, McpError> {
1916 let parsed: ScheduleToolArgs = parse_args(args)?;
1917 let request = ScheduleRequest {
1918 capacities: parsed.capacities,
1919 resource_key: parsed.resource_key,
1920 project_buffer_pct: parsed.project_buffer_pct,
1921 };
1922 check_schedule_params(&request)?;
1923 let graph = self
1924 .resolve_graph(parsed.graph, parsed.plan_id, None)
1925 .await?;
1926 let schedule = run_blocking(move || resource_schedule(&graph, &request)).await?;
1927 to_value(&schedule)
1928 }
1929
1930 async fn handle_simulate(&self, args: Value) -> Result<Value, McpError> {
1931 let parsed: SimulateToolArgs = parse_args(args)?;
1932 let request = SimulateRequest {
1933 schedule: parsed.schedule,
1934 monte_carlo: parsed.monte_carlo,
1935 };
1936 if let Some(schedule) = &request.schedule {
1937 check_schedule_params(schedule)?;
1938 }
1939 if let Some(mc) = &request.monte_carlo {
1940 crate::monte_carlo::check_iterations(mc.iterations)
1941 .map_err(|reason| McpError::invalid_params(reason, None))?;
1942 }
1943 let graph = self
1944 .resolve_graph(parsed.graph, parsed.plan_id, parsed.path)
1945 .await?;
1946 let result = run_blocking(move || crate::simulate::simulate(&graph, &request)).await?;
1947 to_value(&result)
1948 }
1949
1950 async fn handle_sync(&self, args: Value) -> Result<Value, McpError> {
1953 let parsed: SyncArgs = parse_args(args)?;
1954 let (graph, source) = match (parsed.path, parsed.graph) {
1955 (Some(_), Some(_)) => {
1956 return Err(McpError::invalid_params(
1957 "provide exactly one of path or graph",
1958 None,
1959 ));
1960 }
1961 (Some(path), None) => {
1962 let root = self.project_root()?;
1963 let file = root
1964 .resolve_plan_file(&path)
1965 .map_err(planner_error_to_mcp)?;
1966 let (graph, hash) = root.read_graph(&file).map_err(planner_error_to_mcp)?;
1967 (graph, Some((file, hash)))
1968 }
1969 (None, Some(graph)) => (graph, None),
1970 (None, None) => {
1971 return Err(McpError::invalid_params(
1972 "plan.sync requires exactly one of path or graph",
1973 None,
1974 ));
1975 }
1976 };
1977 let project = match parsed.project {
1978 Some(project) => project,
1979 None => self.default_project()?,
1980 };
1981 let (name, variant) = match (&source, parsed.name, parsed.variant) {
1982 (Some((file, _)), name, variant) => {
1983 let root_key = self.project_root()?.project_key();
1986 if name.as_ref().is_some_and(|n| *n != file.name)
1987 || variant.as_ref().is_some_and(|v| *v != file.variant)
1988 || project != root_key
1989 {
1990 return Err(planner_error_to_mcp(PlannerError::InvalidPath {
1991 reason: "name/variant/project must match the plan file path".to_string(),
1992 }));
1993 }
1994 (file.name.clone(), file.variant.clone())
1995 }
1996 (None, Some(name), variant) => (name, variant.unwrap_or_else(|| "main".to_string())),
1997 (None, None, _) => {
1998 return Err(McpError::invalid_params(
1999 "an inline graph requires name",
2000 None,
2001 ));
2002 }
2003 };
2004 let mut request = SyncRequest::new(project, name, variant, graph).force(parsed.force);
2005 if let Some((file, hash)) = source {
2006 request = request
2007 .with_source_path(file.rel_path)
2008 .with_content_hash(hash);
2009 }
2010 let outcome = self
2011 .planner
2012 .sync_plan(request)
2013 .await
2014 .map_err(planner_error_to_mcp)?;
2015 to_value(&outcome)
2016 }
2017
2018 async fn handle_list(&self, args: Value) -> Result<Value, McpError> {
2019 let parsed: ListArgs = parse_args(args)?;
2020 if parsed.active && parsed.include_archived {
2021 return Err(McpError::invalid_params(
2022 "active and include_archived are mutually exclusive",
2023 None,
2024 ));
2025 }
2026 let project = match parsed.project {
2027 Some(project) => project,
2028 None => self.default_project()?,
2029 };
2030 let lines = if parsed.active {
2031 self.planner.list_active_plans(&project).await
2032 } else {
2033 self.planner
2034 .list_plans(&project, parsed.include_archived)
2035 .await
2036 }
2037 .map_err(planner_error_to_mcp)?;
2038 to_value(&lines)
2039 }
2040
2041 async fn handle_export(&self, args: Value) -> Result<Value, McpError> {
2042 let parsed: ExportArgs = parse_args(args)?;
2043 let root = self.project_root()?;
2044 let path = self
2045 .planner
2046 .export_plan(
2047 &PlanId(parsed.plan_id),
2048 root,
2049 parsed.path.as_deref(),
2050 parsed.force,
2051 )
2052 .await
2053 .map_err(planner_error_to_mcp)?;
2054 to_value(&ExportResponse { path })
2055 }
2056
2057 async fn handle_revise(&self, args: Value) -> Result<Value, McpError> {
2058 let parsed: ReviseArgs = parse_args(args)?;
2059 let (revision, diff) = self
2060 .planner
2061 .revise_plan(
2062 ReviseRequest::new(PlanId(parsed.plan_id), parsed.graph).force(parsed.force),
2063 )
2064 .await
2065 .map_err(planner_error_to_mcp)?;
2066 to_value(&ReviseResponse { revision, diff })
2067 }
2068
2069 async fn handle_fork(&self, args: Value) -> Result<Value, McpError> {
2070 let parsed: ForkArgs = parse_args(args)?;
2071 let outcome = self
2072 .planner
2073 .fork_plan(
2074 ForkRequest::new(PlanId(parsed.plan_id), parsed.variant).with_edits(parsed.edits),
2075 )
2076 .await
2077 .map_err(planner_error_to_mcp)?;
2078 to_value(&outcome)
2079 }
2080
2081 async fn handle_select(&self, args: Value) -> Result<Value, McpError> {
2082 let parsed: SelectArgs = parse_args(args)?;
2083 let outcome = self
2084 .planner
2085 .select_variant(&PlanId(parsed.plan_id), parsed.force)
2086 .await
2087 .map_err(planner_error_to_mcp)?;
2088 to_value(&outcome)
2089 }
2090
2091 async fn handle_archive(&self, args: Value) -> Result<Value, McpError> {
2092 let parsed: ArchiveArgs = parse_args(args)?;
2093 let project = match parsed.project {
2094 Some(project) => project,
2095 None => self.default_project()?,
2096 };
2097 self.planner
2098 .archive(
2099 &project,
2100 &parsed.name,
2101 parsed.variant.as_deref(),
2102 parsed.archived,
2103 parsed.force,
2104 )
2105 .await
2106 .map_err(planner_error_to_mcp)?;
2107 to_value(&OkResponse::new())
2108 }
2109
2110 async fn handle_compare(&self, args: Value) -> Result<Value, McpError> {
2111 let parsed: CompareArgs = parse_args(args)?;
2112 check_compare_weights(&parsed.weights)?;
2113 if let Some(schedule) = &parsed.schedule {
2114 check_schedule_params(schedule)?;
2115 }
2116 if let Some(mc) = &parsed.monte_carlo {
2117 crate::monte_carlo::check_iterations(mc.iterations)
2118 .map_err(|reason| McpError::invalid_params(reason, None))?;
2119 }
2120 let request = CompareRequest {
2121 schedule: parsed.schedule,
2122 monte_carlo: parsed.monte_carlo,
2123 weights: parsed.weights,
2124 };
2125 let compare = match (parsed.plan_ids, parsed.plan) {
2126 (Some(ids), None) => {
2127 let distinct: std::collections::HashSet<&str> =
2128 ids.iter().map(String::as_str).collect();
2129 if distinct.len() != ids.len() {
2130 return Err(McpError::invalid_params("plan_ids must be distinct", None));
2131 }
2132 ComparePlansRequest::by_ids(ids.into_iter().map(PlanId).collect(), request)
2133 }
2134 (None, Some(name)) => {
2135 let project = match parsed.project {
2136 Some(project) => project,
2137 None => self.default_project()?,
2138 };
2139 ComparePlansRequest::by_plan(project, name, request)
2140 }
2141 _ => {
2142 return Err(McpError::invalid_params(
2143 "provide exactly one of plan_ids or plan",
2144 None,
2145 ));
2146 }
2147 };
2148 let inputs = self
2150 .planner
2151 .compare_inputs(&compare)
2152 .map_err(planner_error_to_mcp)?;
2153 let request = compare.request;
2154 let comparison = run_blocking(move || crate::compare::compare(&inputs, &request)).await?;
2155 to_value(&comparison)
2156 }
2157
2158 async fn handle_baseline(&self, args: Value) -> Result<Value, McpError> {
2161 let parsed: BaselineArgs = parse_args(args)?;
2162 let plan_id = self
2163 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
2164 .await?;
2165 let mut request = BaselineRequest::new(plan_id);
2166 request.start = parsed.start;
2167 request.calendar = parsed.calendar;
2168 request.reason = parsed.reason;
2169 request.backfill_from = parsed.backfill_from;
2170 let outcome = self
2171 .planner
2172 .baseline(request)
2173 .await
2174 .map_err(planner_error_to_mcp)?;
2175 to_value(&outcome)
2176 }
2177
2178 async fn handle_ev(&self, args: Value) -> Result<Value, McpError> {
2179 let parsed: EvArgs = parse_args(args)?;
2180 let planner = Arc::clone(&self.planner);
2181 let plan_id = self
2182 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
2183 .await?;
2184 let report = run_blocking_planner("ev", async move {
2185 Planner::ev(&*planner, &plan_id, parsed.as_of).await
2186 })
2187 .await?;
2188 to_value(&report)
2189 }
2190
2191 async fn handle_snapshot(&self, args: Value) -> Result<Value, McpError> {
2192 let parsed: SnapshotArgs = parse_args(args)?;
2193 let plan_id = self
2194 .resolve_plan_selector(parsed.plan_id, parsed.plan, parsed.active, parsed.project)
2195 .await?;
2196 let mut request = SnapshotRequest::new(plan_id).with_format(parsed.format);
2197 request.as_of = parsed.as_of;
2198 let planner = Arc::clone(&self.planner);
2199 let outcome = run_blocking_planner("snapshot", async move {
2200 Planner::snapshot(&*planner, request).await
2201 })
2202 .await?;
2203 to_value(&outcome)
2204 }
2205
2206 async fn handle_review(&self, args: Value) -> Result<Value, McpError> {
2215 let ReviewToolArgs {
2216 graph,
2217 plan_id,
2218 path,
2219 capacities,
2220 resource_key,
2221 project_buffer_pct,
2222 max_questions,
2223 } = parse_args(args)?;
2224 let request = review_request(capacities, resource_key, project_buffer_pct, max_questions)?;
2225 let audited_plan_id = plan_id.clone();
2226 let graph = match self.resolve_graph(graph, plan_id, path).await {
2227 Ok(graph) => graph,
2228 Err(err) => {
2229 if err.code != rmcp::model::ErrorCode::INVALID_PARAMS {
2232 let event =
2233 review_audit_event(Err(mcp_error_code(&err)), audited_plan_id, None);
2234 self.planner.record_audit(event).await;
2235 }
2236 return Err(err);
2237 }
2238 };
2239 let judge = self.judge.clone();
2240 let configured_model = judge.configured_model();
2241 let slots = self.review_slots.clone();
2242 let runtime = tokio::runtime::Handle::current();
2243 let outcome = spawn_analysis("review", move || {
2244 let calls_judge = matches!(judge, ReviewJudge::Model(_))
2249 && !crate::lint::lint(&graph)
2250 .findings
2251 .iter()
2252 .any(|f| f.severity == crate::lint::Severity::Error);
2253 let _slot = calls_judge
2254 .then(|| runtime.block_on(slots.acquire_owned()).ok())
2255 .flatten();
2256 runtime.block_on(crate::review::review(&graph, &request, judge.as_judge()))
2257 })
2258 .await?;
2259 let audit_outcome = match &outcome {
2260 Ok(report) => Ok(report),
2261 Err(err) => Err(Some(error_code(err))),
2262 };
2263 let event = review_audit_event(audit_outcome, audited_plan_id, configured_model);
2264 self.planner.record_audit(event).await;
2265 to_value(&outcome.map_err(planner_error_to_mcp)?)
2266 }
2267}
2268
2269fn review_request(
2271 capacities: Option<std::collections::BTreeMap<String, u32>>,
2272 resource_key: Option<String>,
2273 project_buffer_pct: Option<f32>,
2274 max_questions: Option<u16>,
2275) -> Result<ReviewRequest, McpError> {
2276 if let Some(cap) = max_questions {
2277 crate::review::check_max_questions(cap)
2278 .map_err(|reason| McpError::invalid_params(reason, None))?;
2279 }
2280 let capacities = match capacities {
2281 Some(capacities) => {
2282 let schedule = ScheduleRequest {
2283 capacities,
2284 resource_key: resource_key
2285 .unwrap_or_else(crate::resource_schedule::default_resource_key),
2286 project_buffer_pct: project_buffer_pct
2287 .unwrap_or_else(crate::resource_schedule::default_buffer_pct),
2288 };
2289 check_schedule_params(&schedule)?;
2290 Some(schedule)
2291 }
2292 None if resource_key.is_some() || project_buffer_pct.is_some() => {
2293 return Err(McpError::invalid_params(
2294 "resource_key and project_buffer_pct require capacities",
2295 None,
2296 ));
2297 }
2298 None => None,
2299 };
2300 Ok(ReviewRequest {
2301 capacities,
2302 max_questions,
2303 })
2304}
2305
2306fn review_audit_event(
2312 outcome: Result<&ReviewReport, Option<String>>,
2313 plan_id: Option<String>,
2314 configured_model: Option<String>,
2315) -> AuditEvent {
2316 let payload = match outcome {
2317 Ok(report) => json!({
2318 "status": report.status,
2319 "code": null,
2320 "failure_class": report.failure_class,
2321 "plan_id": plan_id,
2322 "question_count": report.question_count,
2323 "prompt_hash": report.prompt_hash,
2324 "model": report.model.clone().or(if report.jev_called { configured_model } else { None }),
2325 "endpoint": report.endpoint,
2326 "jev_called": report.jev_called,
2327 }),
2328 Err(code) => json!({
2329 "status": "error",
2330 "code": code,
2331 "failure_class": null,
2332 "plan_id": plan_id,
2333 "question_count": 0,
2334 "prompt_hash": null,
2335 "model": null,
2336 "endpoint": null,
2337 "jev_called": false,
2338 }),
2339 };
2340 AuditEvent::new("plan.review").with_payload(payload)
2341}
2342
2343fn mcp_error_code(err: &McpError) -> Option<String> {
2346 let (prefix, _) = err.message.split_once(':')?;
2347 (!prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_uppercase() || c == '_'))
2348 .then(|| prefix.to_string())
2349}
2350
2351fn error_code(err: &PlannerError) -> String {
2353 let text = err.to_string();
2354 text.split(':')
2355 .next()
2356 .unwrap_or_default()
2357 .trim()
2358 .to_string()
2359}
2360
2361fn check_schedule_params(request: &ScheduleRequest) -> Result<(), McpError> {
2364 crate::resource_schedule::check_buffer_pct(request.project_buffer_pct)
2365 .map_err(|reason| McpError::invalid_params(reason, None))
2366}
2367
2368fn check_compare_weights(weights: &CompareWeights) -> Result<(), McpError> {
2370 let criteria = [
2371 ("makespan", weights.makespan),
2372 ("p80", weights.p80),
2373 ("criticality_risk", weights.criticality_risk),
2374 ("total_effort", weights.total_effort),
2375 ("peak_load", weights.peak_load),
2376 ];
2377 for (name, value) in criteria {
2378 if !value.is_finite() || value < 0.0 {
2379 return Err(McpError::invalid_params(
2380 format!("weights.{name} must be finite and >= 0"),
2381 None,
2382 ));
2383 }
2384 }
2385 Ok(())
2386}
2387
2388async fn run_blocking_planner<T, Fut>(what: &'static str, call: Fut) -> Result<T, McpError>
2395where
2396 T: Send + 'static,
2397 Fut: std::future::Future<Output = Result<T, PlannerError>> + Send + 'static,
2398{
2399 let runtime = tokio::runtime::Handle::current();
2400 spawn_analysis(what, move || runtime.block_on(call))
2401 .await?
2402 .map_err(planner_error_to_mcp)
2403}
2404
2405async fn run_blocking<T, F>(work: F) -> Result<T, McpError>
2407where
2408 T: Send + 'static,
2409 F: FnOnce() -> Result<T, PlannerError> + Send + 'static,
2410{
2411 spawn_analysis("analysis", work)
2412 .await?
2413 .map_err(planner_error_to_mcp)
2414}
2415
2416async fn spawn_analysis<T, F>(what: &'static str, work: F) -> Result<T, McpError>
2420where
2421 T: Send + 'static,
2422 F: FnOnce() -> T + Send + 'static,
2423{
2424 tokio::task::spawn_blocking(work).await.map_err(|e| {
2425 tracing::error!(error = %e, "{what} task failed");
2426 McpError::internal_error(format!("{what} task failed"), None)
2427 })
2428}
2429
2430impl ServerHandler for PlanServer {
2435 fn get_info(&self) -> ServerConfig {
2436 let mut server_info =
2437 Implementation::new(self.server_name.clone(), self.server_version.clone());
2438 server_info.title = Some("cpm-planner".to_string());
2439 server_info.description = Some(
2440 "MCP server exposing the open-source Praxec CPM planner via twenty-three tools."
2441 .to_string(),
2442 );
2443
2444 let mut info = InitializeResult::default();
2445 info.protocol_version = ProtocolVersion::default();
2446 info.capabilities = ServerCapabilities::builder().enable_tools().build();
2447 info.server_info = server_info;
2448 info.instructions = Some(instructions().to_string());
2449 info
2450 }
2451
2452 async fn initialize(
2453 &self,
2454 request: InitializeRequestParams,
2455 context: RequestContext<RoleServer>,
2456 ) -> Result<InitializeResult, McpError> {
2457 if context.peer.peer_info().is_none() {
2458 context.peer.set_peer_info(request);
2459 }
2460 Ok(self.get_info())
2461 }
2462
2463 async fn list_tools(
2464 &self,
2465 _request: Option<PaginatedRequestParams>,
2466 _context: RequestContext<RoleServer>,
2467 ) -> Result<ListToolsResult, McpError> {
2468 Ok(ListToolsResult::with_all_items(plan_tool_definitions()))
2469 }
2470
2471 async fn call_tool(
2472 &self,
2473 request: CallToolRequestParams,
2474 _context: RequestContext<RoleServer>,
2475 ) -> Result<CallToolResponse, McpError> {
2476 self.dispatch_call(request)
2477 .await
2478 .map(|value| CallToolResult::structured(value).into())
2479 }
2480
2481 fn get_tool(&self, name: &str) -> Option<Tool> {
2482 plan_tool_definitions().into_iter().find(|t| t.name == name)
2483 }
2484
2485 async fn on_initialized(&self, _context: NotificationContext<RoleServer>) {
2486 tracing::info!("cpm-planner client initialized");
2487 }
2488}
2489
2490fn parse_args<T: serde::de::DeserializeOwned>(args: Value) -> Result<T, McpError> {
2496 serde_json::from_value(args)
2497 .map_err(|e| McpError::invalid_params(format!("invalid arguments: {e}"), None))
2498}
2499
2500fn ttl_from_seconds(secs: Option<u64>) -> Result<Option<Duration>, McpError> {
2506 match secs {
2507 None => Ok(None),
2508 Some(0) => Err(McpError::invalid_params(
2509 "ttl_seconds must be >= 1 when provided",
2510 None,
2511 )),
2512 Some(s) => Ok(Some(Duration::from_secs(s))),
2513 }
2514}
2515
2516fn to_value<T: Serialize>(value: &T) -> Result<Value, McpError> {
2521 serde_json::to_value(value)
2522 .map_err(|e| McpError::internal_error(format!("response serialisation failed: {e}"), None))
2523}
2524
2525fn planner_error_to_mcp(err: PlannerError) -> McpError {
2534 McpError::internal_error(err.to_string(), None)
2535}
2536
2537fn instructions() -> &'static str {
2540 r#"This is the cpm-planner MCP server — the open-source CPM planner.
2541
2542Tools (twenty-three total, all `plan.<verb>`):
2543 Selectors (plan.status, plan.get, plan.ev, plan.snapshot, plan.baseline,
2544 plan.acquire_cohort, plan.heartbeat, plan.mark_status, plan.accept and
2545 plan.force_release): pass exactly one of plan_id, plan (a plan line name,
2546 resolved to its selected variant) or active: true (the project's single line
2547 that is unarchived and whose selected variant is unarchived and not complete).
2548 plan and active also accept an optional project (default: the discovered
2549 project root); plan_id refuses project. No active line is NO_ACTIVE_PLAN;
2550 several are AMBIGUOUS_ACTIVE_PLAN, naming them. An unknown line, or one with
2551 no selected variant, is PLAN_NOT_FOUND. Supplying none, several, or project
2552 with plan_id is invalid params. Recurring jobs and agents should look the
2553 plan up with active (or plan) rather than pin a plan_id, so they follow the
2554 work to the next line. active matches only an incomplete line and changes
2555 when another line goes live: resolve once (plan.status returns plan_id, as
2556 does acquire_cohort), then keep that plan_id for heartbeats, marks and the
2557 final status/ev of the plan. A pinned id of an archived variant still reads
2558 through plan.status/plan.ev with archived: true and active_plan.
2559 plan.submit — submit a PlanGraph, get a plan_id (idempotent on identical graphs)
2560 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
2561 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
2562 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
2563 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
2564 limits: at most 5000 deliverables; every hour value (effort, duration, lag, estimate) must be finite and between 0 and 1000000 (INVALID_GRAPH)
2565 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)
2566 plan.heartbeat — refresh a held lock's TTL
2567 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)
2568 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
2569 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, archived and active_plan (the project's single active line's name when the variant is archived; archived and active_plan are null for an unnamed plan), and definition_drift). __start__ and __finish__ are reserved deliverable ids (INVALID_GRAPH)
2570 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}
2571 plan.get — return the submitted PlanGraph (deliverables, estimates, files, metadata) for a plan_id, plan or active selector
2572 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
2573 plan.accept — a manager/owner marks a deliverable Complete without a lease (audited; evidence required; override_lock to take over a live lease)
2574 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
2575 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)
2576 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
2577 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
2578 plan.list — list every plan line of project (default: discovered root), variants sorted; archived omitted unless include_archived; active: true returns only the active lines (not combinable with include_archived)
2579 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)
2580 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
2581 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
2582 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
2583 plan.archive — archive (archived defaults true) or unarchive a whole line or one variant; archived variants stay readable but refuse sync/select/execute
2584 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
2585 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
2586 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, including an archived one (which reports archived: true and, when the project has exactly one active line, its active_plan name); NOT_BASELINED before plan.baseline
2587 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
2588 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
2589 plan.submit with a `name` (optional `project`/`variant`, variant defaults to "main") registers a named variant instead of an unnamed plan
2590 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
2591
2592Plan-as-code workflow: author the graph as
2593.cpm-planner/plans/<name>/<variant>.json, check it with plan.lint {path},
2594then register it with plan.sync {path} and execute its plan_id. Design many
2595variants and pick one: plan.fork creates a draft variant from edits,
2596plan.compare scores every live variant, and plan.select makes exactly one
2597executable. Never keep untracked scratch graphs — the files under
2598.cpm-planner/plans/ are the source of truth for definitions.
2599
2600Review workflow: plan.review {path} on a lint-clean plan; read findings as
2601advisory judgments (check them against what you know), apply a proposal you
2602agree with via plan.fork {edits} (or design the edits for split and
2603missing-dependency findings yourself), then plan.compare and plan.select.
2604
2605Leases default to 5 minutes. Pass ttl_seconds (≤ server max, default 8h)
2606on acquire/heartbeat for long-running work, and heartbeat at least every
2607ttl/3.
2608
2609Every plan's first execution takes an earned-value baseline automatically
2610(CPM_BASELINE_MODE=auto, the default), reported as baseline_taken on the
2611acquire/mark/select response; set CPM_BASELINE_MODE=refuse to make those
2612calls fail with BASELINE_REQUIRED until plan.baseline is called. plan.status
2613reports the plan's baseline (or null).
2614
2615Errors carry stable prefixes: LOCK_HELD, LOCK_NOT_HELD, LOCK_EXPIRED,
2616OVERLAP_DETECTED, MISSING_PREREQUISITE, PLAN_NOT_FOUND, NO_ACTIVE_PLAN,
2617AMBIGUOUS_ACTIVE_PLAN, DELIVERABLE_NOT_FOUND, LAPSE_LIMIT,
2618PREREQUISITES_INCOMPLETE, INVALID_GRAPH,
2619INVALID_CAPACITIES, VARIANT_NOT_SELECTED, ARCHIVE_REFUSED, INVALID_PATH,
2620INVALID_ACTUALS, BASELINE_REQUIRED, NOT_BASELINED, BACKEND_ERROR.
2621
2622DeliverableStatus is internally tagged on `status`:
2623 {"status":"pending"} | {"status":"ready"} | {"status":"in_progress"} |
2624 {"status":"complete"} | {"status":"failed","reason":"..."}
2625
2626Circuit-breaker (visible in plan.status): every lease increments
2627attempt_count (telemetry). An EXPLICIT mark_status failed increments
2628failure_count — a ready deliverable with 3 failed attempts is auto-failed
2629by the next acquire_cohort (reason "circuit-break: exceeded 3 failed
2630attempts") instead of being re-leased forever. A lease that lapses via
2631TTL with no terminal mark (driver killed/timed out) increments
2632lapse_count instead: environmental losses never trip the failure breaker,
2633but at 10 lapses acquire_cohort stops re-leasing that deliverable: it is
2634skipped (the rest of the plan stays leasable) and reported in the acquire
2635response's `blocked` list as {id, code:"LAPSE_LIMIT", reason}. Fix the
2636environment, then clear it with plan.force_release {reset_counters: true}.
2637The response also carries scalar `blocked_count` and `needs_operator`.
2638exhausted:true with needs_operator:true means the plan is stalled on
2639lapse-limited deliverables (clear with plan.force_release {reset_counters:
2640true}), not drained.
2641
2642Targeted acquire: plan.acquire_cohort accepts optional `ids` (only those
2643deliverables are considered; unknown id -> DELIVERABLE_NOT_FOUND) and
2644`filter: {"metadata": {key: value}}` (only deliverables whose metadata
2645equals every pair; others are silently skipped). ids that do not match
2646filter.metadata are ignored (not reported in blocked). Deliverables with
2647metadata.kind = "manual" are never leased. With `ids`, each requested id
2648that is not leased appears in `blocked` with code MANUAL, NOT_READY,
2649LOCKED, LAPSE_LIMIT, FILE_CONFLICT or MAX_COUNT.
2650"#
2651}