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)]
231#[serde(rename_all = "lowercase")]
232pub enum TaskCreationSource {
233    Manual,
234    Agent,
235    Api,
236    Session,
237}
238
239impl TaskCreationSource {
240    pub fn as_str(&self) -> &'static str {
241        match self {
242            Self::Manual => "manual",
243            Self::Agent => "agent",
244            Self::Api => "api",
245            Self::Session => "session",
246        }
247    }
248
249    #[allow(clippy::should_implement_trait)]
250    pub fn from_str(s: &str) -> Option<Self> {
251        match s {
252            "manual" => Some(Self::Manual),
253            "agent" => Some(Self::Agent),
254            "api" => Some(Self::Api),
255            "session" => Some(Self::Session),
256            _ => None,
257        }
258    }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262pub enum VerificationVerdict {
263    #[serde(rename = "APPROVED")]
264    Approved,
265    #[serde(rename = "NOT_APPROVED")]
266    NotApproved,
267    #[serde(rename = "BLOCKED")]
268    Blocked,
269}
270
271impl VerificationVerdict {
272    pub fn as_str(&self) -> &'static str {
273        match self {
274            Self::Approved => "APPROVED",
275            Self::NotApproved => "NOT_APPROVED",
276            Self::Blocked => "BLOCKED",
277        }
278    }
279
280    #[allow(clippy::should_implement_trait)]
281    pub fn from_str(s: &str) -> Option<Self> {
282        match s {
283            "APPROVED" => Some(Self::Approved),
284            "NOT_APPROVED" => Some(Self::NotApproved),
285            "BLOCKED" => Some(Self::Blocked),
286            _ => None,
287        }
288    }
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292#[serde(rename_all = "lowercase")]
293pub enum TaskAnalysisStatus {
294    Pass,
295    Warning,
296    Fail,
297}
298
299impl TaskAnalysisStatus {
300    pub fn as_str(&self) -> &'static str {
301        match self {
302            Self::Pass => "pass",
303            Self::Warning => "warning",
304            Self::Fail => "fail",
305        }
306    }
307
308    fn from_str(value: &str) -> Option<Self> {
309        match value {
310            "pass" => Some(Self::Pass),
311            "warning" => Some(Self::Warning),
312            "fail" => Some(Self::Fail),
313            _ => None,
314        }
315    }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
319#[serde(rename_all = "camelCase")]
320pub struct TaskInvestCheckSummary {
321    pub status: TaskAnalysisStatus,
322    pub reason: String,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326#[serde(rename_all = "camelCase")]
327pub struct TaskInvestValidationChecks {
328    pub independent: TaskInvestCheckSummary,
329    pub negotiable: TaskInvestCheckSummary,
330    pub valuable: TaskInvestCheckSummary,
331    pub estimable: TaskInvestCheckSummary,
332    pub small: TaskInvestCheckSummary,
333    pub testable: TaskInvestCheckSummary,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
337#[serde(rename_all = "camelCase")]
338pub struct TaskInvestValidation {
339    pub source: String,
340    pub overall_status: TaskAnalysisStatus,
341    pub checks: TaskInvestValidationChecks,
342    #[serde(default)]
343    pub issues: Vec<String>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
347#[serde(rename_all = "camelCase")]
348pub struct TaskStoryReadinessChecks {
349    pub scope: bool,
350    pub acceptance_criteria: bool,
351    pub verification_commands: bool,
352    pub test_cases: bool,
353    pub verification_plan: bool,
354    pub dependencies_declared: bool,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "camelCase")]
359pub struct TaskStoryReadiness {
360    pub ready: bool,
361    #[serde(default)]
362    pub missing: Vec<String>,
363    #[serde(default)]
364    pub required_task_fields: Vec<String>,
365    pub checks: TaskStoryReadinessChecks,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369#[serde(rename_all = "camelCase")]
370pub struct TaskArtifactSummary {
371    pub total: usize,
372    #[serde(default)]
373    pub by_type: BTreeMap<String, usize>,
374    pub required_satisfied: bool,
375    #[serde(default)]
376    pub missing_required: Vec<String>,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
380#[serde(rename_all = "camelCase")]
381pub struct TaskVerificationSummary {
382    pub has_verdict: bool,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub verdict: Option<String>,
385    pub has_report: bool,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
389#[serde(rename_all = "camelCase")]
390pub struct TaskCompletionSummary {
391    pub has_summary: bool,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
395#[serde(rename_all = "camelCase")]
396pub struct TaskRunSummary {
397    pub total: usize,
398    pub latest_status: String,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
402#[serde(rename_all = "camelCase")]
403pub struct TaskEvidenceSummary {
404    pub artifact: TaskArtifactSummary,
405    pub verification: TaskVerificationSummary,
406    pub completion: TaskCompletionSummary,
407    pub runs: TaskRunSummary,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct Task {
413    pub id: String,
414    pub title: String,
415    pub objective: String,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub comment: Option<String>,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub scope: Option<String>,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub acceptance_criteria: Option<Vec<String>>,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub verification_commands: Option<Vec<String>>,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub test_cases: Option<Vec<String>>,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub assigned_to: Option<String>,
428    pub status: TaskStatus,
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub board_id: Option<String>,
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub column_id: Option<String>,
433    #[serde(default)]
434    pub position: i64,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub priority: Option<TaskPriority>,
437    #[serde(default)]
438    pub labels: Vec<String>,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub assignee: Option<String>,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub assigned_provider: Option<String>,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub assigned_role: Option<String>,
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub assigned_specialist_id: Option<String>,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub assigned_specialist_name: Option<String>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub trigger_session_id: Option<String>,
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub github_id: Option<String>,
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub github_number: Option<i64>,
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub github_url: Option<String>,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub github_repo: Option<String>,
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub github_state: Option<String>,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub github_synced_at: Option<DateTime<Utc>>,
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub last_sync_error: Option<String>,
465    #[serde(default)]
466    pub dependencies: Vec<String>,
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub parallel_group: Option<String>,
469    pub workspace_id: String,
470    /// Session ID that created this task (for session-scoped filtering)
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub session_id: Option<String>,
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub creation_source: Option<TaskCreationSource>,
475    /// Codebase IDs linked to this task
476    #[serde(default)]
477    pub codebase_ids: Vec<String>,
478    /// Worktree ID assigned to this task
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub worktree_id: Option<String>,
481    /// All session IDs that have been associated with this task (history)
482    #[serde(default)]
483    pub session_ids: Vec<String>,
484    /// Durable per-lane session history for Kanban workflow handoff
485    #[serde(default)]
486    pub lane_sessions: Vec<TaskLaneSession>,
487    /// Adjacent-lane handoff requests and responses
488    #[serde(default)]
489    pub lane_handoffs: Vec<TaskLaneHandoff>,
490    pub created_at: DateTime<Utc>,
491    pub updated_at: DateTime<Utc>,
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub completion_summary: Option<String>,
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub verification_verdict: Option<VerificationVerdict>,
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub verification_report: Option<String>,
498}
499
500impl Task {
501    #[allow(clippy::too_many_arguments)]
502    pub fn new(
503        id: String,
504        title: String,
505        objective: String,
506        workspace_id: String,
507        session_id: Option<String>,
508        scope: Option<String>,
509        acceptance_criteria: Option<Vec<String>>,
510        verification_commands: Option<Vec<String>>,
511        test_cases: Option<Vec<String>>,
512        dependencies: Option<Vec<String>>,
513        parallel_group: Option<String>,
514    ) -> Self {
515        let now = Utc::now();
516        let creation_source = session_id.as_ref().map(|_| TaskCreationSource::Session);
517        Self {
518            id,
519            title,
520            objective,
521            comment: None,
522            scope,
523            acceptance_criteria,
524            verification_commands,
525            test_cases,
526            assigned_to: None,
527            status: TaskStatus::Pending,
528            board_id: None,
529            column_id: Some("backlog".to_string()),
530            position: 0,
531            priority: None,
532            labels: Vec::new(),
533            assignee: None,
534            assigned_provider: None,
535            assigned_role: None,
536            assigned_specialist_id: None,
537            assigned_specialist_name: None,
538            trigger_session_id: None,
539            github_id: None,
540            github_number: None,
541            github_url: None,
542            github_repo: None,
543            github_state: None,
544            github_synced_at: None,
545            last_sync_error: None,
546            dependencies: dependencies.unwrap_or_default(),
547            parallel_group,
548            workspace_id,
549            session_id,
550            creation_source,
551            codebase_ids: Vec::new(),
552            worktree_id: None,
553            session_ids: Vec::new(),
554            lane_sessions: Vec::new(),
555            lane_handoffs: Vec::new(),
556            created_at: now,
557            updated_at: now,
558            completion_summary: None,
559            verification_verdict: None,
560            verification_report: None,
561        }
562    }
563}
564
565#[derive(Debug, Deserialize)]
566struct CanonicalStoryEnvelope {
567    story: CanonicalStoryDocument,
568}
569
570#[derive(Debug, Deserialize)]
571struct CanonicalStoryDocument {
572    invest: Option<CanonicalStoryInvest>,
573    dependencies_and_sequencing: Option<CanonicalStoryDependencies>,
574}
575
576#[derive(Debug, Deserialize)]
577struct CanonicalStoryInvest {
578    independent: Option<CanonicalStoryInvestCheck>,
579    negotiable: Option<CanonicalStoryInvestCheck>,
580    valuable: Option<CanonicalStoryInvestCheck>,
581    estimable: Option<CanonicalStoryInvestCheck>,
582    small: Option<CanonicalStoryInvestCheck>,
583    testable: Option<CanonicalStoryInvestCheck>,
584}
585
586#[derive(Debug, Deserialize)]
587struct CanonicalStoryInvestCheck {
588    status: Option<String>,
589    reason: Option<String>,
590}
591
592#[derive(Debug, Deserialize)]
593struct CanonicalStoryDependencies {
594    #[serde(rename = "depends_on")]
595    _depends_on: Option<Vec<String>>,
596    unblock_condition: Option<String>,
597}
598
599fn normalize_text(value: Option<&str>) -> String {
600    value.unwrap_or_default().trim().to_string()
601}
602
603fn normalize_items(values: Option<&Vec<String>>) -> Vec<String> {
604    values
605        .cloned()
606        .unwrap_or_default()
607        .into_iter()
608        .map(|value| value.trim().to_string())
609        .filter(|value| !value.is_empty())
610        .collect()
611}
612
613fn summarize_statuses(statuses: &[TaskAnalysisStatus]) -> TaskAnalysisStatus {
614    if statuses.contains(&TaskAnalysisStatus::Fail) {
615        TaskAnalysisStatus::Fail
616    } else if statuses.contains(&TaskAnalysisStatus::Warning) {
617        TaskAnalysisStatus::Warning
618    } else {
619        TaskAnalysisStatus::Pass
620    }
621}
622
623fn extract_canonical_story_yaml(content: &str) -> Option<String> {
624    let start = content.find("```yaml")?;
625    let remainder = &content[start + "```yaml".len()..];
626    let end = remainder.find("```")?;
627    Some(remainder[..end].trim().to_string())
628}
629
630fn parse_canonical_story(content: &str) -> Result<Option<CanonicalStoryEnvelope>, String> {
631    let Some(raw_yaml) = extract_canonical_story_yaml(content) else {
632        return Ok(None);
633    };
634
635    serde_yaml::from_str::<CanonicalStoryEnvelope>(&raw_yaml)
636        .map(Some)
637        .map_err(|error| format!("Failed to parse canonical story YAML: {error}"))
638}
639
640fn contains_dependency_signal(text: &str) -> bool {
641    let lower = text.to_ascii_lowercase();
642    [
643        "depends on",
644        "blocked by",
645        "dependency plan",
646        "execution order",
647        "ready now",
648        "no dependencies",
649    ]
650    .iter()
651    .any(|needle| lower.contains(needle))
652}
653
654pub fn build_task_invest_validation(task: &Task) -> TaskInvestValidation {
655    let mut issues = Vec::new();
656    if let Ok(Some(canonical_story)) = parse_canonical_story(&task.objective) {
657        if let Some(invest) = canonical_story.story.invest {
658            let build_check =
659                |check: Option<CanonicalStoryInvestCheck>| -> Option<TaskInvestCheckSummary> {
660                    let check = check?;
661                    Some(TaskInvestCheckSummary {
662                        status: TaskAnalysisStatus::from_str(
663                            check.status.as_deref().unwrap_or_default(),
664                        )?,
665                        reason: normalize_text(check.reason.as_deref()),
666                    })
667                };
668
669            if let (
670                Some(independent),
671                Some(negotiable),
672                Some(valuable),
673                Some(estimable),
674                Some(small),
675                Some(testable),
676            ) = (
677                build_check(invest.independent),
678                build_check(invest.negotiable),
679                build_check(invest.valuable),
680                build_check(invest.estimable),
681                build_check(invest.small),
682                build_check(invest.testable),
683            ) {
684                let checks = TaskInvestValidationChecks {
685                    independent,
686                    negotiable,
687                    valuable,
688                    estimable,
689                    small,
690                    testable,
691                };
692                let statuses = [
693                    checks.independent.status.clone(),
694                    checks.negotiable.status.clone(),
695                    checks.valuable.status.clone(),
696                    checks.estimable.status.clone(),
697                    checks.small.status.clone(),
698                    checks.testable.status.clone(),
699                ];
700                return TaskInvestValidation {
701                    source: "canonical_story".to_string(),
702                    overall_status: summarize_statuses(&statuses),
703                    checks,
704                    issues,
705                };
706            }
707
708            issues.push("Canonical story YAML is missing one or more INVEST checks.".to_string());
709        }
710    } else if let Err(error) = parse_canonical_story(&task.objective) {
711        issues.push(error);
712    }
713
714    let scope = normalize_text(task.scope.as_deref());
715    let objective = normalize_text(Some(task.objective.as_str()));
716    let comment = normalize_text(task.comment.as_deref());
717    let acceptance_criteria = normalize_items(task.acceptance_criteria.as_ref());
718    let verification_commands = normalize_items(task.verification_commands.as_ref());
719    let test_cases = normalize_items(task.test_cases.as_ref());
720    let dependencies = normalize_items(Some(&task.dependencies));
721    let dependency_narrative = format!("{objective}\n{comment}");
722    let declares_dependencies =
723        !dependencies.is_empty() || contains_dependency_signal(&dependency_narrative);
724    let has_verification_plan = !verification_commands.is_empty() || !test_cases.is_empty();
725
726    let checks = TaskInvestValidationChecks {
727        independent: if !dependencies.is_empty() {
728            TaskInvestCheckSummary {
729                status: TaskAnalysisStatus::Fail,
730                reason: format!(
731                    "Depends on {} and should likely be split or explicitly sequenced.",
732                    dependencies.join(", ")
733                ),
734            }
735        } else {
736            TaskInvestCheckSummary {
737                status: TaskAnalysisStatus::Pass,
738                reason: if declares_dependencies {
739                    "Dependency declaration is present and does not list blocking prerequisites."
740                        .to_string()
741                } else {
742                    "No blocking prerequisite was detected.".to_string()
743                },
744            }
745        },
746        negotiable: TaskInvestCheckSummary {
747            status: TaskAnalysisStatus::Warning,
748            reason:
749                "Negotiability is a human judgment call when no canonical story contract is present."
750                    .to_string(),
751        },
752        valuable: if objective.len() >= 24 {
753            TaskInvestCheckSummary {
754                status: TaskAnalysisStatus::Pass,
755                reason: "Objective contains enough detail to express user or delivery value."
756                    .to_string(),
757            }
758        } else {
759            TaskInvestCheckSummary {
760                status: TaskAnalysisStatus::Fail,
761                reason: "Objective is too thin to explain why this story matters.".to_string(),
762            }
763        },
764        estimable: if !scope.is_empty() && !acceptance_criteria.is_empty() {
765            TaskInvestCheckSummary {
766                status: TaskAnalysisStatus::Pass,
767                reason: "Scope and acceptance criteria provide enough context to estimate work."
768                    .to_string(),
769            }
770        } else if !scope.is_empty() || !acceptance_criteria.is_empty() {
771            TaskInvestCheckSummary {
772                status: TaskAnalysisStatus::Warning,
773                reason:
774                    "Some sizing context exists, but either scope or acceptance criteria is still missing."
775                        .to_string(),
776            }
777        } else {
778            TaskInvestCheckSummary {
779                status: TaskAnalysisStatus::Fail,
780                reason: "Missing scope and acceptance criteria leaves the story hard to estimate."
781                    .to_string(),
782            }
783        },
784        small: if acceptance_criteria.len() >= 6 || dependencies.len() >= 2 {
785            TaskInvestCheckSummary {
786                status: TaskAnalysisStatus::Warning,
787                reason:
788                    "The story may be too broad because it carries many acceptance criteria or dependencies."
789                        .to_string(),
790            }
791        } else {
792            TaskInvestCheckSummary {
793                status: TaskAnalysisStatus::Pass,
794                reason: "The story looks narrow enough for a single implementation pass."
795                    .to_string(),
796            }
797        },
798        testable: if acceptance_criteria.len() >= 2 || has_verification_plan {
799            TaskInvestCheckSummary {
800                status: TaskAnalysisStatus::Pass,
801                reason:
802                    "Acceptance criteria or an explicit verification plan makes the outcome testable."
803                        .to_string(),
804            }
805        } else if acceptance_criteria.len() == 1 {
806            TaskInvestCheckSummary {
807                status: TaskAnalysisStatus::Warning,
808                reason: "A single acceptance criterion exists, but verification is still thin."
809                    .to_string(),
810            }
811        } else {
812            TaskInvestCheckSummary {
813                status: TaskAnalysisStatus::Fail,
814                reason: "No acceptance criteria or verification plan was provided.".to_string(),
815            }
816        },
817    };
818
819    let statuses = [
820        checks.independent.status.clone(),
821        checks.negotiable.status.clone(),
822        checks.valuable.status.clone(),
823        checks.estimable.status.clone(),
824        checks.small.status.clone(),
825        checks.testable.status.clone(),
826    ];
827
828    TaskInvestValidation {
829        source: "heuristic".to_string(),
830        overall_status: summarize_statuses(&statuses),
831        checks,
832        issues,
833    }
834}
835
836pub fn build_task_story_readiness_checks(task: &Task) -> TaskStoryReadinessChecks {
837    let canonical_dependencies = parse_canonical_story(&task.objective)
838        .ok()
839        .flatten()
840        .and_then(|story| story.story.dependencies_and_sequencing)
841        .is_some_and(|dependencies| {
842            !normalize_text(dependencies.unblock_condition.as_deref()).is_empty()
843        });
844    let objective = format!(
845        "{}\n{}",
846        normalize_text(Some(task.objective.as_str())),
847        normalize_text(task.comment.as_deref())
848    );
849    let scope = normalize_text(task.scope.as_deref());
850    let acceptance_criteria = normalize_items(task.acceptance_criteria.as_ref());
851    let verification_commands = normalize_items(task.verification_commands.as_ref());
852    let test_cases = normalize_items(task.test_cases.as_ref());
853
854    TaskStoryReadinessChecks {
855        scope: !scope.is_empty(),
856        acceptance_criteria: !acceptance_criteria.is_empty(),
857        verification_commands: !verification_commands.is_empty(),
858        test_cases: !test_cases.is_empty(),
859        verification_plan: !verification_commands.is_empty() || !test_cases.is_empty(),
860        dependencies_declared: canonical_dependencies
861            || !task.dependencies.is_empty()
862            || !normalize_text(task.parallel_group.as_deref()).is_empty()
863            || contains_dependency_signal(&objective),
864    }
865}
866
867pub fn build_task_story_readiness(
868    task: &Task,
869    required_task_fields: &[String],
870) -> TaskStoryReadiness {
871    let checks = build_task_story_readiness_checks(task);
872    let missing = required_task_fields
873        .iter()
874        .filter(|field| match field.as_str() {
875            "scope" => !checks.scope,
876            "acceptance_criteria" => !checks.acceptance_criteria,
877            "verification_commands" => !checks.verification_commands,
878            "test_cases" => !checks.test_cases,
879            "verification_plan" => !checks.verification_plan,
880            "dependencies_declared" => !checks.dependencies_declared,
881            _ => false,
882        })
883        .cloned()
884        .collect::<Vec<_>>();
885
886    TaskStoryReadiness {
887        ready: missing.is_empty(),
888        missing,
889        required_task_fields: required_task_fields.to_vec(),
890        checks,
891    }
892}
893
894pub fn build_task_evidence_summary(
895    task: &Task,
896    artifacts: &[Artifact],
897    required_artifacts: &[String],
898) -> TaskEvidenceSummary {
899    let mut by_type = BTreeMap::new();
900    for artifact in artifacts {
901        let key = artifact.artifact_type.as_str().to_string();
902        *by_type.entry(key).or_insert(0) += 1;
903    }
904
905    let missing_required = required_artifacts
906        .iter()
907        .filter(|artifact| !by_type.contains_key(*artifact))
908        .cloned()
909        .collect::<Vec<_>>();
910    let latest_status = task
911        .lane_sessions
912        .last()
913        .map(|session| task_lane_session_status_as_str(&session.status).to_string())
914        .unwrap_or_else(|| {
915            if task.session_ids.is_empty() {
916                "idle".to_string()
917            } else {
918                "unknown".to_string()
919            }
920        });
921
922    TaskEvidenceSummary {
923        artifact: TaskArtifactSummary {
924            total: artifacts.len(),
925            by_type,
926            required_satisfied: missing_required.is_empty(),
927            missing_required,
928        },
929        verification: TaskVerificationSummary {
930            has_verdict: task.verification_verdict.is_some(),
931            verdict: task
932                .verification_verdict
933                .as_ref()
934                .map(|verdict| verdict.as_str().to_string()),
935            has_report: task
936                .verification_report
937                .as_ref()
938                .is_some_and(|report| !report.trim().is_empty()),
939        },
940        completion: TaskCompletionSummary {
941            has_summary: task
942                .completion_summary
943                .as_ref()
944                .is_some_and(|summary| !summary.trim().is_empty()),
945        },
946        runs: TaskRunSummary {
947            total: task.session_ids.len(),
948            latest_status,
949        },
950    }
951}
952
953pub fn task_lane_session_status_as_str(status: &TaskLaneSessionStatus) -> &'static str {
954    match status {
955        TaskLaneSessionStatus::Running => "running",
956        TaskLaneSessionStatus::Completed => "completed",
957        TaskLaneSessionStatus::Failed => "failed",
958        TaskLaneSessionStatus::TimedOut => "timed_out",
959        TaskLaneSessionStatus::Transitioned => "transitioned",
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    fn build_task_with_objective(objective: &str) -> Task {
968        Task::new(
969            "task-1".to_string(),
970            "Test task".to_string(),
971            objective.to_string(),
972            "workspace-1".to_string(),
973            None,
974            None,
975            None,
976            None,
977            None,
978            None,
979            None,
980        )
981    }
982
983    #[test]
984    fn canonical_story_dependencies_declared_accepts_empty_depends_on() {
985        let task = build_task_with_objective(
986            r#"```yaml
987story:
988  version: 1
989  language: en
990  title: Example
991  problem_statement: Why this matters
992  user_value: Value delivered
993  acceptance_criteria:
994    - id: AC1
995      text: First criterion
996      testable: true
997    - id: AC2
998      text: Second criterion
999      testable: true
1000  constraints_and_affected_areas:
1001    - src/example.ts
1002  dependencies_and_sequencing:
1003    independent_story_check: pass
1004    depends_on: []
1005    unblock_condition: Ready to start now.
1006  out_of_scope:
1007    - None
1008  invest:
1009    independent:
1010      status: pass
1011      reason: why
1012    negotiable:
1013      status: pass
1014      reason: why
1015    valuable:
1016      status: pass
1017      reason: why
1018    estimable:
1019      status: pass
1020      reason: why
1021    small:
1022      status: pass
1023      reason: why
1024    testable:
1025      status: pass
1026      reason: why
1027```"#,
1028        );
1029
1030        let checks = build_task_story_readiness_checks(&task);
1031
1032        assert!(checks.dependencies_declared);
1033    }
1034
1035    #[test]
1036    fn canonical_story_dependencies_declared_accepts_missing_depends_on() {
1037        let task = build_task_with_objective(
1038            r#"```yaml
1039story:
1040  version: 1
1041  language: en
1042  title: Example
1043  problem_statement: Why this matters
1044  user_value: Value delivered
1045  acceptance_criteria:
1046    - id: AC1
1047      text: First criterion
1048      testable: true
1049    - id: AC2
1050      text: Second criterion
1051      testable: true
1052  constraints_and_affected_areas:
1053    - src/example.ts
1054  dependencies_and_sequencing:
1055    independent_story_check: pass
1056    unblock_condition: Ready to start now.
1057  out_of_scope:
1058    - None
1059  invest:
1060    independent:
1061      status: pass
1062      reason: why
1063    negotiable:
1064      status: pass
1065      reason: why
1066    valuable:
1067      status: pass
1068      reason: why
1069    estimable:
1070      status: pass
1071      reason: why
1072    small:
1073      status: pass
1074      reason: why
1075    testable:
1076      status: pass
1077      reason: why
1078```"#,
1079        );
1080
1081        let checks = build_task_story_readiness_checks(&task);
1082
1083        assert!(checks.dependencies_declared);
1084    }
1085}