merge: remove ticket attention fields

This commit is contained in:
Keisuke Hirata 2026-06-09 21:32:36 +09:00
commit 1e534b954b
No known key found for this signature in database
8 changed files with 79 additions and 243 deletions

View File

@ -41,7 +41,7 @@ Intake は以下を行う。
- 作成または refinement する Ticket が、実装・レビュー・検証・完了判断を単独で行える concrete work item であるか確認する。 - 作成または refinement する Ticket が、実装・レビュー・検証・完了判断を単独で行える concrete work item であるか確認する。
- 広い依頼を分割する場合は、進捗コンテナとしての umbrella Ticket ではなく、concrete Ticket / Objective context / split decision record に責務を分ける。 - 広い依頼を分割する場合は、進捗コンテナとしての umbrella Ticket ではなく、concrete Ticket / Objective context / split decision record に責務を分ける。
- Objective-to-Ticket links を提案する場合は canonical opaque Ticket ID だけを使い、dependency / blocking / ordering relation として扱わない。 - Objective-to-Ticket links を提案する場合は canonical opaque Ticket ID だけを使い、dependency / blocking / ordering relation として扱わない。
- Ticket の title / body/request snapshot / acceptance criteria / priority / readiness / action_required / attention_required を、現在の要件として意味がある範囲で提案する。 - Ticket の title / body/request snapshot / acceptance criteria / priority / readiness / risk flags を、現在の要件として意味がある範囲で提案する。
- canonical ID は Ticket 作成/storage が opaque な path-derived value として割り当てるため、Intake はユーザー向け metadata として提案しない。 - canonical ID は Ticket 作成/storage が opaque な path-derived value として割り当てるため、Intake はユーザー向け metadata として提案しない。
- background / requirements / acceptance criteria / escalation conditions を整理する。 - background / requirements / acceptance criteria / escalation conditions を整理する。
- binding decisions / invariants と implementation latitude を分けて書く。 - binding decisions / invariants と implementation latitude を分けて書く。
@ -229,7 +229,7 @@ canonical ID は作成時に storage が opaque/path-derived value として割
新規 Ticket の場合: 新規 Ticket の場合:
- `TicketCreate` を使う。 - `TicketCreate` を使う。
- title / priority / body と、必要な readiness / action_required / attention_required を指定する。canonical ID は storage が割り当てる。 - title / priority / body と、必要な readiness / risk flags を指定する。canonical ID は storage が割り当てる。
- body に readiness / open questions / risk flags と、binding decisions / invariants、implementation latitude、escalation conditions を Markdown で明記する。 - body に readiness / open questions / risk flags と、binding decisions / invariants、implementation latitude、escalation conditions を Markdown で明記する。
既存 Ticket refinement の場合: 既存 Ticket refinement の場合:

View File

@ -214,7 +214,7 @@ Action:
- `TicketComment` に review target と確認観点を記録する。 - `TicketComment` に review target と確認観点を記録する。
- blocker 未解決のまま merge-ready としない。 - blocker 未解決のまま merge-ready としない。
### `blocked_action_required` ### `blocked_by_dependency_or_missing_authority`
人間判断または外部イベント待ち。 人間判断または外部イベント待ち。
@ -229,7 +229,7 @@ Action:
- 必要な判断・外部 action を短く書く。 - 必要な判断・外部 action を短く書く。
- `TicketComment` に blocked reason と next question を記録する。 - `TicketComment` に blocked reason と next question を記録する。
- 必要に応じて attention / action-required frontmatter や orchestration plan の blocker/waiting-capacity 記録で、待ち理由を current state とは別に表す。lifecycle 外の storage bucket へ移す route は使わない。 - 必要に応じて typed relation metadata や orchestration plan の blocker/waiting-capacity 記録で、待ち理由を current state とは別に表す。lifecycle 外の storage bucket へ移す route は使わない。
### `close_ready` ### `close_ready`
@ -393,7 +393,7 @@ IntentPacket が短く書けない場合、`implementation_ready` ではなく `
- `spike_needed` → read-only investigation plan / Pod許可後 - `spike_needed` → read-only investigation plan / Pod許可後
- `implementation_ready``multi-agent-workflow` - `implementation_ready``multi-agent-workflow`
- `review_needed` → reviewer Pod / review workflow - `review_needed` → reviewer Pod / review workflow
- `blocked_action_required` → human / parent Orchestrator - concrete blocker / missing decision → human / parent Orchestrator
- `close_ready` → close workflow / maintainer decision - `close_ready` → close workflow / maintainer decision
## 完了条件 ## 完了条件
@ -410,7 +410,7 @@ IntentPacket が短く書けない場合、`implementation_ready` ではなく `
- unattended scheduler。 - unattended scheduler。
- LeaseStore / queue persistence。 - LeaseStore / queue persistence。
- action-required dashboard UI。 - queue/dashboard UI。
- automatic Pod spawning policy。 - automatic Pod spawning policy。
- TicketUpdate tool の導入。 - TicketUpdate tool の導入。
- external tracker integration。 - external tracker integration。

View File

@ -481,9 +481,7 @@ pub struct NewTicket {
pub assignee: Option<String>, pub assignee: Option<String>,
pub readiness: Option<String>, pub readiness: Option<String>,
pub risk_flags: Vec<String>, pub risk_flags: Vec<String>,
pub action_required: Option<String>,
pub workflow_state: Option<TicketWorkflowState>, pub workflow_state: Option<TicketWorkflowState>,
pub attention_required: Option<String>,
pub queued_by: Option<String>, pub queued_by: Option<String>,
pub queued_at: Option<String>, pub queued_at: Option<String>,
} }
@ -501,9 +499,7 @@ impl NewTicket {
assignee: None, assignee: None,
readiness: None, readiness: None,
risk_flags: Vec::new(), risk_flags: Vec::new(),
action_required: None,
workflow_state: None, workflow_state: None,
attention_required: None,
queued_by: None, queued_by: None,
queued_at: None, queued_at: None,
} }
@ -744,10 +740,8 @@ pub struct TicketMeta {
pub assignee: Option<String>, pub assignee: Option<String>,
pub readiness: Option<String>, pub readiness: Option<String>,
pub risk_flags: Vec<String>, pub risk_flags: Vec<String>,
pub action_required: Option<String>,
pub workflow_state: TicketWorkflowState, pub workflow_state: TicketWorkflowState,
pub workflow_state_explicit: bool, pub workflow_state_explicit: bool,
pub attention_required: Option<String>,
pub queued_by: Option<String>, pub queued_by: Option<String>,
pub queued_at: Option<String>, pub queued_at: Option<String>,
pub raw: BTreeMap<String, String>, pub raw: BTreeMap<String, String>,
@ -763,10 +757,8 @@ pub struct TicketSummary {
pub priority: String, pub priority: String,
pub labels: Vec<String>, pub labels: Vec<String>,
pub readiness: Option<String>, pub readiness: Option<String>,
pub action_required: Option<String>,
pub workflow_state: TicketWorkflowState, pub workflow_state: TicketWorkflowState,
pub workflow_state_explicit: bool, pub workflow_state_explicit: bool,
pub attention_required: Option<String>,
pub queued_by: Option<String>, pub queued_by: Option<String>,
pub queued_at: Option<String>, pub queued_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
@ -1290,10 +1282,8 @@ impl TicketBackend for LocalTicketBackend {
priority: meta.priority, priority: meta.priority,
labels: meta.labels, labels: meta.labels,
readiness: meta.readiness, readiness: meta.readiness,
action_required: meta.action_required,
workflow_state: meta.workflow_state, workflow_state: meta.workflow_state,
workflow_state_explicit: meta.workflow_state_explicit, workflow_state_explicit: meta.workflow_state_explicit,
attention_required: meta.attention_required,
queued_by: meta.queued_by, queued_by: meta.queued_by,
queued_at: meta.queued_at, queued_at: meta.queued_at,
updated_at: meta.updated_at, updated_at: meta.updated_at,
@ -1377,18 +1367,6 @@ impl TicketBackend for LocalTicketBackend {
if !input.risk_flags.is_empty() { if !input.risk_flags.is_empty() {
fields.push(("risk_flags".to_string(), labels_yaml(&input.risk_flags))); fields.push(("risk_flags".to_string(), labels_yaml(&input.risk_flags)));
} }
if let Some(action_required) = input.action_required {
fields.push((
"action_required".to_string(),
format_yaml_string_scalar(action_required.as_str()),
));
}
if let Some(attention_required) = input.attention_required {
fields.push((
"attention_required".to_string(),
format_yaml_string_scalar(attention_required.as_str()),
));
}
if let Some(queued_by) = input.queued_by { if let Some(queued_by) = input.queued_by {
fields.push(( fields.push((
"queued_by".to_string(), "queued_by".to_string(),
@ -1874,7 +1852,16 @@ impl TicketBackend for LocalTicketBackend {
); );
} }
} }
for obsolete in ["id", "slug", "status", "workflow_state", "kind", "labels"] { for obsolete in [
"id",
"slug",
"status",
"workflow_state",
"kind",
"labels",
"action_required",
"attention_required",
] {
if parsed.frontmatter.get(obsolete).is_some() { if parsed.frontmatter.get(obsolete).is_some() {
report.push_error( report.push_error(
format!( format!(
@ -1994,12 +1981,10 @@ struct TicketItemFrontmatter {
assignee: Option<String>, assignee: Option<String>,
readiness: Option<String>, readiness: Option<String>,
risk_flags: Vec<String>, risk_flags: Vec<String>,
action_required: Option<String>,
workflow_state: Option<TicketWorkflowState>, workflow_state: Option<TicketWorkflowState>,
workflow_state_explicit: bool, workflow_state_explicit: bool,
state: Option<TicketWorkflowState>, state: Option<TicketWorkflowState>,
state_explicit: bool, state_explicit: bool,
attention_required: Option<String>,
queued_by: Option<String>, queued_by: Option<String>,
queued_at: Option<String>, queued_at: Option<String>,
raw: BTreeMap<String, String>, raw: BTreeMap<String, String>,
@ -2103,12 +2088,10 @@ fn parse_ticket_frontmatter(content: &str) -> std::result::Result<TicketItemFron
assignee: yaml_string(&mapping, "assignee")?, assignee: yaml_string(&mapping, "assignee")?,
readiness: yaml_string(&mapping, "readiness")?, readiness: yaml_string(&mapping, "readiness")?,
risk_flags: yaml_string_list(&mapping, "risk_flags")?, risk_flags: yaml_string_list(&mapping, "risk_flags")?,
action_required: yaml_string(&mapping, "action_required")?,
workflow_state, workflow_state,
workflow_state_explicit, workflow_state_explicit,
state, state,
state_explicit, state_explicit,
attention_required: yaml_string(&mapping, "attention_required")?,
queued_by: yaml_string(&mapping, "queued_by")?, queued_by: yaml_string(&mapping, "queued_by")?,
queued_at: yaml_string(&mapping, "queued_at")?, queued_at: yaml_string(&mapping, "queued_at")?,
raw, raw,
@ -2233,10 +2216,8 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
assignee: frontmatter.assignee, assignee: frontmatter.assignee,
readiness: frontmatter.readiness, readiness: frontmatter.readiness,
risk_flags: frontmatter.risk_flags, risk_flags: frontmatter.risk_flags,
action_required: frontmatter.action_required,
workflow_state, workflow_state,
workflow_state_explicit: frontmatter.state_explicit, workflow_state_explicit: frontmatter.state_explicit,
attention_required: frontmatter.attention_required,
queued_by: frontmatter.queued_by, queued_by: frontmatter.queued_by,
queued_at: frontmatter.queued_at, queued_at: frontmatter.queued_at,
raw: frontmatter.raw, raw: frontmatter.raw,
@ -3548,8 +3529,6 @@ updated_at: 2026-06-05T00:00:00Z
assignee: null assignee: null
readiness: implementation-ready readiness: implementation-ready
risk_flags: [low, local] risk_flags: [low, local]
action_required: none
attention_required: none
queued_by: workspace-panel queued_by: workspace-panel
queued_at: 2026-06-05T00:01:00Z queued_at: 2026-06-05T00:01:00Z
--- ---
@ -3563,10 +3542,8 @@ queued_at: 2026-06-05T00:01:00Z
assert!(meta.labels.is_empty()); assert!(meta.labels.is_empty());
assert_eq!(meta.readiness.as_deref(), Some("implementation-ready")); assert_eq!(meta.readiness.as_deref(), Some("implementation-ready"));
assert_eq!(meta.risk_flags, vec!["low", "local"]); assert_eq!(meta.risk_flags, vec!["low", "local"]);
assert_eq!(meta.action_required.as_deref(), Some("none"));
assert_eq!(meta.workflow_state, TicketWorkflowState::Ready); assert_eq!(meta.workflow_state, TicketWorkflowState::Ready);
assert!(meta.workflow_state_explicit); assert!(meta.workflow_state_explicit);
assert_eq!(meta.attention_required.as_deref(), Some("none"));
assert_eq!(meta.queued_by.as_deref(), Some("workspace-panel")); assert_eq!(meta.queued_by.as_deref(), Some("workspace-panel"));
assert_eq!(meta.queued_at.as_deref(), Some("2026-06-05T00:01:00Z")); assert_eq!(meta.queued_at.as_deref(), Some("2026-06-05T00:01:00Z"));
} }
@ -3576,8 +3553,6 @@ queued_at: 2026-06-05T00:01:00Z
let frontmatter = parse_ticket_frontmatter( let frontmatter = parse_ticket_frontmatter(
r#"risk_flags: [low, local] r#"risk_flags: [low, local]
assignee: ~ assignee: ~
attention_required: null
action_required: "null"
readiness: "~" readiness: "~"
state: planning state: planning
"#, "#,
@ -3587,8 +3562,6 @@ state: planning
assert!(meta.labels.is_empty()); assert!(meta.labels.is_empty());
assert_eq!(meta.risk_flags, vec!["low", "local"]); assert_eq!(meta.risk_flags, vec!["low", "local"]);
assert_eq!(meta.assignee, None); assert_eq!(meta.assignee, None);
assert_eq!(meta.attention_required, None);
assert_eq!(meta.action_required.as_deref(), Some("null"));
assert_eq!(meta.readiness.as_deref(), Some("~")); assert_eq!(meta.readiness.as_deref(), Some("~"));
assert_eq!(meta.workflow_state, TicketWorkflowState::Planning); assert_eq!(meta.workflow_state, TicketWorkflowState::Planning);
assert!(meta.workflow_state_explicit); assert!(meta.workflow_state_explicit);
@ -3641,6 +3614,8 @@ state: planning
"workflow_state:", "workflow_state:",
"kind:", "kind:",
"labels:", "labels:",
"action_required:",
"attention_required:",
] { ] {
assert!( assert!(
!item.contains(obsolete), !item.contains(obsolete),
@ -3680,8 +3655,6 @@ state: planning
let mut input = NewTicket::new("123"); let mut input = NewTicket::new("123");
input.risk_flags = vec!["1".into(), "42".into()]; input.risk_flags = vec!["1".into(), "42".into()];
input.assignee = Some("42".into()); input.assignee = Some("42".into());
input.attention_required = Some("0".into());
input.action_required = Some("true".into());
let ticket = backend.create(input).unwrap(); let ticket = backend.create(input).unwrap();
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
@ -3689,8 +3662,6 @@ state: planning
assert!(record.meta.labels.is_empty()); assert!(record.meta.labels.is_empty());
assert_eq!(record.meta.risk_flags, vec!["1", "42"]); assert_eq!(record.meta.risk_flags, vec!["1", "42"]);
assert_eq!(record.meta.assignee.as_deref(), Some("42")); assert_eq!(record.meta.assignee.as_deref(), Some("42"));
assert_eq!(record.meta.attention_required.as_deref(), Some("0"));
assert_eq!(record.meta.action_required.as_deref(), Some("true"));
let item = fs::read_to_string(tmp.path().join("tickets").join(&ticket.id).join("item.md")) let item = fs::read_to_string(tmp.path().join("tickets").join(&ticket.id).join("item.md"))
.unwrap(); .unwrap();
@ -3698,8 +3669,8 @@ state: planning
assert!(!item.contains("labels:"), "{item}"); assert!(!item.contains("labels:"), "{item}");
assert!(item.contains("risk_flags: ['1', '42']"), "{item}"); assert!(item.contains("risk_flags: ['1', '42']"), "{item}");
assert!(item.contains("assignee: '42'"), "{item}"); assert!(item.contains("assignee: '42'"), "{item}");
assert!(item.contains("attention_required: '0'"), "{item}"); assert!(!item.contains("attention_required:"), "{item}");
assert!(item.contains("action_required: 'true'"), "{item}"); assert!(!item.contains("action_required:"), "{item}");
let report = backend.doctor().unwrap(); let report = backend.doctor().unwrap();
assert!(report.is_ok(), "{:?}", report.diagnostics); assert!(report.is_ok(), "{:?}", report.diagnostics);
@ -4118,7 +4089,7 @@ state: planning
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap(); fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
fs::write( fs::write(
root.join("20260609-000000-001/item.md"), root.join("20260609-000000-001/item.md"),
"---\nid: old\nslug: old\ntitle: Bad\nstatus: pending\nworkflow_state: ready\nkind: task\nlabels: []\ncreated_at: x\nupdated_at: x\n---\n", "---\nid: old\nslug: old\ntitle: Bad\nstatus: pending\nworkflow_state: ready\nkind: task\nlabels: []\naction_required: human\nattention_required: true\ncreated_at: x\nupdated_at: x\n---\n",
) )
.unwrap(); .unwrap();
fs::write( fs::write(
@ -4141,6 +4112,8 @@ state: planning
assert!(messages.contains("obsolete current frontmatter field 'workflow_state'")); assert!(messages.contains("obsolete current frontmatter field 'workflow_state'"));
assert!(messages.contains("obsolete current frontmatter field 'kind'")); assert!(messages.contains("obsolete current frontmatter field 'kind'"));
assert!(messages.contains("obsolete current frontmatter field 'labels'")); assert!(messages.contains("obsolete current frontmatter field 'labels'"));
assert!(messages.contains("obsolete current frontmatter field 'action_required'"));
assert!(messages.contains("obsolete current frontmatter field 'attention_required'"));
assert!(messages.contains("review event missing valid status")); assert!(messages.contains("review event missing valid status"));
} }

View File

@ -125,15 +125,9 @@ struct TicketCreateParams {
/// Optional risk flag frontmatter values. /// Optional risk flag frontmatter values.
#[serde(default)] #[serde(default)]
risk_flags: Vec<String>, risk_flags: Vec<String>,
/// Optional action-required frontmatter value.
#[serde(default)]
action_required: Option<String>,
/// Optional state frontmatter value. Defaults to `planning`. /// Optional state frontmatter value. Defaults to `planning`.
#[serde(default)] #[serde(default)]
state: Option<TicketWorkflowStateParam>, state: Option<TicketWorkflowStateParam>,
/// Optional attention_required overlay frontmatter value.
#[serde(default)]
attention_required: Option<String>,
/// Optional queued_by frontmatter value. /// Optional queued_by frontmatter value.
#[serde(default)] #[serde(default)]
queued_by: Option<String>, queued_by: Option<String>,
@ -590,9 +584,7 @@ impl Tool for TicketCreateTool {
input.assignee = params.assignee; input.assignee = params.assignee;
input.readiness = params.readiness; input.readiness = params.readiness;
input.risk_flags = params.risk_flags; input.risk_flags = params.risk_flags;
input.action_required = params.action_required;
input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state); input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state);
input.attention_required = params.attention_required;
input.queued_by = params.queued_by; input.queued_by = params.queued_by;
input.queued_at = params.queued_at; input.queued_at = params.queued_at;
@ -1051,18 +1043,6 @@ fn ticket_summary_json(ticket: TicketSummary) -> TicketListTicketOutput {
fn ticket_list_hints(ticket: &TicketSummary) -> Vec<String> { fn ticket_list_hints(ticket: &TicketSummary) -> Vec<String> {
let mut hints = Vec::new(); let mut hints = Vec::new();
if let Some(attention) = ticket.attention_required.as_deref() {
hints.push(format!(
"attention:{}",
truncate_inline(attention, LIST_HINT_MAX_CHARS)
));
}
if let Some(action) = ticket.action_required.as_deref() {
hints.push(format!(
"action:{}",
truncate_inline(action, LIST_HINT_MAX_CHARS)
));
}
if let Some(readiness) = ticket.readiness.as_deref() { if let Some(readiness) = ticket.readiness.as_deref() {
hints.push(format!( hints.push(format!(
"readiness:{}", "readiness:{}",
@ -1183,8 +1163,6 @@ fn ticket_json(
"assignee": ticket.meta.assignee, "assignee": ticket.meta.assignee,
"readiness": ticket.meta.readiness, "readiness": ticket.meta.readiness,
"risk_flags": ticket.meta.risk_flags, "risk_flags": ticket.meta.risk_flags,
"action_required": ticket.meta.action_required,
"attention_required": ticket.meta.attention_required,
"queued_by": ticket.meta.queued_by, "queued_by": ticket.meta.queued_by,
"queued_at": ticket.meta.queued_at, "queued_at": ticket.meta.queued_at,
}, },
@ -1486,6 +1464,8 @@ mod tests {
let created_text = created_json.to_string(); let created_text = created_json.to_string();
assert!(!created_text.contains("legacy_ticket")); assert!(!created_text.contains("legacy_ticket"));
assert!(!created_text.contains("needs_preflight")); assert!(!created_text.contains("needs_preflight"));
assert!(!created_text.contains("action_required"));
assert!(!created_text.contains("attention_required"));
let listed = list let listed = list
.execute( .execute(
@ -1512,6 +1492,8 @@ mod tests {
assert!(shown_content.contains("Created by tool")); assert!(shown_content.contains("Created by tool"));
assert!(!shown_content.contains("legacy_ticket")); assert!(!shown_content.contains("legacy_ticket"));
assert!(!shown_content.contains("needs_preflight")); assert!(!shown_content.contains("needs_preflight"));
assert!(!shown_content.contains("action_required"));
assert!(!shown_content.contains("attention_required"));
let report = doctor let report = doctor
.execute(&json!({}).to_string(), Default::default()) .execute(&json!({}).to_string(), Default::default())
@ -1529,8 +1511,8 @@ mod tests {
"Long Title {}", "Long Title {}",
"x".repeat(LIST_TITLE_MAX_CHARS + 40) "x".repeat(LIST_TITLE_MAX_CHARS + 40)
)); ));
ticket.attention_required = Some(format!( ticket.readiness = Some(format!(
"Needs attention {}", "Ready after review {}",
"a".repeat(LIST_HINT_MAX_CHARS + 40) "a".repeat(LIST_HINT_MAX_CHARS + 40)
)); ));
backend.create(ticket).unwrap(); backend.create(ticket).unwrap();
@ -1544,7 +1526,7 @@ mod tests {
assert!(title.chars().count() <= LIST_TITLE_MAX_CHARS); assert!(title.chars().count() <= LIST_TITLE_MAX_CHARS);
assert!(title.ends_with("...")); assert!(title.ends_with("..."));
let hint = listed_json["tickets"][0]["hints"][0].as_str().unwrap(); let hint = listed_json["tickets"][0]["hints"][0].as_str().unwrap();
assert!(hint.chars().count() <= "attention:".chars().count() + LIST_HINT_MAX_CHARS); assert!(hint.chars().count() <= "readiness:".chars().count() + LIST_HINT_MAX_CHARS);
assert!(hint.ends_with("...")); assert!(hint.ends_with("..."));
} }
@ -2214,6 +2196,8 @@ mod tests {
.to_string(); .to_string();
assert!(!create_schema.contains("legacy_ticket")); assert!(!create_schema.contains("legacy_ticket"));
assert!(!create_schema.contains("needs_preflight")); assert!(!create_schema.contains("needs_preflight"));
assert!(!create_schema.contains("action_required"));
assert!(!create_schema.contains("attention_required"));
let plan_record_schema = tools let plan_record_schema = tools
.iter() .iter()
.map(|definition| definition().0) .map(|definition| definition().0)

View File

@ -2109,18 +2109,6 @@ fn panel_close_blocker(ticket: &ticket::Ticket) -> Option<String> {
ticket.meta.workflow_state.as_str() ticket.meta.workflow_state.as_str()
)); ));
} }
if let Some(reason) = non_empty_ticket_field(ticket.meta.attention_required.as_deref()) {
return Some(format!(
"Close blocked for Ticket {ticket_id}: attention_required is set ({}); no close was recorded.",
bounded_panel_diagnostic(reason)
));
}
if let Some(reason) = non_empty_ticket_field(ticket.meta.action_required.as_deref()) {
return Some(format!(
"Close blocked for Ticket {ticket_id}: action_required is set ({}); no close was recorded.",
bounded_panel_diagnostic(reason)
));
}
if ticket.resolution.is_some() { if ticket.resolution.is_some() {
return Some(format!( return Some(format!(
"Close blocked for Ticket {ticket_id}: resolution.md already exists; no close was recorded." "Close blocked for Ticket {ticket_id}: resolution.md already exists; no close was recorded."
@ -2129,10 +2117,6 @@ fn panel_close_blocker(ticket: &ticket::Ticket) -> Option<String> {
None None
} }
fn non_empty_ticket_field(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn panel_close_resolution( fn panel_close_resolution(
ticket: &ticket::Ticket, ticket: &ticket::Ticket,
record_language: Option<&str>, record_language: Option<&str>,
@ -3295,48 +3279,6 @@ mod tests {
})); }));
} }
#[tokio::test]
async fn ticket_close_action_blocks_action_required_without_mutation() {
let (temp, ticket_id, backend) = ticket_workspace(
"panel-close-action-required",
TicketWorkflowState::Done,
|input| {
input.action_required = Some("human decision needed".to_string());
},
);
let error =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close))
.await
.unwrap_err();
assert!(error.to_string().contains("action_required is set"));
assert!(error.to_string().contains("no close was recorded"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Done);
assert!(ticket.resolution.is_none());
}
#[tokio::test]
async fn ticket_close_action_blocks_attention_required_without_mutation() {
let (temp, ticket_id, backend) = ticket_workspace(
"panel-close-attention-required",
TicketWorkflowState::Done,
|input| {
input.attention_required = Some("needs reply".to_string());
},
);
let error =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close))
.await
.unwrap_err();
assert!(error.to_string().contains("attention_required is set"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert!(ticket.resolution.is_none());
}
#[tokio::test] #[tokio::test]
async fn ticket_close_action_blocks_existing_resolution_without_moving_ticket() { async fn ticket_close_action_blocks_existing_resolution_without_moving_ticket() {
let (temp, ticket_id, backend) = done_ticket_workspace("panel-close-resolution"); let (temp, ticket_id, backend) = done_ticket_workspace("panel-close-resolution");
@ -3489,8 +3431,8 @@ mod tests {
) )
.unwrap(); .unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let mut ticket = NewTicket::new("Needs Human Reply"); let mut ticket = NewTicket::new("Ready Ticket");
ticket.action_required = Some("answer intake question".to_string()); ticket.workflow_state = Some(TicketWorkflowState::Ready);
backend.create(ticket).unwrap(); backend.create(ticket).unwrap();
let list = PodList::from_sources( let list = PodList::from_sources(
PodVisibilitySource::ResumePicker, PodVisibilitySource::ResumePicker,
@ -3502,7 +3444,7 @@ mod tests {
let panel = build_workspace_panel(temp.path(), &list); let panel = build_workspace_panel(temp.path(), &list);
let mut app = app_with_panel(list, panel); let mut app = app_with_panel(list, panel);
assert_eq!(app.selected_panel_row().unwrap().title, "Needs Human Reply"); assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
let lines = list_lines(&app, 100, 6) let lines = list_lines(&app, 100, 6)
.into_iter() .into_iter()
@ -3510,7 +3452,7 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let ticket_line = lines let ticket_line = lines
.iter() .iter()
.position(|line| line.contains("Needs Human Reply")) .position(|line| line.contains("Ready Ticket"))
.unwrap(); .unwrap();
let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap(); let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap();
assert!(ticket_line < pod_line); assert!(ticket_line < pod_line);
@ -4885,7 +4827,6 @@ mod tests {
workflow_state: TicketWorkflowState::parse(state) workflow_state: TicketWorkflowState::parse(state)
.unwrap_or(TicketWorkflowState::Planning), .unwrap_or(TicketWorkflowState::Planning),
workflow_state_explicit: true, workflow_state_explicit: true,
attention_required: None,
next_action: Some(next_action), next_action: Some(next_action),
updated_at: None, updated_at: None,
latest_event_kind: Some("implementation_report".to_string()), latest_event_kind: Some("implementation_report".to_string()),

View File

@ -233,7 +233,6 @@ pub(crate) struct TicketPanelEntry {
pub(crate) priority: String, pub(crate) priority: String,
pub(crate) workflow_state: TicketWorkflowState, pub(crate) workflow_state: TicketWorkflowState,
pub(crate) workflow_state_explicit: bool, pub(crate) workflow_state_explicit: bool,
pub(crate) attention_required: Option<String>,
pub(crate) next_action: Option<NextUserAction>, pub(crate) next_action: Option<NextUserAction>,
pub(crate) updated_at: Option<String>, pub(crate) updated_at: Option<String>,
pub(crate) latest_event_kind: Option<String>, pub(crate) latest_event_kind: Option<String>,
@ -620,10 +619,8 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
priority: meta.priority.clone(), priority: meta.priority.clone(),
labels: meta.labels.clone(), labels: meta.labels.clone(),
readiness: meta.readiness.clone(), readiness: meta.readiness.clone(),
action_required: meta.action_required.clone(),
workflow_state: meta.workflow_state, workflow_state: meta.workflow_state,
workflow_state_explicit: meta.workflow_state_explicit, workflow_state_explicit: meta.workflow_state_explicit,
attention_required: meta.attention_required.clone(),
queued_by: meta.queued_by.clone(), queued_by: meta.queued_by.clone(),
queued_at: meta.queued_at.clone(), queued_at: meta.queued_at.clone(),
updated_at: meta.updated_at.clone(), updated_at: meta.updated_at.clone(),
@ -669,7 +666,6 @@ fn ticket_row(
priority: summary.priority.clone(), priority: summary.priority.clone(),
workflow_state: summary.workflow_state, workflow_state: summary.workflow_state,
workflow_state_explicit: summary.workflow_state_explicit, workflow_state_explicit: summary.workflow_state_explicit,
attention_required: summary.attention_required.clone(),
next_action: derived.action, next_action: derived.action,
updated_at: summary.updated_at.clone(), updated_at: summary.updated_at.clone(),
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()), latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
@ -735,26 +731,6 @@ fn derive_ticket_state(
}; };
} }
if let Some(reason) = summary
.attention_required
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return DerivedTicketState {
kind: PanelRowKind::Blocked,
priority: ActionPriority::UserReply,
action: Some(NextUserAction::Edit),
disabled_reason: Some(
"attention_required is set; resolve it before queueing or routing.".to_string(),
),
key_hint: Some(
"Resolve attention_required in the Ticket frontmatter/thread".to_string(),
),
blocked_reason: Some(reason.to_string()),
};
}
match summary.workflow_state { match summary.workflow_state {
TicketWorkflowState::Ready => DerivedTicketState { TicketWorkflowState::Ready => DerivedTicketState {
kind: PanelRowKind::Ticket, kind: PanelRowKind::Ticket,
@ -870,9 +846,6 @@ pub(crate) fn local_claim_status_for_pod(pod_name: &str, pods: &PodList) -> Tick
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> { fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
let mut parts = vec![format!("{} · {}", entry.id, entry.workflow_state.as_str())]; let mut parts = vec![format!("{} · {}", entry.id, entry.workflow_state.as_str())];
if let Some(reason) = entry.attention_required.as_deref() {
parts.push(format!("attention: {reason}"));
}
if let Some(claim) = entry.local_claim.as_ref() { if let Some(claim) = entry.local_claim.as_ref() {
parts.push(format!( parts.push(format!(
"claim: {} ({})", "claim: {} ({})",
@ -1044,9 +1017,7 @@ mod tests {
fn workspace_panel_without_ticket_config_is_pod_only() { fn workspace_panel_without_ticket_config_is_pod_only() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Hidden Without Config", |input| { create_ticket(&backend, "Hidden Without Config", |_| {});
input.action_required = Some("answer me".to_string());
});
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
@ -1068,37 +1039,22 @@ mod tests {
create_ticket(&backend, "Ready Ticket", |input| { create_ticket(&backend, "Ready Ticket", |input| {
input.workflow_state = Some(TicketWorkflowState::Ready); input.workflow_state = Some(TicketWorkflowState::Ready);
}); });
create_ticket(&backend, "Needs User", |input| { create_ticket(&backend, "Planning Ticket", |_| {});
input.workflow_state = Some(TicketWorkflowState::Ready);
input.attention_required = Some("answer clarification".to_string());
});
let model = build_workspace_panel(temp.path(), &empty_pods()); let model = build_workspace_panel(temp.path(), &empty_pods());
assert_eq!( assert_eq!(
model.composer.available_targets, model.composer.available_targets,
vec![ComposerTarget::Companion, ComposerTarget::TicketIntake] vec![ComposerTarget::Companion, ComposerTarget::TicketIntake]
); );
let rows = model let row = model
.rows .rows
.iter() .iter()
.map(|row| { .find(|row| row.title == "Ready Ticket")
( .unwrap();
row.title.as_str(),
row.status.as_str(),
row.priority,
row.next_action,
)
})
.collect::<Vec<_>>();
assert_eq!(rows[0].0, "Needs User"); assert_eq!(row.status, "ready");
assert_eq!(rows[0].1, "ready"); assert_eq!(row.priority, ActionPriority::ReadyForQueue);
assert_eq!(rows[0].2, ActionPriority::UserReply); assert_eq!(row.next_action, Some(NextUserAction::Queue));
assert_eq!(rows[0].3, Some(NextUserAction::Edit));
assert_eq!(rows[1].0, "Ready Ticket");
assert_eq!(rows[1].1, "ready");
assert_eq!(rows[1].2, ActionPriority::ReadyForQueue);
assert_eq!(rows[1].3, Some(NextUserAction::Queue));
} }
#[test] #[test]
@ -1198,45 +1154,6 @@ mod tests {
); );
} }
#[test]
fn workspace_panel_treats_yaml_null_attention_required_as_unblocked_planning() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let ticket_ref = backend
.create({
let mut input = NewTicket::new("Null Attention Planning");
input.workflow_state = Some(TicketWorkflowState::Planning);
input
})
.unwrap();
let item_path = temp
.path()
.join(".yoi/tickets")
.join(&ticket_ref.id)
.join("item.md");
let item = fs::read_to_string(&item_path).unwrap();
fs::write(
&item_path,
item.replace(
"state: planning\ncreated_at:",
"state: planning\nattention_required: null\ncreated_at:",
),
)
.unwrap();
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = model
.rows
.iter()
.find(|row| row.title == "Null Attention Planning")
.unwrap();
assert_eq!(row.status, "planning");
assert_eq!(row.next_action, Some(NextUserAction::Clarify));
assert_eq!(row.priority, ActionPriority::Background);
}
#[test] #[test]
fn workspace_panel_defaults_missing_open_state_to_planning_and_displays_done_state() { fn workspace_panel_defaults_missing_open_state_to_planning_and_displays_done_state() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -473,7 +473,10 @@ fn show(backend: &LocalTicketBackend, query: String) -> Result<TicketCliOutput,
} }
fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool { fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool {
matches!(key, "legacy_ticket" | "needs_preflight") matches!(
key,
"legacy_ticket" | "needs_preflight" | "action_required" | "attention_required"
)
} }
fn comment( fn comment(
@ -988,18 +991,6 @@ fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
fn ticket_cli_hints(ticket: &TicketSummary) -> String { fn ticket_cli_hints(ticket: &TicketSummary) -> String {
let mut hints = Vec::new(); let mut hints = Vec::new();
if let Some(attention) = ticket.attention_required.as_deref() {
hints.push(format!(
"attention:{}",
truncate_inline(attention, LIST_HINT_MAX_CHARS)
));
}
if let Some(action) = ticket.action_required.as_deref() {
hints.push(format!(
"action:{}",
truncate_inline(action, LIST_HINT_MAX_CHARS)
));
}
if let Some(readiness) = ticket.readiness.as_deref() { if let Some(readiness) = ticket.readiness.as_deref() {
hints.push(format!( hints.push(format!(
"readiness:{}", "readiness:{}",
@ -1333,6 +1324,36 @@ mod tests {
); );
} }
#[test]
fn ticket_cli_show_omits_obsolete_overlay_fields_from_legacy_frontmatter() {
let temp = TempDir::new().unwrap();
let created = run(&temp, &["create", "--title", "Legacy Overlay"]);
let ticket_id = created_id(&created);
let item_path = temp
.path()
.join(".yoi/tickets")
.join(&ticket_id)
.join("item.md");
let item = fs::read_to_string(&item_path).unwrap();
fs::write(
&item_path,
item.replacen(
"---\n",
"---\naction_required: legacy action\nattention_required: legacy attention\n",
1,
),
)
.unwrap();
let shown = run(&temp, &["show", &ticket_id]);
assert_eq!(shown.status, TicketCliStatus::Success);
assert!(shown.stdout.contains("# Legacy Overlay"));
assert!(!shown.stdout.contains("action_required"));
assert!(!shown.stdout.contains("attention_required"));
assert!(!shown.stdout.contains("legacy action"));
assert!(!shown.stdout.contains("legacy attention"));
}
#[test] #[test]
fn ticket_cli_records_lists_and_shows_relations() { fn ticket_cli_records_lists_and_shows_relations() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -208,7 +208,7 @@ Routing classifications include:
- `spike_needed` - `spike_needed`
- `implementation_ready` - `implementation_ready`
- `review_needed` - `review_needed`
- `blocked_action_required` - `blocked_by_dependency_or_missing_authority`
- `close_ready` - `close_ready`
- `closed_or_noop` - `closed_or_noop`