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