Skip to main content

routa_core/models/
task.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5use super::artifact::Artifact;
6
7/// Transport protocol for task sessions
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "lowercase")]
10pub enum TaskSessionTransport {
11    /// Agent Chat Protocol
12    Acp,
13    /// Agent-to-Agent protocol
14    A2a,
15}
16
17/// Status of a task lane session
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum TaskLaneSessionStatus {
21    Running,
22    Completed,
23    Failed,
24    TimedOut,
25    Transitioned,
26}
27
28/// Loop mode for task lane session recovery
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "snake_case")]
31pub enum TaskLaneSessionLoopMode {
32    WatchdogRetry,
33    RalphLoop,
34}
35
36/// Completion requirement for task lane session
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum TaskLaneSessionCompletionRequirement {
40    TurnComplete,
41    CompletionSummary,
42    VerificationReport,
43}
44
45/// Recovery reason for task lane session
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "snake_case")]
48pub enum TaskLaneSessionRecoveryReason {
49    WatchdogInactivity,
50    AgentFailed,
51    CompletionCriteriaNotMet,
52}
53
54/// Session associated with a task lane transition
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "camelCase")]
57pub struct TaskLaneSession {
58    pub session_id: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub routa_agent_id: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub column_id: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub column_name: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub step_id: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub step_index: Option<i64>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub step_name: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub provider: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub role: Option<String>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub specialist_id: Option<String>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub specialist_name: Option<String>,
79    /// Transport protocol used for this session
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub transport: Option<String>,
82    /// A2A-specific: External task ID from the agent system
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub external_task_id: Option<String>,
85    /// A2A-specific: Context ID for tracking the conversation
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub context_id: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub attempt: Option<i64>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub loop_mode: Option<TaskLaneSessionLoopMode>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub completion_requirement: Option<TaskLaneSessionCompletionRequirement>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub objective: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub last_activity_at: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub recovered_from_session_id: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub recovery_reason: Option<TaskLaneSessionRecoveryReason>,
102    pub status: TaskLaneSessionStatus,
103    pub started_at: String,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub completed_at: Option<String>,
106}
107
108/// Handoff request type for task lane transitions
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(rename_all = "snake_case")]
111pub enum TaskLaneHandoffRequestType {
112    EnvironmentPreparation,
113    RuntimeContext,
114    Clarification,
115    RerunCommand,
116}
117
118/// Handoff status for task lane transitions
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "snake_case")]
121pub enum TaskLaneHandoffStatus {
122    Requested,
123    Delivered,
124    Completed,
125    Blocked,
126    Failed,
127}
128
129/// Handoff between adjacent lane sessions
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub struct TaskLaneHandoff {
133    pub id: String,
134    pub from_session_id: String,
135    pub to_session_id: String,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub from_column_id: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub to_column_id: Option<String>,
140    pub request_type: TaskLaneHandoffRequestType,
141    pub request: String,
142    pub status: TaskLaneHandoffStatus,
143    pub requested_at: String,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub responded_at: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub response_summary: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151pub enum TaskPriority {
152    #[serde(rename = "low")]
153    Low,
154    #[serde(rename = "medium")]
155    Medium,
156    #[serde(rename = "high")]
157    High,
158    #[serde(rename = "urgent")]
159    Urgent,
160}
161
162impl TaskPriority {
163    pub fn as_str(&self) -> &'static str {
164        match self {
165            Self::Low => "low",
166            Self::Medium => "medium",
167            Self::High => "high",
168            Self::Urgent => "urgent",
169        }
170    }
171
172    #[allow(clippy::should_implement_trait)]
173    pub fn from_str(s: &str) -> Option<Self> {
174        match s {
175            "low" => Some(Self::Low),
176            "medium" => Some(Self::Medium),
177            "high" => Some(Self::High),
178            "urgent" => Some(Self::Urgent),
179            _ => None,
180        }
181    }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub enum TaskStatus {
186    #[serde(rename = "PENDING")]
187    Pending,
188    #[serde(rename = "IN_PROGRESS")]
189    InProgress,
190    #[serde(rename = "REVIEW_REQUIRED")]
191    ReviewRequired,
192    #[serde(rename = "COMPLETED")]
193    Completed,
194    #[serde(rename = "NEEDS_FIX")]
195    NeedsFix,
196    #[serde(rename = "BLOCKED")]
197    Blocked,
198    #[serde(rename = "CANCELLED")]
199    Cancelled,
200}
201
202impl TaskStatus {
203    pub fn as_str(&self) -> &'static str {
204        match self {
205            Self::Pending => "PENDING",
206            Self::InProgress => "IN_PROGRESS",
207            Self::ReviewRequired => "REVIEW_REQUIRED",
208            Self::Completed => "COMPLETED",
209            Self::NeedsFix => "NEEDS_FIX",
210            Self::Blocked => "BLOCKED",
211            Self::Cancelled => "CANCELLED",
212        }
213    }
214
215    #[allow(clippy::should_implement_trait)]
216    pub fn from_str(s: &str) -> Option<Self> {
217        match s {
218            "PENDING" => Some(Self::Pending),
219            "IN_PROGRESS" => Some(Self::InProgress),
220            "REVIEW_REQUIRED" => Some(Self::ReviewRequired),
221            "COMPLETED" => Some(Self::Completed),
222            "NEEDS_FIX" => Some(Self::NeedsFix),
223            "BLOCKED" => Some(Self::Blocked),
224            "CANCELLED" => Some(Self::Cancelled),
225            _ => None,
226        }
227    }
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231pub enum VerificationVerdict {
232    #[serde(rename = "APPROVED")]
233    Approved,
234    #[serde(rename = "NOT_APPROVED")]
235    NotApproved,
236    #[serde(rename = "BLOCKED")]
237    Blocked,
238}
239
240impl VerificationVerdict {
241    pub fn as_str(&self) -> &'static str {
242        match self {
243            Self::Approved => "APPROVED",
244            Self::NotApproved => "NOT_APPROVED",
245            Self::Blocked => "BLOCKED",
246        }
247    }
248
249    #[allow(clippy::should_implement_trait)]
250    pub fn from_str(s: &str) -> Option<Self> {
251        match s {
252            "APPROVED" => Some(Self::Approved),
253            "NOT_APPROVED" => Some(Self::NotApproved),
254            "BLOCKED" => Some(Self::Blocked),
255            _ => None,
256        }
257    }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
261#[serde(rename_all = "lowercase")]
262pub enum TaskAnalysisStatus {
263    Pass,
264    Warning,
265    Fail,
266}
267
268impl TaskAnalysisStatus {
269    pub fn as_str(&self) -> &'static str {
270        match self {
271            Self::Pass => "pass",
272            Self::Warning => "warning",
273            Self::Fail => "fail",
274        }
275    }
276
277    fn from_str(value: &str) -> Option<Self> {
278        match value {
279            "pass" => Some(Self::Pass),
280            "warning" => Some(Self::Warning),
281            "fail" => Some(Self::Fail),
282            _ => None,
283        }
284    }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
288#[serde(rename_all = "camelCase")]
289pub struct TaskInvestCheckSummary {
290    pub status: TaskAnalysisStatus,
291    pub reason: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(rename_all = "camelCase")]
296pub struct TaskInvestValidationChecks {
297    pub independent: TaskInvestCheckSummary,
298    pub negotiable: TaskInvestCheckSummary,
299    pub valuable: TaskInvestCheckSummary,
300    pub estimable: TaskInvestCheckSummary,
301    pub small: TaskInvestCheckSummary,
302    pub testable: TaskInvestCheckSummary,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
306#[serde(rename_all = "camelCase")]
307pub struct TaskInvestValidation {
308    pub source: String,
309    pub overall_status: TaskAnalysisStatus,
310    pub checks: TaskInvestValidationChecks,
311    #[serde(default)]
312    pub issues: Vec<String>,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
316#[serde(rename_all = "camelCase")]
317pub struct TaskStoryReadinessChecks {
318    pub scope: bool,
319    pub acceptance_criteria: bool,
320    pub verification_commands: bool,
321    pub test_cases: bool,
322    pub verification_plan: bool,
323    pub dependencies_declared: bool,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
327#[serde(rename_all = "camelCase")]
328pub struct TaskStoryReadiness {
329    pub ready: bool,
330    #[serde(default)]
331    pub missing: Vec<String>,
332    #[serde(default)]
333    pub required_task_fields: Vec<String>,
334    pub checks: TaskStoryReadinessChecks,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[serde(rename_all = "camelCase")]
339pub struct TaskArtifactSummary {
340    pub total: usize,
341    #[serde(default)]
342    pub by_type: BTreeMap<String, usize>,
343    pub required_satisfied: bool,
344    #[serde(default)]
345    pub missing_required: Vec<String>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
349#[serde(rename_all = "camelCase")]
350pub struct TaskVerificationSummary {
351    pub has_verdict: bool,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub verdict: Option<String>,
354    pub has_report: bool,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "camelCase")]
359pub struct TaskCompletionSummary {
360    pub has_summary: bool,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
364#[serde(rename_all = "camelCase")]
365pub struct TaskRunSummary {
366    pub total: usize,
367    pub latest_status: String,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
371#[serde(rename_all = "camelCase")]
372pub struct TaskEvidenceSummary {
373    pub artifact: TaskArtifactSummary,
374    pub verification: TaskVerificationSummary,
375    pub completion: TaskCompletionSummary,
376    pub runs: TaskRunSummary,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380#[serde(rename_all = "camelCase")]
381pub struct Task {
382    pub id: String,
383    pub title: String,
384    pub objective: String,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub comment: Option<String>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub scope: Option<String>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub acceptance_criteria: Option<Vec<String>>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub verification_commands: Option<Vec<String>>,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub test_cases: Option<Vec<String>>,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub assigned_to: Option<String>,
397    pub status: TaskStatus,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub board_id: Option<String>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub column_id: Option<String>,
402    #[serde(default)]
403    pub position: i64,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub priority: Option<TaskPriority>,
406    #[serde(default)]
407    pub labels: Vec<String>,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub assignee: Option<String>,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub assigned_provider: Option<String>,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub assigned_role: Option<String>,
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub assigned_specialist_id: Option<String>,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub assigned_specialist_name: Option<String>,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub trigger_session_id: Option<String>,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub github_id: Option<String>,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub github_number: Option<i64>,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub github_url: Option<String>,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub github_repo: Option<String>,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub github_state: Option<String>,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub github_synced_at: Option<DateTime<Utc>>,
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub last_sync_error: Option<String>,
434    #[serde(default)]
435    pub dependencies: Vec<String>,
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub parallel_group: Option<String>,
438    pub workspace_id: String,
439    /// Session ID that created this task (for session-scoped filtering)
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub session_id: Option<String>,
442    /// Codebase IDs linked to this task
443    #[serde(default)]
444    pub codebase_ids: Vec<String>,
445    /// Worktree ID assigned to this task
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub worktree_id: Option<String>,
448    /// All session IDs that have been associated with this task (history)
449    #[serde(default)]
450    pub session_ids: Vec<String>,
451    /// Durable per-lane session history for Kanban workflow handoff
452    #[serde(default)]
453    pub lane_sessions: Vec<TaskLaneSession>,
454    /// Adjacent-lane handoff requests and responses
455    #[serde(default)]
456    pub lane_handoffs: Vec<TaskLaneHandoff>,
457    pub created_at: DateTime<Utc>,
458    pub updated_at: DateTime<Utc>,
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub completion_summary: Option<String>,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub verification_verdict: Option<VerificationVerdict>,
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub verification_report: Option<String>,
465}
466
467impl Task {
468    #[allow(clippy::too_many_arguments)]
469    pub fn new(
470        id: String,
471        title: String,
472        objective: String,
473        workspace_id: String,
474        session_id: Option<String>,
475        scope: Option<String>,
476        acceptance_criteria: Option<Vec<String>>,
477        verification_commands: Option<Vec<String>>,
478        test_cases: Option<Vec<String>>,
479        dependencies: Option<Vec<String>>,
480        parallel_group: Option<String>,
481    ) -> Self {
482        let now = Utc::now();
483        Self {
484            id,
485            title,
486            objective,
487            comment: None,
488            scope,
489            acceptance_criteria,
490            verification_commands,
491            test_cases,
492            assigned_to: None,
493            status: TaskStatus::Pending,
494            board_id: None,
495            column_id: Some("backlog".to_string()),
496            position: 0,
497            priority: None,
498            labels: Vec::new(),
499            assignee: None,
500            assigned_provider: None,
501            assigned_role: None,
502            assigned_specialist_id: None,
503            assigned_specialist_name: None,
504            trigger_session_id: None,
505            github_id: None,
506            github_number: None,
507            github_url: None,
508            github_repo: None,
509            github_state: None,
510            github_synced_at: None,
511            last_sync_error: None,
512            dependencies: dependencies.unwrap_or_default(),
513            parallel_group,
514            workspace_id,
515            session_id,
516            codebase_ids: Vec::new(),
517            worktree_id: None,
518            session_ids: Vec::new(),
519            lane_sessions: Vec::new(),
520            lane_handoffs: Vec::new(),
521            created_at: now,
522            updated_at: now,
523            completion_summary: None,
524            verification_verdict: None,
525            verification_report: None,
526        }
527    }
528}
529
530#[derive(Debug, Deserialize)]
531struct CanonicalStoryEnvelope {
532    story: CanonicalStoryDocument,
533}
534
535#[derive(Debug, Deserialize)]
536struct CanonicalStoryDocument {
537    invest: Option<CanonicalStoryInvest>,
538    dependencies_and_sequencing: Option<CanonicalStoryDependencies>,
539}
540
541#[derive(Debug, Deserialize)]
542struct CanonicalStoryInvest {
543    independent: Option<CanonicalStoryInvestCheck>,
544    negotiable: Option<CanonicalStoryInvestCheck>,
545    valuable: Option<CanonicalStoryInvestCheck>,
546    estimable: Option<CanonicalStoryInvestCheck>,
547    small: Option<CanonicalStoryInvestCheck>,
548    testable: Option<CanonicalStoryInvestCheck>,
549}
550
551#[derive(Debug, Deserialize)]
552struct CanonicalStoryInvestCheck {
553    status: Option<String>,
554    reason: Option<String>,
555}
556
557#[derive(Debug, Deserialize)]
558struct CanonicalStoryDependencies {
559    #[serde(rename = "depends_on")]
560    _depends_on: Option<Vec<String>>,
561    unblock_condition: Option<String>,
562}
563
564fn normalize_text(value: Option<&str>) -> String {
565    value.unwrap_or_default().trim().to_string()
566}
567
568fn normalize_items(values: Option<&Vec<String>>) -> Vec<String> {
569    values
570        .cloned()
571        .unwrap_or_default()
572        .into_iter()
573        .map(|value| value.trim().to_string())
574        .filter(|value| !value.is_empty())
575        .collect()
576}
577
578fn summarize_statuses(statuses: &[TaskAnalysisStatus]) -> TaskAnalysisStatus {
579    if statuses.contains(&TaskAnalysisStatus::Fail) {
580        TaskAnalysisStatus::Fail
581    } else if statuses.contains(&TaskAnalysisStatus::Warning) {
582        TaskAnalysisStatus::Warning
583    } else {
584        TaskAnalysisStatus::Pass
585    }
586}
587
588fn extract_canonical_story_yaml(content: &str) -> Option<String> {
589    let start = content.find("```yaml")?;
590    let remainder = &content[start + "```yaml".len()..];
591    let end = remainder.find("```")?;
592    Some(remainder[..end].trim().to_string())
593}
594
595fn parse_canonical_story(content: &str) -> Result<Option<CanonicalStoryEnvelope>, String> {
596    let Some(raw_yaml) = extract_canonical_story_yaml(content) else {
597        return Ok(None);
598    };
599
600    serde_yaml::from_str::<CanonicalStoryEnvelope>(&raw_yaml)
601        .map(Some)
602        .map_err(|error| format!("Failed to parse canonical story YAML: {error}"))
603}
604
605fn contains_dependency_signal(text: &str) -> bool {
606    let lower = text.to_ascii_lowercase();
607    [
608        "depends on",
609        "blocked by",
610        "dependency plan",
611        "execution order",
612        "ready now",
613        "no dependencies",
614    ]
615    .iter()
616    .any(|needle| lower.contains(needle))
617}
618
619pub fn build_task_invest_validation(task: &Task) -> TaskInvestValidation {
620    let mut issues = Vec::new();
621    if let Ok(Some(canonical_story)) = parse_canonical_story(&task.objective) {
622        if let Some(invest) = canonical_story.story.invest {
623            let build_check =
624                |check: Option<CanonicalStoryInvestCheck>| -> Option<TaskInvestCheckSummary> {
625                    let check = check?;
626                    Some(TaskInvestCheckSummary {
627                        status: TaskAnalysisStatus::from_str(
628                            check.status.as_deref().unwrap_or_default(),
629                        )?,
630                        reason: normalize_text(check.reason.as_deref()),
631                    })
632                };
633
634            if let (
635                Some(independent),
636                Some(negotiable),
637                Some(valuable),
638                Some(estimable),
639                Some(small),
640                Some(testable),
641            ) = (
642                build_check(invest.independent),
643                build_check(invest.negotiable),
644                build_check(invest.valuable),
645                build_check(invest.estimable),
646                build_check(invest.small),
647                build_check(invest.testable),
648            ) {
649                let checks = TaskInvestValidationChecks {
650                    independent,
651                    negotiable,
652                    valuable,
653                    estimable,
654                    small,
655                    testable,
656                };
657                let statuses = [
658                    checks.independent.status.clone(),
659                    checks.negotiable.status.clone(),
660                    checks.valuable.status.clone(),
661                    checks.estimable.status.clone(),
662                    checks.small.status.clone(),
663                    checks.testable.status.clone(),
664                ];
665                return TaskInvestValidation {
666                    source: "canonical_story".to_string(),
667                    overall_status: summarize_statuses(&statuses),
668                    checks,
669                    issues,
670                };
671            }
672
673            issues.push("Canonical story YAML is missing one or more INVEST checks.".to_string());
674        }
675    } else if let Err(error) = parse_canonical_story(&task.objective) {
676        issues.push(error);
677    }
678
679    let scope = normalize_text(task.scope.as_deref());
680    let objective = normalize_text(Some(task.objective.as_str()));
681    let comment = normalize_text(task.comment.as_deref());
682    let acceptance_criteria = normalize_items(task.acceptance_criteria.as_ref());
683    let verification_commands = normalize_items(task.verification_commands.as_ref());
684    let test_cases = normalize_items(task.test_cases.as_ref());
685    let dependencies = normalize_items(Some(&task.dependencies));
686    let dependency_narrative = format!("{objective}\n{comment}");
687    let declares_dependencies =
688        !dependencies.is_empty() || contains_dependency_signal(&dependency_narrative);
689    let has_verification_plan = !verification_commands.is_empty() || !test_cases.is_empty();
690
691    let checks = TaskInvestValidationChecks {
692        independent: if !dependencies.is_empty() {
693            TaskInvestCheckSummary {
694                status: TaskAnalysisStatus::Fail,
695                reason: format!(
696                    "Depends on {} and should likely be split or explicitly sequenced.",
697                    dependencies.join(", ")
698                ),
699            }
700        } else {
701            TaskInvestCheckSummary {
702                status: TaskAnalysisStatus::Pass,
703                reason: if declares_dependencies {
704                    "Dependency declaration is present and does not list blocking prerequisites."
705                        .to_string()
706                } else {
707                    "No blocking prerequisite was detected.".to_string()
708                },
709            }
710        },
711        negotiable: TaskInvestCheckSummary {
712            status: TaskAnalysisStatus::Warning,
713            reason:
714                "Negotiability is a human judgment call when no canonical story contract is present."
715                    .to_string(),
716        },
717        valuable: if objective.len() >= 24 {
718            TaskInvestCheckSummary {
719                status: TaskAnalysisStatus::Pass,
720                reason: "Objective contains enough detail to express user or delivery value."
721                    .to_string(),
722            }
723        } else {
724            TaskInvestCheckSummary {
725                status: TaskAnalysisStatus::Fail,
726                reason: "Objective is too thin to explain why this story matters.".to_string(),
727            }
728        },
729        estimable: if !scope.is_empty() && !acceptance_criteria.is_empty() {
730            TaskInvestCheckSummary {
731                status: TaskAnalysisStatus::Pass,
732                reason: "Scope and acceptance criteria provide enough context to estimate work."
733                    .to_string(),
734            }
735        } else if !scope.is_empty() || !acceptance_criteria.is_empty() {
736            TaskInvestCheckSummary {
737                status: TaskAnalysisStatus::Warning,
738                reason:
739                    "Some sizing context exists, but either scope or acceptance criteria is still missing."
740                        .to_string(),
741            }
742        } else {
743            TaskInvestCheckSummary {
744                status: TaskAnalysisStatus::Fail,
745                reason: "Missing scope and acceptance criteria leaves the story hard to estimate."
746                    .to_string(),
747            }
748        },
749        small: if acceptance_criteria.len() >= 6 || dependencies.len() >= 2 {
750            TaskInvestCheckSummary {
751                status: TaskAnalysisStatus::Warning,
752                reason:
753                    "The story may be too broad because it carries many acceptance criteria or dependencies."
754                        .to_string(),
755            }
756        } else {
757            TaskInvestCheckSummary {
758                status: TaskAnalysisStatus::Pass,
759                reason: "The story looks narrow enough for a single implementation pass."
760                    .to_string(),
761            }
762        },
763        testable: if acceptance_criteria.len() >= 2 || has_verification_plan {
764            TaskInvestCheckSummary {
765                status: TaskAnalysisStatus::Pass,
766                reason:
767                    "Acceptance criteria or an explicit verification plan makes the outcome testable."
768                        .to_string(),
769            }
770        } else if acceptance_criteria.len() == 1 {
771            TaskInvestCheckSummary {
772                status: TaskAnalysisStatus::Warning,
773                reason: "A single acceptance criterion exists, but verification is still thin."
774                    .to_string(),
775            }
776        } else {
777            TaskInvestCheckSummary {
778                status: TaskAnalysisStatus::Fail,
779                reason: "No acceptance criteria or verification plan was provided.".to_string(),
780            }
781        },
782    };
783
784    let statuses = [
785        checks.independent.status.clone(),
786        checks.negotiable.status.clone(),
787        checks.valuable.status.clone(),
788        checks.estimable.status.clone(),
789        checks.small.status.clone(),
790        checks.testable.status.clone(),
791    ];
792
793    TaskInvestValidation {
794        source: "heuristic".to_string(),
795        overall_status: summarize_statuses(&statuses),
796        checks,
797        issues,
798    }
799}
800
801pub fn build_task_story_readiness_checks(task: &Task) -> TaskStoryReadinessChecks {
802    let canonical_dependencies = parse_canonical_story(&task.objective)
803        .ok()
804        .flatten()
805        .and_then(|story| story.story.dependencies_and_sequencing)
806        .is_some_and(|dependencies| {
807            !normalize_text(dependencies.unblock_condition.as_deref()).is_empty()
808        });
809    let objective = format!(
810        "{}\n{}",
811        normalize_text(Some(task.objective.as_str())),
812        normalize_text(task.comment.as_deref())
813    );
814    let scope = normalize_text(task.scope.as_deref());
815    let acceptance_criteria = normalize_items(task.acceptance_criteria.as_ref());
816    let verification_commands = normalize_items(task.verification_commands.as_ref());
817    let test_cases = normalize_items(task.test_cases.as_ref());
818
819    TaskStoryReadinessChecks {
820        scope: !scope.is_empty(),
821        acceptance_criteria: !acceptance_criteria.is_empty(),
822        verification_commands: !verification_commands.is_empty(),
823        test_cases: !test_cases.is_empty(),
824        verification_plan: !verification_commands.is_empty() || !test_cases.is_empty(),
825        dependencies_declared: canonical_dependencies
826            || !task.dependencies.is_empty()
827            || !normalize_text(task.parallel_group.as_deref()).is_empty()
828            || contains_dependency_signal(&objective),
829    }
830}
831
832pub fn build_task_story_readiness(
833    task: &Task,
834    required_task_fields: &[String],
835) -> TaskStoryReadiness {
836    let checks = build_task_story_readiness_checks(task);
837    let missing = required_task_fields
838        .iter()
839        .filter(|field| match field.as_str() {
840            "scope" => !checks.scope,
841            "acceptance_criteria" => !checks.acceptance_criteria,
842            "verification_commands" => !checks.verification_commands,
843            "test_cases" => !checks.test_cases,
844            "verification_plan" => !checks.verification_plan,
845            "dependencies_declared" => !checks.dependencies_declared,
846            _ => false,
847        })
848        .cloned()
849        .collect::<Vec<_>>();
850
851    TaskStoryReadiness {
852        ready: missing.is_empty(),
853        missing,
854        required_task_fields: required_task_fields.to_vec(),
855        checks,
856    }
857}
858
859pub fn build_task_evidence_summary(
860    task: &Task,
861    artifacts: &[Artifact],
862    required_artifacts: &[String],
863) -> TaskEvidenceSummary {
864    let mut by_type = BTreeMap::new();
865    for artifact in artifacts {
866        let key = artifact.artifact_type.as_str().to_string();
867        *by_type.entry(key).or_insert(0) += 1;
868    }
869
870    let missing_required = required_artifacts
871        .iter()
872        .filter(|artifact| !by_type.contains_key(*artifact))
873        .cloned()
874        .collect::<Vec<_>>();
875    let latest_status = task
876        .lane_sessions
877        .last()
878        .map(|session| task_lane_session_status_as_str(&session.status).to_string())
879        .unwrap_or_else(|| {
880            if task.session_ids.is_empty() {
881                "idle".to_string()
882            } else {
883                "unknown".to_string()
884            }
885        });
886
887    TaskEvidenceSummary {
888        artifact: TaskArtifactSummary {
889            total: artifacts.len(),
890            by_type,
891            required_satisfied: missing_required.is_empty(),
892            missing_required,
893        },
894        verification: TaskVerificationSummary {
895            has_verdict: task.verification_verdict.is_some(),
896            verdict: task
897                .verification_verdict
898                .as_ref()
899                .map(|verdict| verdict.as_str().to_string()),
900            has_report: task
901                .verification_report
902                .as_ref()
903                .is_some_and(|report| !report.trim().is_empty()),
904        },
905        completion: TaskCompletionSummary {
906            has_summary: task
907                .completion_summary
908                .as_ref()
909                .is_some_and(|summary| !summary.trim().is_empty()),
910        },
911        runs: TaskRunSummary {
912            total: task.session_ids.len(),
913            latest_status,
914        },
915    }
916}
917
918pub fn task_lane_session_status_as_str(status: &TaskLaneSessionStatus) -> &'static str {
919    match status {
920        TaskLaneSessionStatus::Running => "running",
921        TaskLaneSessionStatus::Completed => "completed",
922        TaskLaneSessionStatus::Failed => "failed",
923        TaskLaneSessionStatus::TimedOut => "timed_out",
924        TaskLaneSessionStatus::Transitioned => "transitioned",
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    fn build_task_with_objective(objective: &str) -> Task {
933        Task::new(
934            "task-1".to_string(),
935            "Test task".to_string(),
936            objective.to_string(),
937            "workspace-1".to_string(),
938            None,
939            None,
940            None,
941            None,
942            None,
943            None,
944            None,
945        )
946    }
947
948    #[test]
949    fn canonical_story_dependencies_declared_accepts_empty_depends_on() {
950        let task = build_task_with_objective(
951            r#"```yaml
952story:
953  version: 1
954  language: en
955  title: Example
956  problem_statement: Why this matters
957  user_value: Value delivered
958  acceptance_criteria:
959    - id: AC1
960      text: First criterion
961      testable: true
962    - id: AC2
963      text: Second criterion
964      testable: true
965  constraints_and_affected_areas:
966    - src/example.ts
967  dependencies_and_sequencing:
968    independent_story_check: pass
969    depends_on: []
970    unblock_condition: Ready to start now.
971  out_of_scope:
972    - None
973  invest:
974    independent:
975      status: pass
976      reason: why
977    negotiable:
978      status: pass
979      reason: why
980    valuable:
981      status: pass
982      reason: why
983    estimable:
984      status: pass
985      reason: why
986    small:
987      status: pass
988      reason: why
989    testable:
990      status: pass
991      reason: why
992```"#,
993        );
994
995        let checks = build_task_story_readiness_checks(&task);
996
997        assert!(checks.dependencies_declared);
998    }
999
1000    #[test]
1001    fn canonical_story_dependencies_declared_accepts_missing_depends_on() {
1002        let task = build_task_with_objective(
1003            r#"```yaml
1004story:
1005  version: 1
1006  language: en
1007  title: Example
1008  problem_statement: Why this matters
1009  user_value: Value delivered
1010  acceptance_criteria:
1011    - id: AC1
1012      text: First criterion
1013      testable: true
1014    - id: AC2
1015      text: Second criterion
1016      testable: true
1017  constraints_and_affected_areas:
1018    - src/example.ts
1019  dependencies_and_sequencing:
1020    independent_story_check: pass
1021    unblock_condition: Ready to start now.
1022  out_of_scope:
1023    - None
1024  invest:
1025    independent:
1026      status: pass
1027      reason: why
1028    negotiable:
1029      status: pass
1030      reason: why
1031    valuable:
1032      status: pass
1033      reason: why
1034    estimable:
1035      status: pass
1036      reason: why
1037    small:
1038      status: pass
1039      reason: why
1040    testable:
1041      status: pass
1042      reason: why
1043```"#,
1044        );
1045
1046        let checks = build_task_story_readiness_checks(&task);
1047
1048        assert!(checks.dependencies_declared);
1049    }
1050}