Plans
PlanManager — multi-step plans with stable step IDs, revision history, and a next-step picker that respects dependencies.
Plans are one half of the cross-session state thematic island — paired with memory. Plans span turns within or across sessions; memory spans sessions. Both outlive a single session arc; neither fits the runtime-lifecycle cluster.
A Plan is the variable surface for work that spans more than one
turn: the steps the agent committed to, which session executed
each one, what evidence each one produced, and the
revision-by-revision history of how the plan changed. The
substrate doesn't pick the steps — the application's planner does
— but it owns the persistence shape, the dependency-aware step
picker, and the revision audit trail. See Lineage
for how plans join sessions, and Subagents for
how a step's suggestedAgent routes to a spec.
Ships from @pleach/core/plans. PlanManager is the orchestrator;
Plan, PlanStep, and PlanRevision are the wire shapes.
import {
PlanManager,
type Plan,
type PlanStep,
type PlanRevision,
} from "@pleach/core/plans";Plan shape
| Field | Type | Notes |
|---|---|---|
planId | string | 10-char id, assigned at create |
title | string | Human-readable |
objective | string | High-level goal |
status | enum | active / completed / paused / abandoned |
steps | PlanStep[] | Ordered |
createdAt | ISO-8601 | UTC |
createdInSession | string | Session that created the plan |
createdInChat | string | Chat that created the plan |
updatedAt | ISO-8601 | Bumped on every revision |
revision | number | Monotonic; bumped on every mutation |
tags | string[] | Optional categorization |
status flips to completed automatically when every step is
completed or skipped — PlanManager.updateStep moves the
record from the active namespace to the completed namespace at
that boundary.
PlanStep shape
| Field | Type | Notes |
|---|---|---|
stepId | string | Stable across revisions — step_ + 8-char id |
title | string | Short label |
description | string | What this step should accomplish |
status | enum | pending / active / completed / skipped / failed |
executedInSession | string | undefined | Session that ran this step |
executedInChat | string | undefined | Chat that ran this step |
suggestedAgent | string | undefined | Recommended spec name |
suggestedTools | string[] | undefined | Expected tool calls |
dependsOn | string[] | undefined | Other stepIds that must complete first |
completionEvidence | object | undefined | canvasCards, jobIds, summary |
updatedAt | ISO-8601 | Bumped on every step mutation |
stepId stability is the load-bearing invariant — revisePlan
preserves completionEvidence, executedInSession, and
executedInChat for steps whose stepId survives the revision,
even if their position or surrounding steps change. Steps without
a passed-in stepId get a new one and start clean.
PlanManager methods
| Method | Returns | Effect |
|---|---|---|
createPlan(input) | Plan | Assigns planId, stamps revision 1, writes the initial PlanRevision snapshot |
updateStep(planId, stepId, update, sessionId) | Plan | Mutates one step; auto-flips plan to completed when all steps are completed or skipped |
revisePlan(planId, revision, sessionId) | Plan | Replace title, objective, or the full step list; rejects revisions to completed plans |
getActivePlans() | Plan[] | Up to 50 |
getCompletedPlans() | Plan[] | Up to 50 |
getPlan(planId) | Plan | null | Checks active first, then completed |
getRevisions(planId) | PlanRevision[] | Sorted ascending by revision |
getNextStep(plan) | PlanStep | null | First pending step whose dependsOn are all completed or skipped |
buildActivePlanSection(plans) | string | Renders an active-plan summary block for system-prompt injection |
getNextStep is pure; the rest read or write through the
BaseStore the manager was constructed with.
Revision history
Every mutation writes a PlanRevision to a per-plan namespace
alongside the plan itself.
| Field | Type | Notes |
|---|---|---|
revision | number | Matches Plan.revision at the time of the snapshot |
snapshot | Plan | structuredClone of the plan at this revision |
changeDescription | string | Free-text; updateStep writes Step "<title>" → <status> |
sessionId | string | Which session made the change |
timestamp | ISO-8601 | UTC |
Revisions are append-only. getRevisions(planId) returns them
sorted ascending so a UI can render the plan's evolution as a
timeline without re-sorting.
Example
import { PlanManager } from "@pleach/core/plans";
import { MyStore } from "./my-store";
const plans = new PlanManager(new MyStore(), orgId, userId);
const plan = await plans.createPlan({
title: "Q3 vendor evaluation",
objective: "Pick a vendor for the queue rewrite.",
steps: [
{ title: "List candidates", description: "Pull from prior research." },
{ title: "Run benchmarks", description: "p99 + cost.",
dependsOn: [] /* filled in after createPlan assigns stepIds */ },
{ title: "Write recommendation", description: "1-pager." },
],
sessionId: session.id,
chatId: chat.id,
});
// Pick what to do next — dependency-aware.
const next = plans.getNextStep(plan);
if (next) {
await plans.updateStep(
plan.planId,
next.stepId,
{ status: "active", executedInSession: session.id, executedInChat: chat.id },
session.id,
);
}Revising mid-flight
The reader can see how revisePlan carries stepIds forward so
completed work survives a re-plan.
const current = await plans.getPlan(plan.planId);
if (!current) throw new Error("plan vanished");
const revised = await plans.revisePlan(
current.planId,
{
steps: [
// Pass existing stepIds back in to preserve their evidence + executedIn* fields.
...current.steps.filter((s) => s.status === "completed"),
{ title: "Cross-check sources", description: "Verify two corroborating refs." },
{ title: "Write recommendation", description: "1-pager." },
],
changeDescription: "Inserted cross-check after benchmark results came in.",
},
session.id,
);Steps without a stepId get a fresh one and start pending. Steps
whose stepId survives the revision keep completionEvidence,
executedInSession, and executedInChat even if their position
shifts.
Walking revision history
The snippet renders a plan's evolution by reading revisions ascending.
const history = await plans.getRevisions(plan.planId);
for (const rev of history) {
console.log(
`r${rev.revision}`,
rev.changeDescription,
`(${rev.snapshot.steps.length} steps)`,
);
}changeDescription is the free-text label updateStep and
revisePlan write; the snapshot is a full structuredClone of the
plan at that revision, so a UI can diff one snapshot against the
next without re-querying.
Where to go next
Agents
The persistent per-spec profile a step's `suggestedAgent` resolves against.
Subagents
Steps with a `suggestedAgent` route to subagent specs.
Lineage
Plans persist across chats — lineage joins the sessions that executed each step.
Query
Aggregate readers project plan + revision history into a review dashboard.
Subpath exports
`@pleach/core/plans` ships PlanManager + the public types.