Skip to content
pleach
Build

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 history. 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

FieldTypeNotes
planIdstring10-char id, assigned at create
titlestringHuman-readable
objectivestringHigh-level goal
statusenumactive / completed / paused / abandoned
stepsPlanStep[]Ordered
createdAtISO-8601UTC
createdInSessionstringSession that created the plan
createdInChatstringChat that created the plan
updatedAtISO-8601Bumped on every revision
revisionnumberMonotonic; bumped on every mutation
tagsstring[]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

FieldTypeNotes
stepIdstringStable across revisions — step_ + 8-char id
titlestringShort label
descriptionstringWhat this step should accomplish
statusenumpending / active / completed / skipped / failed
executedInSessionstring | undefinedSession that ran this step
executedInChatstring | undefinedChat that ran this step
suggestedAgentstring | undefinedRecommended spec name
suggestedToolsstring[] | undefinedExpected tool calls
dependsOnstring[] | undefinedOther stepIds that must complete first
completionEvidenceobject | undefinedcanvasCards, jobIds, summary
updatedAtISO-8601Bumped 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

MethodReturnsEffect
createPlan(input)PlanAssigns planId, stamps revision 1, writes the initial PlanRevision snapshot
updateStep(planId, stepId, update, sessionId)PlanMutates one step; auto-flips plan to completed when all steps are completed or skipped
revisePlan(planId, revision, sessionId)PlanReplace 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 | nullChecks active first, then completed
getRevisions(planId)PlanRevision[]Sorted ascending by revision
getNextStep(plan)PlanStep | nullFirst pending step whose dependsOn are all completed or skipped
buildActivePlanSection(plans)stringRenders 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.

FieldTypeNotes
revisionnumberMatches Plan.revision at the time of the snapshot
snapshotPlanstructuredClone of the plan at this revision
changeDescriptionstringFree-text; updateStep writes Step "<title>" → <status>
sessionIdstringWhich session made the change
timestampISO-8601UTC

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.

Revision history is not the audit ledger

PlanRevision rows live in a per-plan BaseStore namespace — they are a product feature (undo, timelines, diffing), not part of the event log's hash-chained compliance surface, and they carry no tamper-evidence. Per the two-ledgers doctrine, plan effects are what a review reads: the tools that actually ran (tool.* rows) and the turn outcomes. Plan provenance itself is classified engineering-only.

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