feat: replace intake workflow state with planning

This commit is contained in:
2026-06-08 18:00:23 +09:00
parent cb234b86ad
commit ada6db99d8
11 changed files with 292 additions and 272 deletions
+89 -34
View File
@@ -176,7 +176,7 @@ impl From<TicketStatus> for ExtensibleTicketStatus {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TicketWorkflowState {
Intake,
Planning,
Ready,
Queued,
InProgress,
@@ -186,7 +186,7 @@ pub enum TicketWorkflowState {
impl TicketWorkflowState {
pub fn as_str(self) -> &'static str {
match self {
Self::Intake => "intake",
Self::Planning => "planning",
Self::Ready => "ready",
Self::Queued => "queued",
Self::InProgress => "inprogress",
@@ -196,7 +196,7 @@ impl TicketWorkflowState {
pub fn parse(value: &str) -> Option<Self> {
match value {
"intake" => Some(Self::Intake),
"planning" | "intake" => Some(Self::Planning),
"ready" => Some(Self::Ready),
"queued" => Some(Self::Queued),
"inprogress" => Some(Self::InProgress),
@@ -208,12 +208,12 @@ impl TicketWorkflowState {
pub fn default_for_status(status: &ExtensibleTicketStatus) -> Self {
match status {
ExtensibleTicketStatus::Closed => Self::Done,
_ => Self::Intake,
_ => Self::Planning,
}
}
pub fn is_intake_ready_transition(from: Self, to: Self) -> bool {
from == Self::Intake && to == Self::Ready
pub fn is_planning_ready_transition(from: Self, to: Self) -> bool {
from == Self::Planning && to == Self::Ready
}
pub fn is_queue_transition(from: Self, to: Self) -> bool {
@@ -223,7 +223,10 @@ impl TicketWorkflowState {
pub fn is_role_transition(from: Self, to: Self) -> bool {
matches!(
(from, to),
(Self::Queued, Self::InProgress) | (Self::InProgress, Self::Done)
(Self::Queued, Self::InProgress)
| (Self::InProgress, Self::Done)
| (Self::Ready, Self::Planning)
| (Self::Queued, Self::Planning)
)
}
}
@@ -495,6 +498,7 @@ pub struct NewTicket {
pub assignee: Option<String>,
pub legacy_ticket: Option<String>,
pub readiness: Option<String>,
/// Legacy metadata accepted for existing records only; not a workflow stop gate.
pub needs_preflight: Option<bool>,
pub risk_flags: Vec<String>,
pub action_required: Option<String>,
@@ -566,6 +570,7 @@ pub struct TicketMeta {
pub assignee: Option<String>,
pub legacy_ticket: Option<String>,
pub readiness: Option<String>,
/// Legacy metadata accepted for existing records only; not a workflow stop gate.
pub needs_preflight: Option<bool>,
pub risk_flags: Vec<String>,
pub action_required: Option<String>,
@@ -587,6 +592,7 @@ pub struct TicketSummary {
pub priority: String,
pub labels: Vec<String>,
pub readiness: Option<String>,
/// Legacy metadata accepted for existing records only; not a workflow stop gate.
pub needs_preflight: Option<bool>,
pub action_required: Option<String>,
pub workflow_state: TicketWorkflowState,
@@ -737,9 +743,9 @@ impl LocalTicketBackend {
pub fn default_intake_ready_state_change_body(&self, from: &str) -> String {
if is_japanese_record_language(self.record_language()) {
format!("Ticket intake が完了しました。workflow_state {from} -> ready。\n")
format!("Ticket planning が完了しました。workflow_state {from} -> ready。\n")
} else {
format!("Ticket intake complete; workflow_state {from} -> ready.\n")
format!("Ticket planning complete; workflow_state {from} -> ready.\n")
}
}
@@ -1130,7 +1136,7 @@ impl TicketBackend for LocalTicketBackend {
format_yaml_string_scalar(
input
.workflow_state
.unwrap_or(TicketWorkflowState::Intake)
.unwrap_or(TicketWorkflowState::Planning)
.as_str(),
),
));
@@ -1279,7 +1285,7 @@ impl TicketBackend for LocalTicketBackend {
})?;
if !TicketWorkflowState::is_role_transition(from, to) {
return Err(TicketError::Conflict(format!(
"workflow_state transition {} -> {} is not allowed through set_workflow_state; use dedicated intake-ready or queue APIs for gated transitions",
"workflow_state transition {} -> {} is not allowed through set_workflow_state; use dedicated planning-ready or queue APIs for gated transitions",
from.as_str(),
to.as_str()
)));
@@ -1307,9 +1313,9 @@ impl TicketBackend for LocalTicketBackend {
change.to
))
})?;
if !TicketWorkflowState::is_intake_ready_transition(from, to) {
if !TicketWorkflowState::is_planning_ready_transition(from, to) {
return Err(TicketError::Conflict(format!(
"mark_intake_ready only allows workflow_state intake -> ready, got {} -> {}",
"mark_intake_ready only allows workflow_state planning -> ready, got {} -> {}",
from.as_str(),
to.as_str()
)));
@@ -1747,7 +1753,7 @@ fn parse_ticket_frontmatter(content: &str) -> std::result::Result<TicketItemFron
let workflow_state_value = yaml_string(&mapping, "workflow_state")?;
let workflow_state = match workflow_state_value.as_deref() {
Some(value) => Some(TicketWorkflowState::parse(value).ok_or_else(|| {
format!("invalid workflow_state '{value}': expected intake, ready, queued, inprogress, or done")
format!("invalid workflow_state '{value}': expected planning, ready, queued, inprogress, done, or legacy intake")
})?),
None => None,
};
@@ -2470,6 +2476,55 @@ mod tests {
LocalTicketBackend::new(dir.path().join("tickets"))
}
#[test]
fn workflow_state_parses_legacy_intake_as_planning_and_emits_planning() {
assert_eq!(
TicketWorkflowState::parse("planning"),
Some(TicketWorkflowState::Planning)
);
assert_eq!(
TicketWorkflowState::parse("intake"),
Some(TicketWorkflowState::Planning)
);
assert_eq!(TicketWorkflowState::Planning.as_str(), "planning");
assert_eq!(
TicketWorkflowState::default_for_status(&ExtensibleTicketStatus::Open),
TicketWorkflowState::Planning
);
}
#[test]
fn workflow_state_transition_graph_allows_planning_lane_and_returns() {
assert!(TicketWorkflowState::is_planning_ready_transition(
TicketWorkflowState::Planning,
TicketWorkflowState::Ready
));
assert!(TicketWorkflowState::is_queue_transition(
TicketWorkflowState::Ready,
TicketWorkflowState::Queued
));
assert!(TicketWorkflowState::is_role_transition(
TicketWorkflowState::Queued,
TicketWorkflowState::InProgress
));
assert!(TicketWorkflowState::is_role_transition(
TicketWorkflowState::InProgress,
TicketWorkflowState::Done
));
assert!(TicketWorkflowState::is_role_transition(
TicketWorkflowState::Ready,
TicketWorkflowState::Planning
));
assert!(TicketWorkflowState::is_role_transition(
TicketWorkflowState::Queued,
TicketWorkflowState::Planning
));
assert!(!TicketWorkflowState::is_role_transition(
TicketWorkflowState::Planning,
TicketWorkflowState::Queued
));
}
#[test]
fn parses_item_frontmatter_and_optional_fields() {
let item = r#"---
@@ -2537,7 +2592,7 @@ workflow_state: intake
assert_eq!(meta.action_required.as_deref(), Some("null"));
assert_eq!(meta.readiness.as_deref(), Some("~"));
assert_eq!(meta.needs_preflight, Some(false));
assert_eq!(meta.workflow_state, TicketWorkflowState::Intake);
assert_eq!(meta.workflow_state, TicketWorkflowState::Planning);
assert!(meta.workflow_state_explicit);
}
@@ -2581,7 +2636,7 @@ workflow_state: intake
assert!(dir.join("artifacts/.gitkeep").exists());
assert_eq!(ticket.slug, "example-ticket");
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
assert!(record.meta.workflow_state_explicit);
let report = backend.doctor().unwrap();
assert!(report.is_ok(), "{:?}", report.diagnostics);
@@ -2754,10 +2809,10 @@ workflow_state: intake
.create(NewTicket::new("Typed Thread Ticket"))
.unwrap();
let mut change = TicketStateChange::new(
"preflight",
"requirements-sync",
"implementation-ready",
"preflight approved",
"Preflight finished; implementation can begin.",
"requirements approved",
"Planning sync finished; implementation can begin.",
);
change.author = Some("orchestrator".into());
backend
@@ -2775,13 +2830,13 @@ workflow_state: intake
.iter()
.find(|event| event.kind == TicketEventKind::StateChanged)
.unwrap();
assert_eq!(state_event.from.as_deref(), Some("preflight"));
assert_eq!(state_event.from.as_deref(), Some("requirements-sync"));
assert_eq!(state_event.to.as_deref(), Some("implementation-ready"));
assert_eq!(state_event.reason.as_deref(), Some("preflight approved"));
assert_eq!(state_event.reason.as_deref(), Some("requirements approved"));
assert_eq!(state_event.author.as_deref(), Some("orchestrator"));
assert_eq!(
state_event.attributes.get("reason").map(String::as_str),
Some("preflight approved")
Some("requirements approved")
);
assert!(
record
@@ -2798,7 +2853,7 @@ workflow_state: intake
)
.unwrap();
assert!(thread.contains("event: state_changed"));
assert!(thread.contains("reason: \"preflight approved\""));
assert!(thread.contains("reason: \"requirements approved\""));
assert!(thread.contains("event: intake_summary"));
let report = backend.doctor().unwrap();
assert!(report.is_ok(), "{:?}", report.diagnostics);
@@ -2817,11 +2872,11 @@ workflow_state: intake
.join(&ticket.id)
.join("item.md");
backend
.set_frontmatter_fields(&item, &[("readiness", "preflight")])
.set_frontmatter_fields(&item, &[("readiness", "requirements-sync")])
.unwrap();
let mut change = TicketStateChange::new(
"preflight",
"requirements-sync",
"implementation-ready",
"requirements accepted",
"Implementation is authorized.",
@@ -2843,7 +2898,7 @@ workflow_state: intake
.unwrap();
assert_eq!(event.state_field.as_deref(), Some("readiness"));
let stale = TicketStateChange::new(
"preflight",
"requirements-sync",
"done",
"stale update",
"This must be rejected.",
@@ -2861,7 +2916,7 @@ workflow_state: intake
let missing_meta = ticket_meta(
parse_ticket_frontmatter("status: open").expect("missing workflow state parses"),
);
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Intake);
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Planning);
assert!(!missing_meta.workflow_state_explicit);
let closed_meta =
@@ -2896,14 +2951,14 @@ workflow_state: intake
fn workflow_queue_rejects_non_ready_ticket_without_mutation() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Intake Ticket")).unwrap();
let ticket = backend.create(NewTicket::new("Planning Ticket")).unwrap();
assert!(matches!(
backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"),
Err(TicketError::Conflict(_))
));
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
assert!(record.meta.queued_by.is_none());
assert!(
!record
@@ -2921,7 +2976,7 @@ workflow_state: intake
.create(NewTicket::new("Generic Workflow Bypass"))
.unwrap();
let change = TicketStateChange::new(
"intake",
"planning",
"done",
"bypass",
"Generic state field API must not mutate workflow_state.",
@@ -2936,18 +2991,18 @@ workflow_state: intake
Err(TicketError::Conflict(_))
));
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
}
#[test]
fn mark_intake_ready_records_summary_and_state_change() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Intake Ready")).unwrap();
let ticket = backend.create(NewTicket::new("Planning Ready")).unwrap();
let mut summary = TicketIntakeSummary::new("Concise accepted requirements.");
summary.author = Some("intake".to_string());
let mut change =
TicketStateChange::new("intake", "ready", "accepted", "Ticket is ready to queue.");
TicketStateChange::new("planning", "ready", "accepted", "Ticket is ready to queue.");
change.author = Some("intake".to_string());
backend
@@ -2964,7 +3019,7 @@ workflow_state: intake
assert!(record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
&& event.from.as_deref() == Some("intake")
&& event.from.as_deref() == Some("planning")
&& event.to.as_deref() == Some("ready")
}));
}
+79 -15
View File
@@ -68,14 +68,14 @@ const COMMENT_DESCRIPTION: &str = "Append a typed Ticket thread event. `role` mu
configured Ticket backend root.";
const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket intake as ready through the typed \
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `workflow_state`, and transitions workflow_state to `ready`.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `workflow_state` through the typed \
Ticket backend with a bounded `state_changed` event. This does not move local open/pending/closed \
status; use `TicketStatus` or `TicketClose` for local status changes. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
transition is accepted and recorded.";
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
by `yoi ticket doctor`.";
@@ -116,16 +116,13 @@ struct TicketCreateParams {
/// Optional readiness frontmatter value.
#[serde(default)]
readiness: Option<String>,
/// Optional preflight flag frontmatter value.
#[serde(default)]
needs_preflight: Option<bool>,
/// Optional risk flag frontmatter values.
#[serde(default)]
risk_flags: Vec<String>,
/// Optional action-required frontmatter value.
#[serde(default)]
action_required: Option<String>,
/// Optional workflow_state frontmatter value. Defaults to `intake`.
/// Optional workflow_state frontmatter value. Defaults to `planning`.
#[serde(default)]
workflow_state: Option<TicketWorkflowStateParam>,
/// Optional attention_required overlay frontmatter value.
@@ -142,7 +139,8 @@ struct TicketCreateParams {
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketWorkflowStateParam {
Intake,
#[serde(alias = "intake")]
Planning,
Ready,
Queued,
Inprogress,
@@ -152,7 +150,7 @@ enum TicketWorkflowStateParam {
impl TicketWorkflowStateParam {
fn into_state(self) -> TicketWorkflowState {
match self {
Self::Intake => TicketWorkflowState::Intake,
Self::Planning => TicketWorkflowState::Planning,
Self::Ready => TicketWorkflowState::Ready,
Self::Queued => TicketWorkflowState::Queued,
Self::Inprogress => TicketWorkflowState::InProgress,
@@ -277,7 +275,7 @@ struct TicketIntakeReadyParams {
/// Optional author for both intake_summary and state_changed events.
#[serde(default)]
author: Option<String>,
/// Reason attached to the state_changed event. Defaults to `intake_ready`.
/// Reason attached to the state_changed event. Defaults to `planning_ready`.
#[serde(default)]
reason: Option<String>,
/// Optional state_changed body. If omitted, a concise default is used.
@@ -413,7 +411,6 @@ impl Tool for TicketCreateTool {
input.assignee = params.assignee;
input.legacy_ticket = params.legacy_ticket;
input.readiness = params.readiness;
input.needs_preflight = params.needs_preflight;
input.risk_flags = params.risk_flags;
input.action_required = params.action_required;
input.workflow_state = params
@@ -580,8 +577,10 @@ impl Tool for TicketReviewTool {
impl Tool for TicketIntakeReadyTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
let from = TicketWorkflowState::Intake;
let reason = params.reason.unwrap_or_else(|| "intake_ready".to_string());
let from = TicketWorkflowState::Planning;
let reason = params
.reason
.unwrap_or_else(|| "planning_ready".to_string());
let body = params.state_change_body.unwrap_or_else(|| {
self.backend
.default_intake_ready_state_change_body(from.as_str())
@@ -1229,7 +1228,7 @@ mod tests {
assert_eq!(
transitions,
vec![
(Some("intake"), Some("ready")),
(Some("planning"), Some("ready")),
(Some("ready"), Some("queued")),
(Some("queued"), Some("inprogress")),
(Some("inprogress"), Some("done"))
@@ -1237,6 +1236,71 @@ mod tests {
);
}
#[tokio::test]
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
let mut ready_input = NewTicket::new("Ready Needs Planning");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
workflow
.execute(
&json!({
"ticket": ready.id,
"from": "ready",
"to": "planning",
"reason": "missing_acceptance_decision",
"body": "Missing decision: clarify acceptance criteria before queueing.\n",
"author": "orchestrator"
})
.to_string(),
)
.await
.unwrap();
let ready_record = backend.show(TicketIdOrSlug::Query(ready.slug)).unwrap();
assert_eq!(
ready_record.meta.workflow_state,
TicketWorkflowState::Planning
);
assert!(ready_record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.from.as_deref() == Some("ready")
&& event.to.as_deref() == Some("planning")
&& event.reason.as_deref() == Some("missing_acceptance_decision")
}));
let mut queued_input = NewTicket::new("Queued Needs Planning");
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
let queued = backend.create(queued_input).unwrap();
workflow
.execute(
&json!({
"ticket": queued.id,
"from": "queued",
"to": "planning",
"reason": "missing_authority_decision",
"body": "Missing decision: define authority boundary before implementation side effects.\n",
"author": "orchestrator"
})
.to_string(),
)
.await
.unwrap();
let queued_record = backend.show(TicketIdOrSlug::Query(queued.slug)).unwrap();
assert_eq!(
queued_record.meta.workflow_state,
TicketWorkflowState::Planning
);
assert!(queued_record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.from.as_deref() == Some("queued")
&& event.to.as_deref() == Some("planning")
&& event.reason.as_deref() == Some("missing_authority_decision")
}));
}
#[tokio::test]
async fn ticket_workflow_tool_rejects_stale_transition_without_status_move() {
let temp = TempDir::new().unwrap();
@@ -1266,7 +1330,7 @@ mod tests {
.contains("workflow_state changed concurrently")
);
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!(!record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
@@ -1336,7 +1400,7 @@ mod tests {
}
#[tokio::test]
async fn ticket_intake_ready_tool_rejects_non_intake_ticket() {
async fn ticket_intake_ready_tool_rejects_non_planning_ticket() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Already Ready");