Skip to main content

cpm_planner/
server.rs

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