Skip to main content
Glama

cpm-planner

CI Release License: Apache-2.0

cpm-planner is a Critical Path Method (CPM) planner for agent work, exposed as an MCP server. You submit a task graph and it computes the schedule: earliest and latest start and finish, slack, the critical path and the bottleneck tasks that gate completion. It then leases lock-aware cohorts of ready deliverables with disjoint file sets, so several workers can run in parallel without colliding. Plans can live in the repository as plan-as-code files with forkable, comparable variants; read-only tools lint, level against resource capacities and run Monte Carlo risk; earned-value tools track PV, EV, AC, SPI and CPI against a frozen baseline; and an optional AI review proposes verified schedule improvements. Any MCP client (Claude Code, Cursor, VS Code, Codex, a custom orchestrator, or a praxec workflow) drives it over stdio. It is a standalone tool with no dependency on praxec.

Contents

Related MCP server: LinkedRun

Install

Prebuilt packages are published for Linux, macOS and Windows on x86_64 and ARM64.

The installers resolve your OS and CPU and download the matching archive. They verify its SHA-256 against the release's checksums.sha256 before installing, then atomically replace the binary in a user-local directory. A checksum mismatch aborts with a non-zero exit and installs nothing. Re-running the installer upgrades in place.

# Linux / macOS  ->  ~/.local/bin/cpm-planner
curl -fsSL https://github.com/praxec/cpm-planner/releases/latest/download/install.sh | sh

# Windows (PowerShell)  ->  %LOCALAPPDATA%\Programs\cpm-planner\cpm-planner.exe
irm https://github.com/praxec/cpm-planner/releases/latest/download/install.ps1 | iex

The installer prints the absolute path it installed to. Use that path when you register the server with a GUI client.

If you would rather read the script before running it, download, inspect, then run (see Pin a version for <version>):

# Linux / macOS
curl -fsSLO https://github.com/praxec/cpm-planner/releases/latest/download/install.sh
less install.sh
sh install.sh --version <version>
# Windows
irm https://github.com/praxec/cpm-planner/releases/latest/download/install.ps1 -OutFile install.ps1
Get-Content install.ps1
.\install.ps1 -Version <version>

PATH. If the install directory is not on your PATH, the installer prints the exact line to add. Pass --add-to-path (-AddToPath on Windows) to have it do that for you, user-level only and never with sudo.

On Linux and macOS it appends the line only if it is not already there, to:

  • ~/.zshrc for zsh;

  • ~/.bash_profile if it exists, else ~/.bashrc, for bash;

  • ~/.profile for other shells.

For fish it prints fish_add_path <dir> for you to run instead of writing a file.

Downloads. The installers only accept https:// download URLs.

Upgrades. After upgrading, restart your MCP client so it launches the new binary. On Windows the old exe is renamed to cpm-planner.exe.old and removed on the next run.

Direct downloads. You can also download an archive yourself and check it against checksums.sha256; a machine-readable release-manifest.json is published too. All assets are at https://github.com/praxec/cpm-planner/releases/latest.

Platform

Asset

Linux x86_64

cpm-planner-x86_64-unknown-linux-gnu.tar.gz

Linux ARM64

cpm-planner-aarch64-unknown-linux-gnu.tar.gz

macOS x86_64

cpm-planner-x86_64-apple-darwin.tar.gz

macOS Apple Silicon

cpm-planner-aarch64-apple-darwin.tar.gz

Windows x86_64

cpm-planner-x86_64-pc-windows-msvc.zip

Windows ARM64

cpm-planner-aarch64-pc-windows-msvc.zip

Pin a version

In the commands in this README, <version> is a release tag such as v0.2.0. Pick one from the releases page.

# Linux / macOS
curl -fsSL https://github.com/praxec/cpm-planner/releases/latest/download/install.sh | sh -s -- --version <version>

# Windows (PowerShell): the piped form cannot take parameters, so use the environment variable...
$env:PRAXEC_VERSION = '<version>'; irm https://github.com/praxec/cpm-planner/releases/latest/download/install.ps1 | iex
# ...or a script block
& ([scriptblock]::Create((irm https://github.com/praxec/cpm-planner/releases/latest/download/install.ps1))) -Version <version>

npm

With Node 18 or later, install the cpm-planner command from npm:

npm install -g @matthew-cochran/cpm
cpm-planner --version

The package is a small launcher. It downloads the release binary for your platform, verifies its checksum and caches it, during install or, where npm skips install scripts, on first run. The package version is the release version, so npm install -g @matthew-cochran/cpm@0.2.0 pins one. To run it without installing, use npx -y @matthew-cochran/cpm. For details and environment variables, see npm/README.md.

From source

Build and install a tagged release with Cargo (Rust 1.99 or newer):

cargo install --git https://github.com/praxec/cpm-planner --tag <version> --locked

The crates.io listing lags behind the GitHub releases until the next cargo publish, so cargo install cpm-planner does not give you the current version yet. Install from the tag as above.

Docker

docker pull ghcr.io/praxec/cpm-planner
docker run -i --rm -v cpm-planner-data:/data ghcr.io/praxec/cpm-planner

-i keeps stdin open for the stdio transport.

The image sets CPM_PLANNER_DB=/data/cpm-planner.db and declares /data as a volume owned by the unprivileged app user, so a named volume persists plan state across runs. For a bind mount (-v "$PWD:/data"), run as yourself so the directory is writable: --user "$(id -u):$(id -g)". Pass -e CPM_PLANNER_DB=... only to override the path.

To register the image with a client, put docker in command and the rest in args. For example, in Claude Code:

claude mcp add cpm-planner --scope user -- docker run -i --rm -v cpm-planner-data:/data ghcr.io/praxec/cpm-planner

Verify the install

cpm-planner --version

It prints cpm-planner <version>. To check the MCP side without a client, run the smoke script from the repository. It performs the MCP initialize and tools/list handshake and fails if plan.submit, plan.status or plan.get is missing:

node scripts/mcp-smoke.mjs /absolute/path/to/cpm-planner

Quickstart: plan as code

Write a plan as .cpm-planner/plans/<name>/<variant>.json (a PlanGraph):

  1. plan.lint {path: ".cpm-planner/plans/checkout/main.json"} — static checks, no state written.

  2. plan.sync {path: ".cpm-planner/plans/checkout/main.json"} — register the variant and return its plan_id.

  3. plan.status {plan_id} — schedule, critical path, ready set and locks.

plan.sync tracks the file by content hash; plan.status reports definition_drift when the tracked file and the stored head graph disagree.

Quickstart: earned value

Freeze the baseline, record progress, then read the report (earned_pct is earned in proportion under earning_rule: "weighted"; fifty_fifty credits 50% once a deliverable is in progress or has any reported earned_pct, and the default zero_hundred earns at completion):

  1. plan.baseline {plan_id} — freeze the CPM schedule and budgets as baseline 1.

  2. plan.mark_status {plan_id, deliverable_id, caller_id, status: {"status": "in_progress"}, earned_pct: 50, actual_effort_hours: 4} — report progress and actual cost.

  3. plan.ev {plan_id} — PV, EV, AC, SV, CV, SPI, CPI, EAC and alerts.

  4. plan.snapshot {plan_id, format: "markdown"} — append the reading and export a Markdown table.

A ratio with a zero denominator is null and explained in undefined; alerts (SPI_BELOW_0_9, CPI_BELOW_0_9) compare the two latest stored readings.

Use with AI agents

Any MCP client can run cpm-planner over stdio: register the cpm-planner binary as the command, or, with Node 18 or later, npx -y @matthew-cochran/cpm. For a GUI client, use the absolute path the installer printed.

Install the agent skills (/cpm-plan and friends) with cpm-planner skills install --target <tool> --user (or --project <dir>), where <tool> is claude, codex, cursor, copilot, gemini, all, or agents-md (project only).

docs/AGENT-INSTALL.md has the per-client steps (Claude Code, Claude Desktop, Codex, Cursor, VS Code, Gemini CLI and others), env blocks, verification and troubleshooting. llms.txt indexes the docs for LLMs.

MCP tools

Tool

Does

plan.submit

Submit a task graph; returns a plan id (idempotent on the graph + caller). A prerequisite is a deliverable id string or an object {id, consumes?, kind?: artifact|interface, lag_hours?}; both forms round-trip through plan.get. A deliverable may set milestone: true (an acceptance point, zero-length unless given an estimate or duration, reported in plan.status milestones). An optional earning_rule (zero_hundred, the 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 partial progress and is part of the plan's identity. A deliverable's optional duration_hours (calendar time) replaces effort as its scheduled length and a prerequisite's lag_hours delays the dependent's start; effort stays the cost basis. An optional estimate {optimistic, likely, pessimistic} (0 <= optimistic <= likely <= pessimistic) is a three-point effort estimate. Scheduled-length precedence: duration_hours > estimated_effort_hours > estimate.likely > 0 for a milestone > the estimator default; Monte Carlo samples the estimate only when neither duration_hours nor estimated_effort_hours is set. Limits: at most 5000 deliverables, and every hour value (effort, duration, lag, estimate) finite and between 0 and 1000000 (INVALID_GRAPH). With name (optional project/variant, variant defaults to main) it registers a named variant instead of an unnamed plan.

plan.acquire_cohort

Atomically acquire up to N ready deliverables with mutually disjoint file sets (an owned_files entry may be {path, mode: "append"}; append claims on one path may be co-leased and appear in shared_paths; a file may be owned by several deliverables only if they are ordered by prerequisites, or all claims are append); optional ttl_seconds sets the lease TTL (clamped to the server maximum); optional ids and filter.metadata target deliverables; metadata.kind = "manual" deliverables are never leased; the response lists unleased candidates in blocked (with blocked_count, needs_operator). Thread affinity (threads): the caller that last completed a metadata.thread deliverable is offered that thread's next one first; optional thread prefers one thread, and optional affinity_seconds (default 0) holds back a thread another caller advanced less than that long ago, reported in blocked as AFFINITY_HOLD.

plan.heartbeat

Refresh the TTL on a held lock; optional ttl_seconds sets the new TTL (clamped to the server maximum).

plan.mark_status

Mark a deliverable complete/failed; releases its lock. Without a lock, Complete requires complete prerequisites (PREREQUISITES_INCOMPLETE) and every lockless mark is audited. A lockless in_progress (owner or manual work) persists across server restarts and is not leasable; hand it back with a lockless ready (or any other status) mark. Optional earned-value progress: earned_pct (integer 0 to 100, only with in_progress; accepted and ignored with complete), actual_effort_hours (total effort so far, finite, 0 to 1000000; replaces leased hours as actual cost) and evidence (a note of at most 2048 characters, or an evidence record with a scope and the identity of what was checked; appended to the deliverable's evidence list, which keeps at most 100 entries); violations are INVALID_ACTUALS, including a negative, fractional or over-100 earned_pct. A malformed record is INVALID_EVIDENCE, a record whose scope may not close the deliverable EVIDENCE_SCOPE_MISMATCH, closing a deliverable that declares metadata.evidence_scope without a matching record EVIDENCE_REQUIRED, and a gate record that does not match its metadata.candidate EVIDENCE_STALE. plan.select copies the actuals of every deliverable whose Complete status it carries (leased hours add up, the carried deliverable's reported percent 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 to the deliverable's leased hours; a lapsed lease counts up to its expiry.

plan.status

Read-only snapshot of the plan and its locks. critical_path always runs __start__ to __finish__ (synthetic zero-effort endpoints; their schedule rows carry synthetic: true, and __start__/__finish__ are reserved deliverable ids rejected with INVALID_GRAPH). A forced revision that invalidates a lease reports superseded_revision; it is re-acquirable after its prerequisites complete but the stale holder cannot mark it complete. plan_complete is true once every deliverable is Complete; the plan.completed audit event fires once when that happens. milestones has one row per deliverable with milestone: true (or legacy metadata.milestone == true): id, critical_path (longest chain from __start__ to it), hours (its earliest finish) and complete. For a named variant it also reports 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: null (unknown) without a project root for the variant's project, without a tracked plan file, or when the file is missing, unreadable or over 8 MiB; otherwise false when the file's bytes match the last synced or exported hash or the file's graph equals the head graph (re-formatting is not drift), and true when the file's graph differs from the head (or does not parse). An inline plan.revise/plan.sync of a file-backed variant therefore reports true until the file is re-exported or re-synced.

plan.get

Return the stored plan graph for a plan selected by plan_id, plan line name or active: true (read back what was submitted).

plan.force_release

Operator escape hatch: release a lock regardless of holder/TTL; reset_counters: true also clears lapse/failure counters. A lockless in_progress has no lock, so plan.force_release leaves it as is; hand it back with a lockless plan.mark_status.

plan.accept

Manager/owner acceptance: complete a deliverable without holding its lease (audited, with evidence: a note or an evidence record, checked as on plan.mark_status and appended to the deliverable's evidence list).

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, and unordered file overlaps (#20).

plan.schedule

Level a graph against resource capacities (metadata.owner by default): makespan, per-deliverable start/finish, per-resource load, the driving chain (dependency vs named resource waits), and project/feeding buffers (#19). A deliverable can atomically claim multiple pools with metadata.resources: {"agent":1,"builder":1}; every claimed pool needs enough capacity. duration_hours with metadata.wall_clock_only: true models CI/observation time that blocks dependents but consumes no capacity. project_buffer_pct is 0 to 100 (default 25). Optional calendar (one working calendar, or a per-resource map with a default entry) and start (RFC 3339, default now) place each deliverable in its resource's working windows: hours become wall-clock hours from start, rows add start_at, finish_at, calendars and calendar_wait_hours, and the driving chain reports waited_on: "calendar" (calendars). Without calendar the output is unchanged.

plan.simulate

Read-only what-if for a graph, stored plan or plan file (persists nothing): lint, critical path, schedule, milestones, optional resource schedule (schedule, same inputs and INVALID_CAPACITIES: rule as plan.schedule) and Monte Carlo (monte_carlo: iterations 1 to 50000, default 2000; seed, default 0xC0FFEE; iterations × (deliverables + prerequisite edges) must not exceed 200000000), and the scorecard (#23). Optional calendar and start, as on plan.schedule, apply to the resource schedule and to Monte Carlo.

plan.release.compile

Compile versioned, bounded workflow/promotion metadata into a lint-clean ordinary plan: protected PRs, parallel checks/joins, owner waits/approvals/merges, producer retry receipts, retained artifacts, native fixed-cost integration waves/evidence reuse, and central acceptance. Reports batched-versus-per-change gate economics and dominance warnings; runs offline and never executes or accepts work (release workflows).

plan.release.diff

Compare a retained generated graph with current workflow metadata; report policy/estimate drift and added, removed or changed ids before revising or baselining (release workflows). CLI equivalents: cpm-planner release compile, release lint, and release diff.

plan.sync

Register or update one variant of a named plan line from a plan file (path, of the form .cpm-planner/plans/<name>/<variant>.json) or an inline graph (requires name; variant defaults to main). A file is read with a no-follow confined path and tracked by content hash for drift detection; project defaults to the discovered project root; with path, any name/variant/project given must match the file's (INVALID_PATH: name/variant/project must match the plan file path); force releases live locks of removed deliverables.

plan.list

List every plan line of project (default: discovered project root), sorted by name with variants sorted; archived lines and variants are omitted unless include_archived. active: true returns only the project's active lines and cannot be combined with include_archived.

plan.export

Write the head graph of plan_id to its own variant file, or to a confined path, and return the root-relative path. Refuses (INVALID_PATH) another variant's tracked plan file, or the variant's own file while it holds local edits never synced, unless force: true. The written file is not synced, except that exporting to the variant's own tracked file records its hash (drift is then false).

plan.revise

Replace a plan's graph in place, carrying progress over (unchanged deliverables keep status/counters; changed or reopened ones are re-derived). A deliverable can declare metadata.consumes_revision_from for a direct prerequisite; changing that barrier or any prerequisite refuses its active lease unless force, which reports a diagnostic superseded_revision and preserves its prior evidence/hours. Returns the new revision and diff; force releases live locks of removed deliverables.

plan.fork

Copy a named variant's head graph, apply ordered edits, and register the result as a new draft (not selected) variant of the same line. Forking into an archived line is ARCHIVE_REFUSED before any file is written; forking from an archived variant of a live line is allowed. If registration fails after the file was written, the file is removed.

plan.select

Make a named variant its line's selected (only executable) variant, carrying progress over; force releases locks on the previously selected variant.

plan.archive

Archive (archived defaults true) or unarchive a whole line or one variant. Archived variants stay readable but refuse sync, selection and execution (ARCHIVE_REFUSED).

plan.compare

Compare stored plans on the scorecard (plan_ids, 2 to 16 distinct ids, or plan line name in project (default: the discovered root) for every non-archived variant, at most 16): Pareto front, weighted rank and recommended plan; equal scores share a rank and tied_with_recommended lists the variants tied with the recommendation. Weights must be finite and >= 0. With monte_carlo, iterations × (deliverables + prerequisite edges) summed over all variants must not exceed 200000000 (INVALID_GRAPH). Read-only; scoring runs off the async runtime.

plan.baseline

Freeze the plan's current CPM schedule (earliest start/finish per deliverable), or with capacities the levelled schedule plan.schedule computes, and budgets (effort basis × 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), day_start ("HH:MM", default 00:00), workdays (default mon–fri), utc_offset_minutes (default 0)} (omitted: wall-clock hours on the first baseline; a re-baseline keeps the previous baseline's calendar; null returns to wall-clock hours; a map with a default entry gives each resource its own calendar, so PV accrues only inside each deliverable's windows). Optional capacities, resource_key and project_buffer_pct (reused by a re-baseline that omits them) and schedule ("cpm" or "levelled") choose the frozen schedule (calendars). The first baseline is number 1; re-baselining needs a non-blank reason (at most 2048 characters, INVALID_GRAPH otherwise) and keeps actuals and snapshots. Baselines, actuals and snapshots belong to one variant: a newly selected variant takes its own baseline 1, with no reason needed. Execution-side: only the selected, unarchived variant (VARIANT_NOT_SELECTED / ARCHIVE_REFUSED). Audited as plan.ev.baselined.

plan.ev

Earned-value report against the latest baseline as of as_of (RFC 3339, default now). as_of is the PV status date; EV and AC reflect progress and actuals recorded up to the moment the call runs. Returns BAC, PV, EV, AC, SV, CV, SPI, CPI, EAC, ETC, VAC, TCPI, per-deliverable rows, critical float consumed, SPI_BELOW_0_9 / CPI_BELOW_0_9 alerts (metric 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) and excluded_unbaselined (deliverables added since the baseline). AC sums every recorded hour of the plan, removed and unbaselined deliverables included (at the baseline row's cost rate, or 1 without one), so spend never disappears, even after a re-baseline. A baselined deliverable that plan.revise removes keeps the percent it had earned at removal (100 if complete, else its earning rule's percent) and reports row status removed; adding it back restarts its earned percent while its hours keep accumulating. A ratio with a zero denominator is null and explained in undefined; output never contains NaN or infinity. 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; NOT_BASELINED before plan.baseline.

plan.snapshot

Compute the plan.ev report and append it as a snapshot (as_of is the PV status date; EV and AC are the progress recorded when the call runs). A snapshot whose as_of is more than an hour before its taken_at is marked backfilled: true: it raises no alerts and later alerts skip it. Returns its summary (with undefined explaining each null ratio; alerts consider only readings up to its own position: this snapshot and the latest earlier non-backfilled 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 when they were taken), 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", undefined ratios shown as n/a). Execution-side like plan.baseline; NOT_BASELINED before it.

plan.review

Optional AI review of a graph, stored plan or plan file (read-only; exactly one of graph, plan_id, path; optional capacities / resource_key / project_buffer_pct to level makespans, and max_questions 1 to 64, default 64). Lint always runs; lint errors return status: "invalid_graph" without calling the judge. Otherwise one batched call to Jev asks about likely false and missing dependencies, split and interface-split candidates and crash options, and returns advisory findings plus proposals: plan.fork edit lists, each verified by simulate (lints clean, shortens the makespan) and ranked by hours_saved / max(cost, 1). Without a key, or with an unusable LLM setting, status is review_unavailable with a reason, plus lint. Every review reports provider (direct, command or disabled). Run plan.optimize first; see Plan review (optional).

plan.overview

Read-only, deterministic one-call snapshot of a plan at as_of (default now) that joins plan.status, plan.get and plan.ev with each deliverable's evidence status: counts, the EV headline, the critical path with remaining hours, milestones, ready work (with its action, float and file conflicts), blocked work (with its blocker class and root blockers), leases, attention items and stale evidence. detail brief (default) or full, limit 1 to 1000, filter {owner, milestone}, evidence_target (an identity every record is judged against) and format json (default) or markdown. CLI: cpm-planner overview, Markdown by default (overview).

plan.explain

Read-only, deterministic: for deliverable_id, why it is not moving (state, blocker class, root blockers, driving chain) and what completing it changes (what it unlocks, the milestones it feeds with their slack, its critical-path impact); always the plan's next work in classes (ready and critical, ready with slack, blocked by dependency, manual, external or gate, file-conflicting). as_of, limit and format as for plan.overview. CLI: cpm-planner explain.

plan.optimize

Offline, deterministic optimization of a graph, stored plan or plan file (exactly one of graph, plan_id, path; optional capacities / resource_key / project_buffer_pct and max_proposals, default 32). Proposes plan.fork edit lists (same-owner merges, false dependencies, parallel cohorts, gate consolidation, work bypassing a gate), each checked by lint, the schedule and simulate, keeping acceptance criteria, gates, milestones, approvals, contracts and owned files, never lengthening the makespan or adding effort; rejected candidates come with their reasons. No model or network call; a graph that does not simulate is INVALID_GRAPH. CLI: cpm-planner optimize (optimize).

plan.status, plan.get, plan.ev, plan.snapshot, plan.baseline, plan.overview, plan.explain, plan.acquire_cohort, plan.heartbeat, plan.mark_status, plan.accept and plan.force_release address a plan by exactly one of plan_id, plan (a plan line name, resolved to its selected variant) or active: true (the project's single line that is unarchived and whose selected variant is unarchived and not complete). plan and active also take an optional project (default: the discovered project root); project with plan_id is invalid params. No active line is NO_ACTIVE_PLAN; several are AMBIGUOUS_ACTIVE_PLAN, naming them; an unknown line, or a line with no selected variant, is PLAN_NOT_FOUND. Recurring jobs and agents should look the plan up with active (or plan) rather than pin a plan_id, so they follow the work to the next line. active matches only an incomplete line, and it changes (or becomes ambiguous) when another line goes live: resolve once (plan.status and plan.acquire_cohort return the plan_id), then keep that plan_id for heartbeats, marks and the final plan.status/plan.ev of that plan. A pinned id of an archived variant still reads through plan.status/plan.ev, which report archived: true and active_plan naming where the live work is, while snapshots, baselines and execution refuse with ARCHIVE_REFUSED.

plan.lint, plan.simulate, plan.optimize 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. Monte Carlo output is reproducible for a given seed on the same platform and toolchain; bit-identical results across targets or compiler versions are not guaranteed, because float math functions can differ.

Plan-as-code workflow

Author a plan as a file at .cpm-planner/plans/<name>/<variant>.json, check it with plan.lint {path}, register it with plan.sync {path} and execute the returned plan_id. A named line has many variants but exactly one selected: plan.fork creates a draft from structured edits, plan.compare scores the variants, and plan.select makes one executable. Keep definitions in tracked files — never keep untracked scratch graphs.

Plan review (optional)

Run plan.optimize first: it is free, offline and deterministic, and it catches the structural waste the graph shows by itself (false dependencies, same-owner merges, redundant gates). plan.review stays the optional AI step for the judgments the graph cannot make alone.

plan.review asks Jev (typesafe/jev-1.13), TypeSafe's calibrated-judgment model, about a lint-clean plan through OpenRouter: one batched call of at most 64 questions per review. It is off until you set OPENROUTER_API_KEY (or CPM_OPENROUTER_KEY_FILE); without a key the tool still answers, with review_unavailable and the lint report. An invalid LLM setting never stops the server: it is logged at startup and plan.review reports review_unavailable with a reason naming the setting.

  • Probabilities are advisory. Treat findings as a second opinion. Proposals are verified mechanically (the edited plan must lint clean and simulate to a shorter makespan); apply one with plan.fork {edits}, then plan.compare.

  • Cost. Each plan.review call that reaches the judge is one billed OpenRouter request; Jev costs about $0.042 per million input tokens on OpenRouter. Each report carries the provider's usage when given. At most 2 reviews that call the judge run at once per server; further calls wait for a slot (reviews that end before the call, such as no key or lint errors, never wait).

  • Privacy. This is everything sent to OpenRouter in that one request:

    • a fixed task sentence, the plan makespan and the critical path (ids);

    • the questions: for each, its kind, the deliverable ids it names, and a question sentence that quotes those ids and, depending on the kind, the prerequisite's consumes text, the deliverable's scheduled hours, the plan's median scheduled hours, and the heuristic signals that raised a missing-dependency question (a description mentions the other, files in the same or nested directories, a shared metadata.owner);

    • for every deliverable a question names: its id, scheduled hours, float hours, critical and milestone flags, owned file paths (sent in full, not truncated), prerequisites (id, consumes, kind) and its description, artifact and owner metadata. description, artifact, owner and consumes are cut to 500 characters each.

    Nothing else in the graph (other deliverables, other metadata keys, estimates) is sent. Do not review plans whose contents you may not share with OpenRouter.

  • Audit. The key is never logged or returned, and neither is a misplaced value of any LLM variable. Every plan.review call records one plan.review audit event, whatever its outcome (including a missing plan, a bad path or a failed review); the only unaudited case is a call rejected for its params (invalid_params). The event has exactly these fields:

    • status: ok, review_unavailable, invalid_graph, or error (the review failed: PLAN_NOT_FOUND, INVALID_PATH, INVALID_CAPACITIES, …);

    • code: the error prefix for error, else null;

    • failure_class: for a review_unavailable caused by a failed judge call, its class (unauthorized, rate_limited, timeout, upstream, decode, transport, invalid_request); null otherwise (including no key and an unusable LLM setting);

    • plan_id: the stored plan reviewed, null for an inline graph or path;

    • question_count: questions sent (0 when the judge was not called);

    • jev_called: whether the judge was called;

    • prompt_hash: sha256 of the canonical request, null when the judge was not called;

    • model: the model the provider reported, else the configured model when the call failed; null when the judge was not called;

    • endpoint: the judge endpoint's host; null when no judge is configured, lint found errors, or the review ended in error.

    The prompt itself is never recorded.

Use as a library

The CPM kernel is also a plain Rust library, independent of MCP:

use cpm_planner::{CpmAlgorithm, Task, TaskKind};

let mut tasks = vec![
    Task::new("design", "Design", TaskKind::Custom { description: "design".into() }, 4.0),
    Task::new("build", "Build", TaskKind::Custom { description: "build".into() }, 8.0)
        .depends_on("design"),
    Task::new("test", "Test", TaskKind::Custom { description: "test".into() }, 2.0)
        .depends_on("build"),
];

let result = CpmAlgorithm::calculate(&mut tasks);
println!("critical path: {:?}", result.critical_path); // ["design", "build", "test"]
// also: result.bottlenecks, result.optimal_duration_parallel,
// and per-task .float (slack) / .is_critical on each Task.

The crate-level docs (cargo doc --open, or docs.rs once a current version is published there) have a runnable example that drives the planner through submit, lease and complete.

llm::jev::JevJudge (the plan.review judge) owns its HTTP client and connection pool. A pooled connection is driven by the tokio runtime that opened it, so use a judge (and its clones) from one runtime, and build a new judge for another runtime.

Praxec

cpm-planner is fully standalone: it speaks plain MCP and has no code dependency on any particular client. Praxec packs use it as an MCP tool. To wire it into a praxec workflow, declare an MCP connection (protocol only, no shared code):

connections:
  planner:
    kind: mcp
    command: cpm-planner

The easiest way to get it, plus a workflow pack that uses it, up and running is the one-command setup:

curl -fsSL https://raw.githubusercontent.com/praxec/packs/main/setup.sh | bash

See the pack registry for this tool's provider coordinates (container image or release binary) and which packs depend on it.

Environment variables

Set these in a client's env block (or -e/--env flag, or docker run -e). All are optional.

Variable

Default

Meaning

CPM_PLANNER_DB

~/.local/share/praxec/cpm-planner.db on every OS (~ is HOME, else USERPROFILE: %USERPROFILE%\.local\share\praxec\cpm-planner.db on Windows); /data/cpm-planner.db in the Docker image

SQLite path for durable, cross-process planner state; :memory: gives ephemeral state.

CPM_PLANNER_STATE_MODE

unset

Leave unset for normal durable-state opening. If an existing database is empty or its sidecar receipt detects missing known plans, startup refuses to mutate it. Set to recover only after reviewing the timestamped backup path reported by the server; initialize explicitly accepts an existing empty location. Every MCP result reports durable_state with the resolved location, outcome, and any backup path.

CPM_PROJECT_ROOT

nearest ancestor of cwd with .cpm-planner/ or .git

Repo root for plan-as-code files (.cpm-planner/plans/<name>/<variant>.json). Tools that need a root report INVALID_PATH: no project root (set CPM_PROJECT_ROOT or run inside a repo) when none is found.

CPM_MAX_TTL_SECS

28800 (8h)

Server-side ceiling for ttl_seconds on plan.acquire_cohort and plan.heartbeat; larger requested values are clamped. Must be an integer in 1..=2592000 (at most 30 days) — any other value aborts startup.

OPENROUTER_API_KEY

unset

OpenRouter key for plan.review. Unset or blank: plan.review reports review_unavailable ("no OpenRouter key configured"). Never logged or returned.

CPM_OPENROUTER_KEY_FILE

unset

File holding the OpenRouter key (contents trimmed; at most 4096 bytes), used when OPENROUTER_API_KEY is unset. On unix a world-readable (o+r) or empty file is ignored with a warning (chmod 600 it).

CPM_REVIEW_PROVIDER

direct

Who answers plan.review: direct (OpenRouter, using the key settings here), command (the adapter below) or disabled. There is no fallback between providers. cpm-planner review doctor reports readiness by setting name, never by value.

CPM_REVIEW_COMMAND

unset

Absolute path of the review adapter for CPM_REVIEW_PROVIDER=command. See docs/review-provider.md.

CPM_REVIEW_COMMAND_ENV

unset

Comma-separated variable names to pass to the adapter, whose environment is otherwise cleared. It may not name the OpenRouter key variables.

CPM_JEV_MODEL

typesafe/jev-1.13

Jev model id for plan.review: at most 128 characters of A-Z a-z 0-9 . _ : / -, and never containing the key; anything else is logged (naming the variable, not the value) and makes plan.review unavailable.

CPM_JEV_ENDPOINT

https://openrouter.ai/api/v1/systemone

Jev endpoint URL: https://, or http:// only for a loopback host, with no credentials.

CPM_LLM_TIMEOUT_SECS

30

Bound on each LLM call, integer seconds in 1..=300.

CPM_LLM_MODEL

deepseek/deepseek-v4.1-flash

Generative (chat) model id: at most 128 characters of A-Z a-z 0-9 . _ : / -. A frontier vendor is refused before any call, with a typed error naming the model: anthropic/, openai/ (except openai/gpt-oss*), google/, x-ai/, xai/.

Leases default to 5 minutes. For long-running work pass ttl_seconds (≤ the server maximum) on acquire/heartbeat, and heartbeat at least every ttl/3.

Documentation

Contributing

Contributions are welcome. See CONTRIBUTING.md for the toolchain, the gates and the branch model. Everyone taking part agrees to the Code of Conduct.

License

Apache-2.0.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      B
      quality
      D
      maintenance
      A durable DAG-based task planner exposed as an MCP server that lets AI orchestrators break a goal into a dependency graph of tasks, execute them in parallel where possible, track state durably, and handle human-in-the-loop approval through 22 MCP tools.
      24
      MIT
    • A
      license
      A
      quality
      C
      maintenance
      Enables agents to submit and manage persistent, dependency-aware task graphs with immutable artifacts, resource reservations, durable event streaming, and retryable process execution over MCP.
      12
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables MCP-compatible AI clients to query, analyze, and mutate Oracle Primavera P6 schedules from XER files or live P6 EPPM, with tools for critical path, DCMA compliance, earned value, resources, baselines, and exports.
      35 PyPI
      1
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables AI clients to build and calculate critical-path project plans through tools, including resource leveling, vacation and holiday calendars, what-if scenario comparison and Monte Carlo schedule risk simulation. Projects are read from and written as plain JSON files.
      MIT