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 aFAILEDplan failed,Noneotherwise),forecast_id,review(the consolidated stakeholder-panel review, orNone),open_questionsandassumptions(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 thePLANNING/FAILEDshells (which may carry no items), duplicate item ids, an unresolvable dependency, or a dependency cycle (topological sort); a second validator tiesfailure_reasonto theFAILEDstatus (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(WORKorDECISION),optionsandchosen_option_id(decision items), andsatisfies(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 durablePlanfrom an executedDecompositionResult(subtasks become plan items).decomposition_from_plan()rebuilds a dispatchableDecompositionResultfrom 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_planand dispatched throughcoordinate(precomputed_plan=...). A dispatch failure (missing coordinator, missing task, missing plan, or a coordinator error) marks the parent taskFAILEDso the stuck plan surfaces on the board and stays re-runnable; the plan staysAPPROVEDbecause 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 aFAILEDplan, surfacing itsfailure_reasonso 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'sforecast_idhydrated 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 viaPATCH /plans/{id}) and a per-item comment thread that updates live over theplanschannel.