Skip to main content

cpm_planner/
server.rs

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