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)]
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 #[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 #[serde(default)]
477 pub codebase_ids: Vec<String>,
478 #[serde(skip_serializing_if = "Option::is_none")]
480 pub worktree_id: Option<String>,
481 #[serde(default)]
483 pub session_ids: Vec<String>,
484 #[serde(default)]
486 pub lane_sessions: Vec<TaskLaneSession>,
487 #[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}