use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use super::artifact::Artifact;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TaskSessionTransport {
Acp,
A2a,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskLaneSessionStatus {
Running,
Completed,
Failed,
TimedOut,
Transitioned,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskLaneSessionLoopMode {
WatchdogRetry,
RalphLoop,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskLaneSessionCompletionRequirement {
TurnComplete,
CompletionSummary,
VerificationReport,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskLaneSessionRecoveryReason {
WatchdogInactivity,
AgentFailed,
CompletionCriteriaNotMet,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskLaneSession {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub routa_agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub step_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub step_index: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub step_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub specialist_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub specialist_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attempt: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub loop_mode: Option<TaskLaneSessionLoopMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_requirement: Option<TaskLaneSessionCompletionRequirement>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objective: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_activity_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recovered_from_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recovery_reason: Option<TaskLaneSessionRecoveryReason>,
pub status: TaskLaneSessionStatus,
pub started_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskLaneHandoffRequestType {
EnvironmentPreparation,
RuntimeContext,
Clarification,
RerunCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskLaneHandoffStatus {
Requested,
Delivered,
Completed,
Blocked,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskLaneHandoff {
pub id: String,
pub from_session_id: String,
pub to_session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_column_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to_column_id: Option<String>,
pub request_type: TaskLaneHandoffRequestType,
pub request: String,
pub status: TaskLaneHandoffStatus,
pub requested_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub responded_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TaskPriority {
#[serde(rename = "low")]
Low,
#[serde(rename = "medium")]
Medium,
#[serde(rename = "high")]
High,
#[serde(rename = "urgent")]
Urgent,
}
impl TaskPriority {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Urgent => "urgent",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"urgent" => Some(Self::Urgent),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TaskStatus {
#[serde(rename = "PENDING")]
Pending,
#[serde(rename = "IN_PROGRESS")]
InProgress,
#[serde(rename = "REVIEW_REQUIRED")]
ReviewRequired,
#[serde(rename = "COMPLETED")]
Completed,
#[serde(rename = "NEEDS_FIX")]
NeedsFix,
#[serde(rename = "BLOCKED")]
Blocked,
#[serde(rename = "CANCELLED")]
Cancelled,
}
impl TaskStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "PENDING",
Self::InProgress => "IN_PROGRESS",
Self::ReviewRequired => "REVIEW_REQUIRED",
Self::Completed => "COMPLETED",
Self::NeedsFix => "NEEDS_FIX",
Self::Blocked => "BLOCKED",
Self::Cancelled => "CANCELLED",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"PENDING" => Some(Self::Pending),
"IN_PROGRESS" => Some(Self::InProgress),
"REVIEW_REQUIRED" => Some(Self::ReviewRequired),
"COMPLETED" => Some(Self::Completed),
"NEEDS_FIX" => Some(Self::NeedsFix),
"BLOCKED" => Some(Self::Blocked),
"CANCELLED" => Some(Self::Cancelled),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TaskCreationSource {
Manual,
Agent,
Api,
Session,
}
impl TaskCreationSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Agent => "agent",
Self::Api => "api",
Self::Session => "session",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"manual" => Some(Self::Manual),
"agent" => Some(Self::Agent),
"api" => Some(Self::Api),
"session" => Some(Self::Session),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum VerificationVerdict {
#[serde(rename = "APPROVED")]
Approved,
#[serde(rename = "NOT_APPROVED")]
NotApproved,
#[serde(rename = "BLOCKED")]
Blocked,
}
impl VerificationVerdict {
pub fn as_str(&self) -> &'static str {
match self {
Self::Approved => "APPROVED",
Self::NotApproved => "NOT_APPROVED",
Self::Blocked => "BLOCKED",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"APPROVED" => Some(Self::Approved),
"NOT_APPROVED" => Some(Self::NotApproved),
"BLOCKED" => Some(Self::Blocked),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TaskAnalysisStatus {
Pass,
Warning,
Fail,
}
impl TaskAnalysisStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Warning => "warning",
Self::Fail => "fail",
}
}
fn from_str(value: &str) -> Option<Self> {
match value {
"pass" => Some(Self::Pass),
"warning" => Some(Self::Warning),
"fail" => Some(Self::Fail),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskInvestCheckSummary {
pub status: TaskAnalysisStatus,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskInvestValidationChecks {
pub independent: TaskInvestCheckSummary,
pub negotiable: TaskInvestCheckSummary,
pub valuable: TaskInvestCheckSummary,
pub estimable: TaskInvestCheckSummary,
pub small: TaskInvestCheckSummary,
pub testable: TaskInvestCheckSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskInvestValidation {
pub source: String,
pub overall_status: TaskAnalysisStatus,
pub checks: TaskInvestValidationChecks,
#[serde(default)]
pub issues: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskStoryReadinessChecks {
pub scope: bool,
pub acceptance_criteria: bool,
pub verification_commands: bool,
pub test_cases: bool,
pub verification_plan: bool,
pub dependencies_declared: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskStoryReadiness {
pub ready: bool,
#[serde(default)]
pub missing: Vec<String>,
#[serde(default)]
pub required_task_fields: Vec<String>,
pub checks: TaskStoryReadinessChecks,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskArtifactSummary {
pub total: usize,
#[serde(default)]
pub by_type: BTreeMap<String, usize>,
pub required_satisfied: bool,
#[serde(default)]
pub missing_required: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskVerificationSummary {
pub has_verdict: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub verdict: Option<String>,
pub has_report: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskCompletionSummary {
pub has_summary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskRunSummary {
pub total: usize,
pub latest_status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskEvidenceSummary {
pub artifact: TaskArtifactSummary,
pub verification: TaskVerificationSummary,
pub completion: TaskCompletionSummary,
pub runs: TaskRunSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Task {
pub id: String,
pub title: String,
pub objective: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub acceptance_criteria: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification_commands: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_cases: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<String>,
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub board_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_id: Option<String>,
#[serde(default)]
pub position: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<TaskPriority>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_specialist_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_specialist_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_number: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_synced_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_sync_error: Option<String>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_group: Option<String>,
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creation_source: Option<TaskCreationSource>,
#[serde(default)]
pub codebase_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_id: Option<String>,
#[serde(default)]
pub session_ids: Vec<String>,
#[serde(default)]
pub lane_sessions: Vec<TaskLaneSession>,
#[serde(default)]
pub lane_handoffs: Vec<TaskLaneHandoff>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification_verdict: Option<VerificationVerdict>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification_report: Option<String>,
}
impl Task {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
title: String,
objective: String,
workspace_id: String,
session_id: Option<String>,
scope: Option<String>,
acceptance_criteria: Option<Vec<String>>,
verification_commands: Option<Vec<String>>,
test_cases: Option<Vec<String>>,
dependencies: Option<Vec<String>>,
parallel_group: Option<String>,
) -> Self {
let now = Utc::now();
let creation_source = session_id.as_ref().map(|_| TaskCreationSource::Session);
Self {
id,
title,
objective,
comment: None,
scope,
acceptance_criteria,
verification_commands,
test_cases,
assigned_to: None,
status: TaskStatus::Pending,
board_id: None,
column_id: Some("backlog".to_string()),
position: 0,
priority: None,
labels: Vec::new(),
assignee: None,
assigned_provider: None,
assigned_role: None,
assigned_specialist_id: None,
assigned_specialist_name: None,
trigger_session_id: None,
github_id: None,
github_number: None,
github_url: None,
github_repo: None,
github_state: None,
github_synced_at: None,
last_sync_error: None,
dependencies: dependencies.unwrap_or_default(),
parallel_group,
workspace_id,
session_id,
creation_source,
codebase_ids: Vec::new(),
worktree_id: None,
session_ids: Vec::new(),
lane_sessions: Vec::new(),
lane_handoffs: Vec::new(),
created_at: now,
updated_at: now,
completion_summary: None,
verification_verdict: None,
verification_report: None,
}
}
}
#[derive(Debug, Deserialize)]
struct CanonicalStoryEnvelope {
story: CanonicalStoryDocument,
}
#[derive(Debug, Deserialize)]
struct CanonicalStoryDocument {
invest: Option<CanonicalStoryInvest>,
dependencies_and_sequencing: Option<CanonicalStoryDependencies>,
}
#[derive(Debug, Deserialize)]
struct CanonicalStoryInvest {
independent: Option<CanonicalStoryInvestCheck>,
negotiable: Option<CanonicalStoryInvestCheck>,
valuable: Option<CanonicalStoryInvestCheck>,
estimable: Option<CanonicalStoryInvestCheck>,
small: Option<CanonicalStoryInvestCheck>,
testable: Option<CanonicalStoryInvestCheck>,
}
#[derive(Debug, Deserialize)]
struct CanonicalStoryInvestCheck {
status: Option<String>,
reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CanonicalStoryDependencies {
#[serde(rename = "depends_on")]
_depends_on: Option<Vec<String>>,
unblock_condition: Option<String>,
}
fn normalize_text(value: Option<&str>) -> String {
value.unwrap_or_default().trim().to_string()
}
fn normalize_items(values: Option<&Vec<String>>) -> Vec<String> {
values
.cloned()
.unwrap_or_default()
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}
fn summarize_statuses(statuses: &[TaskAnalysisStatus]) -> TaskAnalysisStatus {
if statuses.contains(&TaskAnalysisStatus::Fail) {
TaskAnalysisStatus::Fail
} else if statuses.contains(&TaskAnalysisStatus::Warning) {
TaskAnalysisStatus::Warning
} else {
TaskAnalysisStatus::Pass
}
}
fn extract_canonical_story_yaml(content: &str) -> Option<String> {
let start = content.find("```yaml")?;
let remainder = &content[start + "```yaml".len()..];
let end = remainder.find("```")?;
Some(remainder[..end].trim().to_string())
}
fn parse_canonical_story(content: &str) -> Result<Option<CanonicalStoryEnvelope>, String> {
let Some(raw_yaml) = extract_canonical_story_yaml(content) else {
return Ok(None);
};
serde_yaml::from_str::<CanonicalStoryEnvelope>(&raw_yaml)
.map(Some)
.map_err(|error| format!("Failed to parse canonical story YAML: {error}"))
}
fn contains_dependency_signal(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
[
"depends on",
"blocked by",
"dependency plan",
"execution order",
"ready now",
"no dependencies",
]
.iter()
.any(|needle| lower.contains(needle))
}
pub fn build_task_invest_validation(task: &Task) -> TaskInvestValidation {
let mut issues = Vec::new();
if let Ok(Some(canonical_story)) = parse_canonical_story(&task.objective) {
if let Some(invest) = canonical_story.story.invest {
let build_check =
|check: Option<CanonicalStoryInvestCheck>| -> Option<TaskInvestCheckSummary> {
let check = check?;
Some(TaskInvestCheckSummary {
status: TaskAnalysisStatus::from_str(
check.status.as_deref().unwrap_or_default(),
)?,
reason: normalize_text(check.reason.as_deref()),
})
};
if let (
Some(independent),
Some(negotiable),
Some(valuable),
Some(estimable),
Some(small),
Some(testable),
) = (
build_check(invest.independent),
build_check(invest.negotiable),
build_check(invest.valuable),
build_check(invest.estimable),
build_check(invest.small),
build_check(invest.testable),
) {
let checks = TaskInvestValidationChecks {
independent,
negotiable,
valuable,
estimable,
small,
testable,
};
let statuses = [
checks.independent.status.clone(),
checks.negotiable.status.clone(),
checks.valuable.status.clone(),
checks.estimable.status.clone(),
checks.small.status.clone(),
checks.testable.status.clone(),
];
return TaskInvestValidation {
source: "canonical_story".to_string(),
overall_status: summarize_statuses(&statuses),
checks,
issues,
};
}
issues.push("Canonical story YAML is missing one or more INVEST checks.".to_string());
}
} else if let Err(error) = parse_canonical_story(&task.objective) {
issues.push(error);
}
let scope = normalize_text(task.scope.as_deref());
let objective = normalize_text(Some(task.objective.as_str()));
let comment = normalize_text(task.comment.as_deref());
let acceptance_criteria = normalize_items(task.acceptance_criteria.as_ref());
let verification_commands = normalize_items(task.verification_commands.as_ref());
let test_cases = normalize_items(task.test_cases.as_ref());
let dependencies = normalize_items(Some(&task.dependencies));
let dependency_narrative = format!("{objective}\n{comment}");
let declares_dependencies =
!dependencies.is_empty() || contains_dependency_signal(&dependency_narrative);
let has_verification_plan = !verification_commands.is_empty() || !test_cases.is_empty();
let checks = TaskInvestValidationChecks {
independent: if !dependencies.is_empty() {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Fail,
reason: format!(
"Depends on {} and should likely be split or explicitly sequenced.",
dependencies.join(", ")
),
}
} else {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Pass,
reason: if declares_dependencies {
"Dependency declaration is present and does not list blocking prerequisites."
.to_string()
} else {
"No blocking prerequisite was detected.".to_string()
},
}
},
negotiable: TaskInvestCheckSummary {
status: TaskAnalysisStatus::Warning,
reason:
"Negotiability is a human judgment call when no canonical story contract is present."
.to_string(),
},
valuable: if objective.len() >= 24 {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Pass,
reason: "Objective contains enough detail to express user or delivery value."
.to_string(),
}
} else {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Fail,
reason: "Objective is too thin to explain why this story matters.".to_string(),
}
},
estimable: if !scope.is_empty() && !acceptance_criteria.is_empty() {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Pass,
reason: "Scope and acceptance criteria provide enough context to estimate work."
.to_string(),
}
} else if !scope.is_empty() || !acceptance_criteria.is_empty() {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Warning,
reason:
"Some sizing context exists, but either scope or acceptance criteria is still missing."
.to_string(),
}
} else {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Fail,
reason: "Missing scope and acceptance criteria leaves the story hard to estimate."
.to_string(),
}
},
small: if acceptance_criteria.len() >= 6 || dependencies.len() >= 2 {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Warning,
reason:
"The story may be too broad because it carries many acceptance criteria or dependencies."
.to_string(),
}
} else {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Pass,
reason: "The story looks narrow enough for a single implementation pass."
.to_string(),
}
},
testable: if acceptance_criteria.len() >= 2 || has_verification_plan {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Pass,
reason:
"Acceptance criteria or an explicit verification plan makes the outcome testable."
.to_string(),
}
} else if acceptance_criteria.len() == 1 {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Warning,
reason: "A single acceptance criterion exists, but verification is still thin."
.to_string(),
}
} else {
TaskInvestCheckSummary {
status: TaskAnalysisStatus::Fail,
reason: "No acceptance criteria or verification plan was provided.".to_string(),
}
},
};
let statuses = [
checks.independent.status.clone(),
checks.negotiable.status.clone(),
checks.valuable.status.clone(),
checks.estimable.status.clone(),
checks.small.status.clone(),
checks.testable.status.clone(),
];
TaskInvestValidation {
source: "heuristic".to_string(),
overall_status: summarize_statuses(&statuses),
checks,
issues,
}
}
pub fn build_task_story_readiness_checks(task: &Task) -> TaskStoryReadinessChecks {
let canonical_dependencies = parse_canonical_story(&task.objective)
.ok()
.flatten()
.and_then(|story| story.story.dependencies_and_sequencing)
.is_some_and(|dependencies| {
!normalize_text(dependencies.unblock_condition.as_deref()).is_empty()
});
let objective = format!(
"{}\n{}",
normalize_text(Some(task.objective.as_str())),
normalize_text(task.comment.as_deref())
);
let scope = normalize_text(task.scope.as_deref());
let acceptance_criteria = normalize_items(task.acceptance_criteria.as_ref());
let verification_commands = normalize_items(task.verification_commands.as_ref());
let test_cases = normalize_items(task.test_cases.as_ref());
TaskStoryReadinessChecks {
scope: !scope.is_empty(),
acceptance_criteria: !acceptance_criteria.is_empty(),
verification_commands: !verification_commands.is_empty(),
test_cases: !test_cases.is_empty(),
verification_plan: !verification_commands.is_empty() || !test_cases.is_empty(),
dependencies_declared: canonical_dependencies
|| !task.dependencies.is_empty()
|| !normalize_text(task.parallel_group.as_deref()).is_empty()
|| contains_dependency_signal(&objective),
}
}
pub fn build_task_story_readiness(
task: &Task,
required_task_fields: &[String],
) -> TaskStoryReadiness {
let checks = build_task_story_readiness_checks(task);
let missing = required_task_fields
.iter()
.filter(|field| match field.as_str() {
"scope" => !checks.scope,
"acceptance_criteria" => !checks.acceptance_criteria,
"verification_commands" => !checks.verification_commands,
"test_cases" => !checks.test_cases,
"verification_plan" => !checks.verification_plan,
"dependencies_declared" => !checks.dependencies_declared,
_ => false,
})
.cloned()
.collect::<Vec<_>>();
TaskStoryReadiness {
ready: missing.is_empty(),
missing,
required_task_fields: required_task_fields.to_vec(),
checks,
}
}
pub fn build_task_evidence_summary(
task: &Task,
artifacts: &[Artifact],
required_artifacts: &[String],
) -> TaskEvidenceSummary {
let mut by_type = BTreeMap::new();
for artifact in artifacts {
let key = artifact.artifact_type.as_str().to_string();
*by_type.entry(key).or_insert(0) += 1;
}
let missing_required = required_artifacts
.iter()
.filter(|artifact| !by_type.contains_key(*artifact))
.cloned()
.collect::<Vec<_>>();
let latest_status = task
.lane_sessions
.last()
.map(|session| task_lane_session_status_as_str(&session.status).to_string())
.unwrap_or_else(|| {
if task.session_ids.is_empty() {
"idle".to_string()
} else {
"unknown".to_string()
}
});
TaskEvidenceSummary {
artifact: TaskArtifactSummary {
total: artifacts.len(),
by_type,
required_satisfied: missing_required.is_empty(),
missing_required,
},
verification: TaskVerificationSummary {
has_verdict: task.verification_verdict.is_some(),
verdict: task
.verification_verdict
.as_ref()
.map(|verdict| verdict.as_str().to_string()),
has_report: task
.verification_report
.as_ref()
.is_some_and(|report| !report.trim().is_empty()),
},
completion: TaskCompletionSummary {
has_summary: task
.completion_summary
.as_ref()
.is_some_and(|summary| !summary.trim().is_empty()),
},
runs: TaskRunSummary {
total: task.session_ids.len(),
latest_status,
},
}
}
pub fn task_lane_session_status_as_str(status: &TaskLaneSessionStatus) -> &'static str {
match status {
TaskLaneSessionStatus::Running => "running",
TaskLaneSessionStatus::Completed => "completed",
TaskLaneSessionStatus::Failed => "failed",
TaskLaneSessionStatus::TimedOut => "timed_out",
TaskLaneSessionStatus::Transitioned => "transitioned",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_task_with_objective(objective: &str) -> Task {
Task::new(
"task-1".to_string(),
"Test task".to_string(),
objective.to_string(),
"workspace-1".to_string(),
None,
None,
None,
None,
None,
None,
None,
)
}
#[test]
fn canonical_story_dependencies_declared_accepts_empty_depends_on() {
let task = build_task_with_objective(
r#"```yaml
story:
version: 1
language: en
title: Example
problem_statement: Why this matters
user_value: Value delivered
acceptance_criteria:
- id: AC1
text: First criterion
testable: true
- id: AC2
text: Second criterion
testable: true
constraints_and_affected_areas:
- src/example.ts
dependencies_and_sequencing:
independent_story_check: pass
depends_on: []
unblock_condition: Ready to start now.
out_of_scope:
- None
invest:
independent:
status: pass
reason: why
negotiable:
status: pass
reason: why
valuable:
status: pass
reason: why
estimable:
status: pass
reason: why
small:
status: pass
reason: why
testable:
status: pass
reason: why
```"#,
);
let checks = build_task_story_readiness_checks(&task);
assert!(checks.dependencies_declared);
}
#[test]
fn canonical_story_dependencies_declared_accepts_missing_depends_on() {
let task = build_task_with_objective(
r#"```yaml
story:
version: 1
language: en
title: Example
problem_statement: Why this matters
user_value: Value delivered
acceptance_criteria:
- id: AC1
text: First criterion
testable: true
- id: AC2
text: Second criterion
testable: true
constraints_and_affected_areas:
- src/example.ts
dependencies_and_sequencing:
independent_story_check: pass
unblock_condition: Ready to start now.
out_of_scope:
- None
invest:
independent:
status: pass
reason: why
negotiable:
status: pass
reason: why
valuable:
status: pass
reason: why
estimable:
status: pass
reason: why
small:
status: pass
reason: why
testable:
status: pass
reason: why
```"#,
);
let checks = build_task_story_readiness_checks(&task);
assert!(checks.dependencies_declared);
}
}