1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5use super::artifact::Artifact;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "lowercase")]
10pub enum TaskSessionTransport {
11 Acp,
13 A2a,
15}
16
17#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "snake_case")]
31pub enum TaskLaneSessionLoopMode {
32 WatchdogRetry,
33 RalphLoop,
34}
35
36#[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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
81 pub transport: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub external_task_id: Option<String>,
85 #[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#[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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
441 pub session_id: Option<String>,
442 #[serde(default)]
444 pub codebase_ids: Vec<String>,
445 #[serde(skip_serializing_if = "Option::is_none")]
447 pub worktree_id: Option<String>,
448 #[serde(default)]
450 pub session_ids: Vec<String>,
451 #[serde(default)]
453 pub lane_sessions: Vec<TaskLaneSession>,
454 #[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}