- {IN_PROGRESS_STAGE_LABELS[stage]}{" "}
+ {IN_PROGRESS_PIPELINE_LABELS[p]}{" "}
- ({byStage[stage].length})
+ ({byPipeline[p].length})
- {byStage[stage].map(({ project, item }) => (
+ {byPipeline[p].map(({ project, item }) => (
Option<&'static str> {
- match s {
- Stage::Upcoming | Stage::Backlog => Some("Backlog"),
- Stage::Coding { .. }
- | Stage::Blocked { .. }
- | Stage::Archived {
- reason: ArchiveReason::Blocked { .. },
- ..
- } => Some("In Progress"),
- Stage::Qa | Stage::ReviewHold { .. } => Some("QA"),
- Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => {
- Some("Merge")
- }
- Stage::Done { .. } => Some("Done"),
- Stage::Frozen { resume_to } => display_section(resume_to),
- Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
- Some("Closed")
- }
- Stage::Archived { .. } => None, // Completed/MergeFailed/ReviewHeld stay hidden
+ // Archived items with non-Blocked reasons are hidden from chat output.
+ if matches!(s, Stage::Archived { reason, .. } if !matches!(reason, ArchiveReason::Blocked { .. }))
+ {
+ return None;
}
+ Some(match s.pipeline() {
+ Pipeline::Backlog => "Backlog",
+ Pipeline::Coding => "In Progress",
+ Pipeline::Qa => "QA",
+ Pipeline::Merge => "Merge",
+ Pipeline::Done => "Done",
+ Pipeline::Closed => "Closed",
+ Pipeline::Archived => return None,
+ })
}
/// Check which dependency numbers from `item.depends_on` are unmet.
@@ -114,10 +107,10 @@ pub(crate) fn build_status_from_items(
let config = ProjectConfig::load(project_root).ok();
- // Pre-fetch working tree state for all Coding-stage items whose worktrees exist.
+ // Pre-fetch working tree state for all Coding-column items whose worktrees exist.
let dirty_files_by_story: HashMap = items
.iter()
- .filter(|i| matches!(i.stage, Stage::Coding { .. }))
+ .filter(|i| i.stage.pipeline() == Pipeline::Coding && i.stage.status() == Status::Active)
.filter_map(|i| {
let wt = crate::worktree::worktree_path(project_root, &i.story_id.0);
if wt.is_dir() {
@@ -137,10 +130,13 @@ pub(crate) fn build_status_from_items(
.into_iter()
.collect();
// Merge-failure detail now lives on the typed MergeJob CRDT entry
- // (story 929 — CRDT is the sole source of metadata).
+ // (story 929 — CRDT is the sole source of metadata). Only items in the
+ // Merge column with an Active status (i.e. `Stage::Merge { .. }`) need a
+ // pre-fetched failure snippet; MergeFailure(Final) items render their
+ // own snippet from the typed kind.
let merge_failures: HashMap = items
.iter()
- .filter(|i| matches!(i.stage, Stage::Merge { .. }))
+ .filter(|i| i.stage.pipeline() == Pipeline::Merge && i.stage.status() == Status::Active)
.filter_map(|i| {
let job = crate::crdt_state::read_merge_job(&i.story_id.0)?;
let err = job.error?;
@@ -260,8 +256,10 @@ fn render_item_line(
} else {
Some(item.name.as_str())
};
- // Use the typed CRDT stage as the sole source of truth (story 945).
- let frozen = matches!(item.stage, Stage::Frozen { .. });
+ // Use the new Pipeline + Status helpers (story 1085).
+ let pipeline = item.stage.pipeline();
+ let status = item.stage.status();
+ let frozen = status == Status::Frozen;
let base_label = super::story_short_label(story_id, name_opt);
let display = if frozen {
format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
@@ -282,41 +280,52 @@ fn render_item_line(
format!(" *(waiting on: {})*", nums.join(", "))
};
- // Closed-stage items (abandoned / superseded / rejected) each get a
+ // Closed-pipeline items (abandoned / superseded / rejected) each get a
// distinct indicator and optionally display their metadata.
- match &item.stage {
- Stage::Abandoned { .. } => {
+ match status {
+ Status::Abandoned => {
return format!(" \u{1F5D1}\u{FE0F} {display}{cost_suffix}\n"); // 🗑️
}
- Stage::Superseded { superseded_by, .. } => {
+ Status::Superseded => {
+ let superseded_by = match &item.stage {
+ Stage::Superseded { superseded_by, .. } => superseded_by.0.as_str(),
+ _ => "",
+ };
return format!(
- " \u{1F500} {display}{cost_suffix} — superseded by {}\n", // 🔀
- superseded_by.0
+ " \u{1F500} {display}{cost_suffix} — superseded by {superseded_by}\n", // 🔀
);
}
- Stage::Rejected { reason, .. } => {
+ Status::Rejected => {
+ let reason = match &item.stage {
+ Stage::Rejected { reason, .. } => reason.as_str(),
+ _ => "",
+ };
let snippet = first_non_empty_snippet(reason, 120);
return format!(" \u{1F6AB} {display}{cost_suffix} — {snippet}\n"); // 🚫
}
_ => {}
}
- // Merge-stage items get dedicated breakdown indicators instead of the
+ // Merge-column items get dedicated breakdown indicators instead of the
// generic traffic-light dot. MergeFailure / MergeFailureFinal items
- // now also appear in the Merge section (in-place) so they are handled
- // here alongside normal Merge items.
- if matches!(
- item.stage,
- Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. }
- ) {
- match &item.stage {
+ // appear in the Merge column (in-place) and are handled by the same arm.
+ if pipeline == Pipeline::Merge {
+ match status {
// MergeFailureFinal: mergemaster already tried and gave up — always ⛔.
- Stage::MergeFailureFinal { kind } => {
+ Status::MergeFailureFinal => {
+ let kind = match &item.stage {
+ Stage::MergeFailureFinal { kind } => kind,
+ _ => unreachable!(),
+ };
let snippet = first_non_empty_snippet(&kind.display_reason(), 120);
return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n");
}
// MergeFailure: a recovery agent may be running or queued.
- Stage::MergeFailure { kind, .. } => {
+ Status::MergeFailure => {
+ let kind = match &item.stage {
+ Stage::MergeFailure { kind, .. } => kind,
+ _ => unreachable!(),
+ };
return match agent.map(|a| &a.status) {
Some(AgentStatus::Running) => format!(
" \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n"
@@ -353,16 +362,7 @@ fn render_item_line(
}
}
- let blocked = matches!(
- item.stage,
- Stage::Blocked { .. }
- | Stage::MergeFailure { .. }
- | Stage::MergeFailureFinal { .. }
- | Stage::Archived {
- reason: ArchiveReason::Blocked { .. },
- ..
- }
- );
+ let blocked = status == Status::Blocked;
// Blocked items with a recovery agent get differentiated indicators.
if blocked {
return match agent.map(|a| &a.status) {
diff --git a/server/src/http/mcp/story_tools/story/query.rs b/server/src/http/mcp/story_tools/story/query.rs
index a70ec725..800201fe 100644
--- a/server/src/http/mcp/story_tools/story/query.rs
+++ b/server/src/http/mcp/story_tools/story/query.rs
@@ -51,6 +51,8 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result Result = state
.archived
.iter()
- .map(|s| json!({ "story_id": s.story_id, "name": slim_name(&s.name), "stage": "archived" }))
+ .map(|s| {
+ json!({
+ "story_id": s.story_id,
+ "name": slim_name(&s.name),
+ "stage": "archived",
+ "pipeline": s.pipeline.as_str(),
+ "status": s.status.as_str(),
+ })
+ })
.collect();
serde_json::to_string_pretty(&json!({
diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs
index eba5c2b2..c65891a9 100644
--- a/server/src/http/workflow/pipeline.rs
+++ b/server/src/http/workflow/pipeline.rs
@@ -24,6 +24,10 @@ pub struct UpcomingStory {
pub merge_failure: Option,
/// Active agent working on this item, if any.
pub agent: Option,
+ /// Display column (story 1085) — derived from `Stage::pipeline()`.
+ pub pipeline: crate::pipeline_state::Pipeline,
+ /// Display badge/indicator (story 1085) — derived from `Stage::status()`.
+ pub status: crate::pipeline_state::Status,
/// True when the item is held in QA for human review.
#[serde(skip_serializing_if = "Option::is_none")]
pub review_hold: Option,
@@ -142,6 +146,8 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result {
error: None,
merge_failure,
agent,
+ pipeline: item.stage.pipeline(),
+ status: item.stage.status(),
review_hold,
qa,
retry_count: if item.retry_count() > 0 {
@@ -278,6 +284,8 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result, St
error: None,
merge_failure: None,
agent: None,
+ pipeline: item.stage.pipeline(),
+ status: item.stage.status(),
review_hold: None,
qa: None,
retry_count: if item_retry_count > 0 {
diff --git a/server/src/pipeline_state/mod.rs b/server/src/pipeline_state/mod.rs
index 8c1f03c6..9d7b4ac1 100644
--- a/server/src/pipeline_state/mod.rs
+++ b/server/src/pipeline_state/mod.rs
@@ -41,8 +41,8 @@ mod tests;
#[allow(unused_imports)]
pub use types::{
AgentClaim, AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind,
- NodePubkey, PipelineItem, PlanState, Stage, StoryId, TransitionError, stage_dir_name,
- stage_label,
+ NodePubkey, Pipeline, PipelineItem, PlanState, Stage, Status, StoryId, TransitionError,
+ stage_dir_name, stage_label,
};
#[allow(unused_imports)]
diff --git a/server/src/pipeline_state/types.rs b/server/src/pipeline_state/types.rs
index cf1354dd..2c3fdf7a 100644
--- a/server/src/pipeline_state/types.rs
+++ b/server/src/pipeline_state/types.rs
@@ -429,6 +429,144 @@ impl Stage {
}
}
+// ── Display split (story 1085): Pipeline column + Status badge ─────────────
+
+/// Column placement for a work item in the UI/chat status display.
+///
+/// Derived from [`Stage`] via [`Stage::pipeline`]. Display callers route items
+/// to columns by this enum instead of pattern-matching `Stage` variants, so
+/// new badges (e.g. `Frozen`, `Blocked`) do not produce new columns.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Pipeline {
+ /// Items in `Upcoming` or `Backlog` stages.
+ Backlog,
+ /// Items being coded (or blocked while in the coding lane).
+ Coding,
+ /// Items in QA or `ReviewHold`.
+ Qa,
+ /// Items in `Merge`, `MergeFailure`, or `MergeFailureFinal`.
+ Merge,
+ /// Items in `Done`.
+ Done,
+ /// Abandoned, superseded, or rejected items.
+ Closed,
+ /// Items swept into `Archived`.
+ Archived,
+}
+
+impl Pipeline {
+ /// Stable wire-format identifier (kebab-case).
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Pipeline::Backlog => "backlog",
+ Pipeline::Coding => "coding",
+ Pipeline::Qa => "qa",
+ Pipeline::Merge => "merge",
+ Pipeline::Done => "done",
+ Pipeline::Closed => "closed",
+ Pipeline::Archived => "archived",
+ }
+ }
+}
+
+/// Badge/indicator for a work item, orthogonal to its [`Pipeline`] column.
+///
+/// Derived from [`Stage`] via [`Stage::status`]. A `Frozen` story stays in
+/// its underlying `Pipeline` column (e.g. `Coding`) and is decorated with
+/// `Status::Frozen` for the display. `Status::Done` is reserved for items in
+/// the `Done` column and is never produced for items still in flight, so a
+/// done item never carries a `MergeFailure*` badge (story 1052).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case", tag = "kind")]
+pub enum Status {
+ /// No special badge — normal in-progress item.
+ Active,
+ /// Item is paused (`Stage::Frozen`).
+ Frozen,
+ /// Item is held for human review (`Stage::ReviewHold`).
+ ReviewHold,
+ /// Item is blocked (`Stage::Blocked` or legacy `Archived(Blocked)`).
+ Blocked,
+ /// Merge failed; mergemaster may still be recovering.
+ MergeFailure,
+ /// Merge failed beyond automatic recovery.
+ MergeFailureFinal,
+ /// User abandoned the item.
+ Abandoned,
+ /// Item was superseded by another work item.
+ Superseded,
+ /// Item was permanently rejected.
+ Rejected,
+ /// Item completed successfully.
+ Done,
+}
+
+impl Status {
+ /// Stable wire-format identifier (kebab-case).
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Status::Active => "active",
+ Status::Frozen => "frozen",
+ Status::ReviewHold => "review-hold",
+ Status::Blocked => "blocked",
+ Status::MergeFailure => "merge-failure",
+ Status::MergeFailureFinal => "merge-failure-final",
+ Status::Abandoned => "abandoned",
+ Status::Superseded => "superseded",
+ Status::Rejected => "rejected",
+ Status::Done => "done",
+ }
+ }
+}
+
+impl Stage {
+ /// Display column for this stage. `Frozen { resume_to }` recurses so a
+ /// paused story keeps its underlying column.
+ pub fn pipeline(&self) -> Pipeline {
+ match self {
+ Stage::Upcoming | Stage::Backlog => Pipeline::Backlog,
+ Stage::Coding { .. } | Stage::Blocked { .. } => Pipeline::Coding,
+ Stage::Qa | Stage::ReviewHold { .. } => Pipeline::Qa,
+ Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => {
+ Pipeline::Merge
+ }
+ Stage::Frozen { resume_to } => resume_to.pipeline(),
+ Stage::Done { .. } => Pipeline::Done,
+ Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
+ Pipeline::Closed
+ }
+ Stage::Archived {
+ reason: ArchiveReason::Blocked { .. },
+ ..
+ } => Pipeline::Coding,
+ Stage::Archived { .. } => Pipeline::Archived,
+ }
+ }
+
+ /// Display badge for this stage. `Frozen { resume_to }` returns
+ /// `Status::Frozen` regardless of the inner stage; callers wanting the
+ /// underlying badge inspect `resume_to` directly.
+ pub fn status(&self) -> Status {
+ match self {
+ Stage::Frozen { .. } => Status::Frozen,
+ Stage::ReviewHold { .. } => Status::ReviewHold,
+ Stage::Blocked { .. }
+ | Stage::Archived {
+ reason: ArchiveReason::Blocked { .. },
+ ..
+ } => Status::Blocked,
+ Stage::MergeFailure { .. } => Status::MergeFailure,
+ Stage::MergeFailureFinal { .. } => Status::MergeFailureFinal,
+ Stage::Abandoned { .. } => Status::Abandoned,
+ Stage::Superseded { .. } => Status::Superseded,
+ Stage::Rejected { .. } => Status::Rejected,
+ Stage::Done { .. } => Status::Done,
+ _ => Status::Active,
+ }
+ }
+}
+
// ── Per-node execution state ────────────────────────────────────────────────
/// Per-node execution tracking, stored in the CRDT under each node's pubkey.
diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs
index 02b53c6a..6330435a 100644
--- a/server/src/service/ws/message/convert.rs
+++ b/server/src/service/ws/message/convert.rs
@@ -212,6 +212,8 @@ mod tests {
error: None,
merge_failure: None,
agent: None,
+ pipeline: crate::pipeline_state::Pipeline::Backlog,
+ status: crate::pipeline_state::Status::Active,
review_hold: None,
qa: None,
retry_count: None,
@@ -226,6 +228,8 @@ mod tests {
error: None,
merge_failure: None,
agent: None,
+ pipeline: crate::pipeline_state::Pipeline::Coding,
+ status: crate::pipeline_state::Status::Active,
review_hold: None,
qa: None,
retry_count: None,
@@ -242,6 +246,8 @@ mod tests {
error: None,
merge_failure: None,
agent: None,
+ pipeline: crate::pipeline_state::Pipeline::Done,
+ status: crate::pipeline_state::Status::Done,
review_hold: None,
qa: None,
retry_count: None,
@@ -303,6 +309,8 @@ mod tests {
model: Some(crate::agents::AgentModel::Sonnet),
status: crate::agents::AgentStatus::Running,
}),
+ pipeline: crate::pipeline_state::Pipeline::Coding,
+ status: crate::pipeline_state::Status::Active,
review_hold: None,
qa: None,
retry_count: None,
diff --git a/server/src/service/ws/message/response.rs b/server/src/service/ws/message/response.rs
index 751df126..1003d687 100644
--- a/server/src/service/ws/message/response.rs
+++ b/server/src/service/ws/message/response.rs
@@ -205,6 +205,8 @@ mod tests {
error: None,
merge_failure: None,
agent: None,
+ pipeline: crate::pipeline_state::Pipeline::Backlog,
+ status: crate::pipeline_state::Status::Active,
review_hold: None,
qa: None,
retry_count: None,