Development Preview · PR #2551 · 58d61b2 · built
Skip to content

Plan Review

The plan-review subsystem turns the decomposed breakdown of an objective from a transient value into a durable, reviewable, editable first-class entity. When the plan-approval gate is enabled, splittable team work is decomposed into a Plan, persisted, and parked for a human decision before any team builds. An operator can read, rework, or send the plan back for changes through the /plans API and the Plan Review workspace, then approve or reject it through the existing /approvals decision path.

Plan Review is the single review surface for shaping an initiative: a request yields one plan reviewed as a whole, never a scatter of per-item approvals. This is the sole reason approval-gating defaults on (see Conversational entry); mid-build implementation forks are a separate, narrow surface documented in agent-execution.md.

Durable Plan Entity

Plan (core/plan.py) is the first-class replacement for a plan that previously lived only as a DecompositionResult serialised into an approval's metadata. It is persisted, versioned, and revisable, and outlives the approval decision: the approval carries only the plan's plan_id.

  • Plan: id (UUID), project, objective_id, objective_title (denormalised at creation so the surface never resolves, or falls back to, a raw id), parent_task_id, items (ordered tuple forming a dependency DAG), task_structure, coordination_topology, status, failure_reason (why a FAILED plan failed, None otherwise), forecast_id, review (the consolidated stakeholder-panel review, or None), open_questions and assumptions (what the planner surfaced for the human), objective_criteria (the objective's acceptance criteria, denormalised for the coverage map), version_history (snapshots of prior submitted versions), version, created_at, updated_at. A model validator rejects an empty item list for every status except the PLANNING / FAILED shells (which may carry no items), duplicate item ids, an unresolvable dependency, or a dependency cycle (topological sort); a second validator ties failure_reason to the FAILED status (present iff FAILED). A malformed plan is caught at construction rather than as a dispatch failure.
  • PlanItem: id (a canonical UUID string, because dispatch rebuilds each child task from it), title, description, dependencies, owner, acceptance_criteria, expected_artifacts, required_skills, required_tags, estimated_complexity, stakes, kind (WORK or DECISION), options and chosen_option_id (decision items), and satisfies (the objective criteria this item advances). A validator rejects a non-UUID id, a self-dependency, or duplicate dependencies.

Decision items

A PlanItem with kind = DECISION is a real choice the plan hinges on (stack, architecture) rather than a unit of work. It carries options (at least two PlanOptions, exactly one recommended, unique ids) and an optional chosen_option_id; a WORK item carries neither (validate_decision_options). The generator emits a decision where a genuine choice exists; the reviewer records the pick on the review workspace, and PlanItem.resolved_option() resolves it to the chosen option or, absent a pick, the recommended one. Decision items are not executed: decomposition_from_plan strips them from the dispatchable tree (and from remaining items' dependencies), and on approval each resolved decision is recorded into the project brain as a first-class DECISION entry (api/controllers/_plan_decision_record.py) so the company's shaping choices survive rather than vanishing.

Stakeholder review panel

Before the plan is parked for the human, a bounded panel of stakeholder agents reviews it (engine/plan_review/). select_review_panel seats the relevant leads (CTO, CFO, department heads for the domains touched, a senior peer), sized to the plan and excluding the owner (no self-review). Each panellist runs a bounded persona session (AgentSessionPlanReviewPanel) and submits a structured verdict (ENDORSED / CONCERNS / REVISION_REQUESTED) with categorised findings; a deterministic synthesis (synthesise_review) consolidates them onto Plan.review (overall verdict = the most severe). The panel is wired best-effort at startup and runs as a distinct pipeline phase between decompose and the human gate; when no panel is attached the plan is parked with review = None.

Lifecycle (PlanStatus)

stateDiagram-v2
    [*] --> PLANNING
    PLANNING --> PENDING_REVIEW: decomposition fills the shell
    PLANNING --> FAILED: decomposition failed / empty
    DRAFT --> PENDING_REVIEW
    PENDING_REVIEW --> APPROVED
    PENDING_REVIEW --> REJECTED
    PENDING_REVIEW --> FAILED: approval-park failed
    PENDING_REVIEW --> DRAFT: edit / request-changes
    DRAFT --> SUPERSEDED: superseded by a re-plan
    PENDING_REVIEW --> SUPERSEDED: superseded by a re-plan
    APPROVED --> [*]
    REJECTED --> [*]
    FAILED --> [*]
    SUPERSEDED --> [*]

Plan-first-from-greenlight. When a splittable initiative is greenlit, a PLANNING shell (no items yet) is persisted before decomposition runs, so every greenlit objective leaves a first-class, visible plan even if decomposition never completes. Decomposition fills the shell in place (moving it to PENDING_REVIEW); a decomposition that fails or produces no items transitions the shell to FAILED, carrying a failure_reason the review surface shows, rather than leaving a silent orphan task. A plan can also reach FAILED after decomposition succeeded, if parking the approval fails: it is then FAILED with its items intact, so FAILED permits (but does not require) an empty item list. The PLANNING and FAILED statuses are the only ones permitted to carry an empty item list (enforced by the model validator and the SQLite / Postgres items CHECK); every other status requires a non-empty, validated item DAG. A failure_reason is present iff the status is FAILED (a cross-field model validator enforces both directions).

An edit or request-changes is accepted only from a reworkable status.

DRAFT and PENDING_REVIEW are the reworkable statuses; PLANNING is a transient shell (not operator-reworkable); APPROVED, REJECTED, SUPERSEDED, and FAILED are terminal. An operator rework or request-changes is accepted only from a reworkable status, so a decided or failed plan cannot be revived (a retry is a fresh run). Each edit bumps version, and every write is version-guarded (optimistic concurrency): a stale writer is rejected with a conflict rather than silently clobbering a concurrent edit.

Persistence

PlanRepository (persistence/plan_protocol.py) composes the ADR-0001 generics IdKeyedRepository[Plan, NotBlankStr] + FilteredQueryRepository[Plan, PlanFilterSpec], with SQLite and Postgres implementations kept in parity. The plans table stores items as JSON (a non-empty array for every status except the PLANNING / FAILED shells, which may carry no items, CHECK-enforced), the nullable failure_reason (non-blank when present, CHECK-enforced), and review / open_questions / assumptions / objective_criteria / version_history as JSON columns; Postgres uses TIMESTAMPTZ for the timestamps and a composite (project, status, id) index for the combined-filter list query. update() takes an expected_version guard and raises PersistenceVersionConflictError when the stored version has moved.

Per-item discussion lives in a separate append-only store, PlanItemCommentRepository (persistence/plan_comment_protocol.py, composing AppendOnlyRepository), backed by the plan_item_comments table. Comments are immutable and written independently of the version-guarded plan row, so posting a comment never conflicts with a concurrent rework. Each comment carries an author_kind (human/agent), the responding agent's id for an agent comment, and a flat reply_to_id linking a reply to the message it answers: the item is the thread, so a reply is a parent link, not a nested tree. This keeps the append-only immutability (each comment and reply is its own row) while making a comment reply-bearing and agent-answerable.

When a reply model is configured, a human comment is answered inline by the responsible role: PlanItemReplyService (engine/plan_review/reply.py) resolves the responder (the item's owner role if an active agent holds it, else the Chief of Staff) and makes ONE grounded, fenced completion call (not a ReactLoop, no tools) over the item's own text, then appends an attributed agent reply linked to the operator's comment. It is loop-safe (only a human comment is answered, so an agent reply never triggers another) and failure-isolated: the human POST .../comments always returns 201 even if reply generation fails, and the reply is gated live per comment by coordination.plan_review_reply_enabled (opt-out, default on). Lightweight discussion never resets the plan; only request-changes does that.

Decomposition Projection

engine/decomposition/plan_mapping.py projects both directions so the gate, the API, and the resume path stay in step:

  • plan_from_decomposition() builds a durable Plan from an executed DecompositionResult (subtasks become plan items).
  • decomposition_from_plan() rebuilds a dispatchable DecompositionResult from a (possibly operator-edited) durable plan, so the tree that builds on approval is exactly the plan under review. Each child task carries the item's acceptance criteria and expected artifacts, so the fail-loud zero-artifact guard engages on the plan-review dispatch path.

Conversational entry

A work request in the unified chat (a /meta/chat/turn classified propose) is a first-class producer of plans. A conversational brief becomes ONE durable objective, not a list of candidate work items to approve individually. ConversationalPlanDispatcher (meta/chief_of_staff/plan_intake.py) provisions or reuses a project (a uuid5 keyed on the conversation, so a follow-up turn lands on the same project), builds a single WorkItem with plan_required=True, and runs intake_only synchronously so the operator gets an immediate PlanDraftSummary (task id, project, title). Execution is backgrounded: continue_from_intake decomposes the objective and, because plan_required forces a SPLITTABLE routing verdict into the (default-on) gate, parks a PLAN_REVIEW approval carrying the drafted plan. The propose turn therefore never parks per-item work approvals; it hands back a pointer into Plan Review, and the dashboard's Request-work result links there. Steering directives a turn also raises stay on their own confirmation path (compensated if the plan draft fails).

API

PlanController (api/controllers/plans.py, path /plans) owns the plan-native capabilities the approval flow lacks. Whole-plan approve/reject stay atomic on the canonical /approvals/{id} path; because a plan review is decision-gathering with its own surface, it is excluded from the generic Approvals inbox (a source filter on GET /approvals) and gains its own red nav badge, and the operator approves or rejects it inline on the Plan Review page (the toolbar resolves the plan's parked approval from its plan_id metadata and drives the same /approvals path, so approval stays atomic).

Method Path Purpose
GET /plans List plans (cursor pagination; status / project / objective_id filters)
GET /plans/{id} Fetch a plan
PATCH /plans/{id} Rework items (new revision, back to PENDING_REVIEW)
POST /plans/{id}/request-changes Send back to DRAFT with a note
GET /plans/{id}/comments List a plan's comments oldest-first (optional item_id)
POST /plans/{id}/comments/items/{item_id} Post a comment on an item (optional reply_to_id); a responsible role may answer inline

PlanService (api/services/plan_service.py) owns the lifecycle transitions with uniform API_PLAN_* audit logging, the terminal-status guard, version-conflict translation, and the sync_status() used by the approval-resume path so the decision transition gets the same audit coverage as an operator edit. On a rework it snapshots the pre-edit version into version_history (bounded), so a reviewer can diff how a revision addressed the panel's concerns. Edits and decisions publish plan.updated / plan.changes_requested events, and a posted comment publishes plan.comment_added, all on the plans WebSocket channel. The event is a refresh signal (its payload stays the minimal locator); a subscriber reloads the item's thread, so an inline agent reply (broadcast the same way when it lands) surfaces without a new channel or payload shape. The comment endpoints live on PlanCommentController (api/controllers/plan_comments.py); a human comment's author is taken from the authenticated user, never the request body, and an agent reply is attributed to the responding role.

Dispatch on Approval

Approve/reject route through the existing idempotent /approvals/{id} path into try_plan_review_resume (api/controllers/_plan_review_resume.py), keyed off the ApprovalSource.PLAN_REVIEW discriminator:

  • The decision is reflected onto the durable plan first (APPROVED / REJECTED).
  • On approve, the durable plan is loaded and rebuilt via decomposition_from_plan and dispatched through coordinate(precomputed_plan=...). A dispatch failure (missing coordinator, missing task, missing plan, or a coordinator error) marks the parent task FAILED so the stuck plan surfaces on the board and stays re-runnable; the plan stays APPROVED because the decision stands.
  • On reject, the parent task is cancelled and nothing builds.
  • The gate persists the plan before parking the approval; if the approval write fails, the filled plan is marked FAILED (carrying the reason) rather than deleted, so the failure stays visible in Plan Review instead of vanishing.

Configuration

The subsystem is gated and sized by five coordination.* settings (settings/definitions/coordination.py), all applied on the next runtime-services rebuild:

Setting Default Purpose
coordination.plan_approval_required true Master gate: when off, splittable team work dispatches straight to the coordinator and no plan is parked. On by default so every greenlit initiative parks a plan for holistic review. Everything below is inert until this is on.
coordination.plan_review_panel_enabled true Whether the stakeholder panel runs before the human sees the plan. Defaults on, but only takes effect once approval is gated and a provider is wired; otherwise the plan is parked with review = None.
coordination.plan_review_panel_size 4 (max 8) Maximum panellists seated (the relevant leads sized to the plan, not everyone).
coordination.plan_review_panel_max_turns 6 Hard turn cap per panellist session before it must submit a verdict.
coordination.plan_review_panel_cost_ceiling 1.0 Per-reviewer spend ceiling (base currency); the session halts once accumulated cost reaches it.

Workspace

The Plan Review workspace (web/src/pages/PlansPage.tsx, PlanDetailPage.tsx, and web/src/pages/plans/) is a pure API consumer: it hydrates from GET /plans, walks every cursor page so the review inbox can filter and sort across the whole set, and writes every change through the API. The detail page reworks items (title, description, owner, complexity, stakes) or sends the plan back for changes, and surfaces a disconnected-updates banner when the WebSocket drops. Beyond the item list, it renders review panels derived from the plan (no extra persisted state):

  • Decomposition failure (PlanFailureBanner): shown only for a FAILED plan, surfacing its failure_reason so the operator can see why the run failed and start a fresh one.
  • Needs your input (PlanOpenQuestionsPanel): the planner's open questions and assumptions to answer or correct before approving.
  • Cost forecast (PlanForecastPanel): the plan's forecast_id hydrated to show the estimate with its band, decision state, and any hard-ceiling halt.
  • Staffing (PlanStaffingPanel): per-owner item load derived from item owners, flagging bottlenecks and unassigned work.
  • Success-criteria coverage (PlanCoveragePanel): each objective criterion and the items that advance it, flagging any criterion nothing covers.
  • Stakeholder review (PlanReviewPanel): the panel's consolidated verdict and each lead's findings.
  • Changes since last revision (PlanVersionDiff): items added / removed / modified versus the last version snapshot.
  • Timeline (PlanTimeline): execution waves derived from the dependency DAG.
  • Decision options and discussion (PlanItemCard): each decision item's options (pick recorded via PATCH /plans/{id}) and a per-item comment thread that updates live over the plans channel.