Skip to main content

batty_cli/team/
task_cmd.rs

1use std::collections::{BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use chrono::{DateTime, Duration as ChronoDuration, Utc};
6use serde_yaml::{Mapping, Value};
7
8use crate::task::Task;
9
10use super::board::{read_workflow_metadata, write_workflow_metadata};
11use super::workflow::{ReviewDisposition, TaskState, can_transition};
12
13pub fn cmd_transition(board_dir: &Path, task_id: u32, target: &str) -> Result<()> {
14    transition_task(board_dir, task_id, target)?;
15    println!("Task #{task_id} transitioned to {}.", target.trim());
16    Ok(())
17}
18
19pub(crate) fn transition_task(board_dir: &Path, task_id: u32, target: &str) -> Result<()> {
20    let task_path = find_task_path(board_dir, task_id)?;
21    let task = Task::from_file(&task_path)?;
22    let current = parse_task_state(&task.status)?;
23    let target = parse_task_state(target)?;
24
25    can_transition(current, target).map_err(anyhow::Error::msg)?;
26
27    update_task_frontmatter(&task_path, |mapping| {
28        set_status(mapping, target);
29        if target != TaskState::Blocked {
30            clear_blocked(mapping);
31        }
32    })?;
33    Ok(())
34}
35
36pub(crate) fn block_task_with_reason(board_dir: &Path, task_id: u32, reason: &str) -> Result<()> {
37    let task_path = find_task_path(board_dir, task_id)?;
38    update_task_frontmatter(&task_path, |mapping| {
39        set_status(mapping, TaskState::Blocked);
40        set_blocked_reason(mapping, Some(reason), Some(reason));
41    })?;
42    Ok(())
43}
44
45pub fn cmd_assign(
46    board_dir: &Path,
47    task_id: u32,
48    exec_owner: Option<&str>,
49    review_owner: Option<&str>,
50) -> Result<()> {
51    if exec_owner.is_none() && review_owner.is_none() {
52        bail!("at least one owner must be provided");
53    }
54
55    assign_task_owners(board_dir, task_id, exec_owner, review_owner)?;
56
57    println!("Task #{task_id} ownership updated.");
58    Ok(())
59}
60
61pub(crate) fn assign_task_owners(
62    board_dir: &Path,
63    task_id: u32,
64    exec_owner: Option<&str>,
65    review_owner: Option<&str>,
66) -> Result<()> {
67    if exec_owner.is_none() && review_owner.is_none() {
68        bail!("at least one owner must be provided");
69    }
70
71    let task_path = find_task_path(board_dir, task_id)?;
72    update_task_frontmatter(&task_path, |mapping| {
73        if let Some(owner) = exec_owner {
74            set_optional_string(mapping, "claimed_by", normalize_optional(owner));
75            if normalize_optional(owner).is_some() {
76                let now = Utc::now();
77                set_optional_string(mapping, "claimed_at", Some(&now.to_rfc3339()));
78            }
79        }
80        if let Some(owner) = review_owner {
81            set_optional_string(mapping, "review_owner", normalize_optional(owner));
82        }
83    })?;
84    Ok(())
85}
86
87/// Remove the claimed_by and review_owner fields from a task.
88pub(crate) fn unclaim_task(board_dir: &Path, task_id: u32) -> Result<()> {
89    let task_path = find_task_path(board_dir, task_id)?;
90    update_task_frontmatter(&task_path, |mapping| {
91        set_optional_string(mapping, "claimed_by", None);
92        set_optional_string(mapping, "review_owner", None);
93        set_optional_string(mapping, "claimed_at", None);
94        set_optional_u64(mapping, "claim_ttl_secs", None);
95        set_optional_string(mapping, "claim_expires_at", None);
96        set_optional_string(mapping, "last_progress_at", None);
97        set_optional_string(mapping, "claim_warning_sent_at", None);
98        set_optional_u32(mapping, "claim_extensions", None);
99        set_optional_u64(mapping, "last_output_bytes", None);
100    })?;
101    Ok(())
102}
103
104/// Release engineer ownership while preserving downstream review/block metadata.
105pub(crate) fn release_engineer_claim(board_dir: &Path, task_id: u32) -> Result<()> {
106    let task_path = find_task_path(board_dir, task_id)?;
107    update_task_frontmatter(&task_path, |mapping| {
108        set_optional_string(mapping, "claimed_by", None);
109        set_optional_string(mapping, "claimed_at", None);
110        set_optional_u64(mapping, "claim_ttl_secs", None);
111        set_optional_string(mapping, "claim_expires_at", None);
112        set_optional_string(mapping, "last_progress_at", None);
113        set_optional_string(mapping, "claim_warning_sent_at", None);
114        set_optional_u32(mapping, "claim_extensions", None);
115        set_optional_u64(mapping, "last_output_bytes", None);
116    })?;
117    Ok(())
118}
119
120pub(crate) fn initialize_task_claim(
121    board_dir: &Path,
122    task_id: u32,
123    ttl_secs: u64,
124    now: DateTime<Utc>,
125    output_bytes: u64,
126) -> Result<()> {
127    let task_path = find_task_path(board_dir, task_id)?;
128    let expires_at = now + ChronoDuration::seconds(ttl_secs as i64);
129    update_task_frontmatter(&task_path, |mapping| {
130        set_optional_string(mapping, "claimed_at", Some(&now.to_rfc3339()));
131        set_optional_u64(mapping, "claim_ttl_secs", Some(ttl_secs));
132        set_optional_string(mapping, "claim_expires_at", Some(&expires_at.to_rfc3339()));
133        set_optional_string(mapping, "last_progress_at", Some(&now.to_rfc3339()));
134        set_optional_string(mapping, "claim_warning_sent_at", None);
135        set_optional_u32(mapping, "claim_extensions", Some(0));
136        set_optional_u64(mapping, "last_output_bytes", Some(output_bytes));
137    })?;
138    Ok(())
139}
140
141pub(crate) fn refresh_task_claim_progress(
142    board_dir: &Path,
143    task_id: u32,
144    ttl_secs: u64,
145    now: DateTime<Utc>,
146    output_bytes: u64,
147    extensions: u32,
148) -> Result<()> {
149    let task_path = find_task_path(board_dir, task_id)?;
150    let expires_at = now + ChronoDuration::seconds(ttl_secs as i64);
151    update_task_frontmatter(&task_path, |mapping| {
152        set_optional_u64(mapping, "claim_ttl_secs", Some(ttl_secs));
153        set_optional_string(mapping, "claim_expires_at", Some(&expires_at.to_rfc3339()));
154        set_optional_string(mapping, "last_progress_at", Some(&now.to_rfc3339()));
155        set_optional_string(mapping, "claim_warning_sent_at", None);
156        set_optional_u32(mapping, "claim_extensions", Some(extensions));
157        set_optional_u64(mapping, "last_output_bytes", Some(output_bytes));
158    })?;
159    Ok(())
160}
161
162pub(crate) fn mark_task_claim_warning(
163    board_dir: &Path,
164    task_id: u32,
165    now: DateTime<Utc>,
166) -> Result<()> {
167    let task_path = find_task_path(board_dir, task_id)?;
168    update_task_frontmatter(&task_path, |mapping| {
169        set_optional_string(mapping, "claim_warning_sent_at", Some(&now.to_rfc3339()));
170    })?;
171    Ok(())
172}
173
174pub(crate) fn reclaim_task_claim(board_dir: &Path, task_id: u32, next_action: &str) -> Result<()> {
175    let task_path = find_task_path(board_dir, task_id)?;
176    update_task_frontmatter(&task_path, |mapping| {
177        set_optional_string(mapping, "claimed_by", None);
178        set_optional_string(mapping, "review_owner", None);
179        set_optional_string(mapping, "claimed_at", None);
180        set_optional_u64(mapping, "claim_ttl_secs", None);
181        set_optional_string(mapping, "claim_expires_at", None);
182        set_optional_string(mapping, "last_progress_at", None);
183        set_optional_string(mapping, "claim_warning_sent_at", None);
184        set_optional_u32(mapping, "claim_extensions", None);
185        set_optional_u64(mapping, "last_output_bytes", None);
186        set_optional_string(mapping, "next_action", Some(next_action));
187        set_status(mapping, TaskState::Todo);
188    })?;
189    Ok(())
190}
191
192pub(crate) fn append_task_dependencies(
193    board_dir: &Path,
194    task_id: u32,
195    dependency_ids: &[u32],
196) -> Result<Vec<u32>> {
197    let task_path = find_task_path(board_dir, task_id)?;
198    let mut merged = Vec::new();
199    update_task_frontmatter(&task_path, |mapping| {
200        let key = yaml_key("depends_on");
201        let mut deps = BTreeSet::new();
202        if let Some(Value::Sequence(existing)) = mapping.get(&key) {
203            for value in existing {
204                if let Some(dep_id) = value.as_u64() {
205                    deps.insert(dep_id as u32);
206                }
207            }
208        }
209        deps.extend(dependency_ids.iter().copied());
210        merged = deps.iter().copied().collect();
211        if merged.is_empty() {
212            mapping.remove(key);
213        } else {
214            mapping.insert(
215                key,
216                Value::Sequence(
217                    merged
218                        .iter()
219                        .map(|dep_id| Value::Number((*dep_id as u64).into()))
220                        .collect(),
221                ),
222            );
223        }
224    })?;
225    Ok(merged)
226}
227
228pub fn cmd_review(
229    board_dir: &Path,
230    task_id: u32,
231    disposition: &str,
232    feedback: Option<&str>,
233) -> Result<()> {
234    let task_path = find_task_path(board_dir, task_id)?;
235    let task = Task::from_file(&task_path)?;
236    let current = parse_task_state(&task.status)?;
237    let disposition = parse_review_disposition(disposition)?;
238    let target = match disposition {
239        ReviewDisposition::Approved => TaskState::Done,
240        ReviewDisposition::ChangesRequested => TaskState::InProgress,
241        ReviewDisposition::Rejected => TaskState::Archived,
242    };
243
244    can_transition(current, target).map_err(anyhow::Error::msg)?;
245
246    update_task_frontmatter(&task_path, |mapping| {
247        set_status(mapping, target);
248        clear_blocked(mapping);
249        if let Some(text) = feedback {
250            set_optional_string(mapping, "review_feedback", Some(text));
251        }
252    })?;
253
254    let mut metadata = read_workflow_metadata(&task_path)?;
255    metadata.outcome = Some(review_disposition_name(disposition).to_string());
256    if disposition == ReviewDisposition::Approved {
257        metadata.review_blockers.clear();
258    }
259    write_workflow_metadata(&task_path, &metadata)?;
260
261    if disposition == ReviewDisposition::ChangesRequested {
262        if let Some(text) = feedback {
263            // Deliver feedback to the engineer's inbox.
264            // board_dir is <project_root>/.batty/team_config/board
265            if let Some(engineer) = task.claimed_by.as_deref() {
266                if let Some(project_root) = board_dir
267                    .parent() // team_config
268                    .and_then(|p| p.parent()) // .batty
269                    .and_then(|p| p.parent())
270                // project_root
271                {
272                    let inbox_root = super::inbox::inboxes_root(project_root);
273                    if let Ok(()) = queue_review_feedback(&inbox_root, engineer, task_id, text) {
274                        println!("Review feedback delivered to {engineer}'s inbox.");
275                    }
276                }
277            }
278        }
279    }
280
281    println!(
282        "Task #{task_id} review recorded as {}.",
283        review_disposition_name(disposition)
284    );
285    Ok(())
286}
287
288fn queue_review_feedback(
289    inbox_root: &Path,
290    engineer: &str,
291    task_id: u32,
292    feedback: &str,
293) -> Result<()> {
294    use super::inbox;
295    let message = format!("Review feedback for task #{task_id}: {feedback}");
296    let msg = inbox::InboxMessage::new_send("reviewer", engineer, &message);
297    inbox::deliver_to_inbox(inbox_root, &msg)?;
298    Ok(())
299}
300
301/// Structured review: stores review_disposition, review_feedback, reviewed_by,
302/// reviewed_at in task frontmatter and applies the correct state transition.
303///
304/// Disposition mapping:
305///   approve         → Done
306///   request-changes → InProgress (feedback delivered to engineer inbox)
307///   reject          → Blocked (reason stored in blocked_on)
308pub fn cmd_review_structured(
309    board_dir: &Path,
310    task_id: u32,
311    disposition: &str,
312    feedback: Option<&str>,
313    reviewer: &str,
314) -> Result<()> {
315    let task_path = find_task_path(board_dir, task_id)?;
316    let task = Task::from_file(&task_path)?;
317    let current = parse_task_state(&task.status)?;
318
319    let (target_state, disposition_str) = match disposition {
320        "approve" => (TaskState::Done, "approved"),
321        "request-changes" | "request_changes" => (TaskState::InProgress, "changes_requested"),
322        "reject" => (TaskState::Blocked, "rejected"),
323        other => bail!("unknown review disposition: {other}"),
324    };
325
326    can_transition(current, target_state).map_err(anyhow::Error::msg)?;
327
328    let now = chrono::Utc::now().to_rfc3339();
329    let default_reject_reason = format!("rejected by {reviewer}");
330
331    update_task_frontmatter(&task_path, |mapping| {
332        set_status(mapping, target_state);
333        set_optional_string(mapping, "review_disposition", Some(disposition_str));
334        set_optional_string(mapping, "reviewed_by", Some(reviewer));
335        set_optional_string(mapping, "reviewed_at", Some(&now));
336        if let Some(text) = feedback {
337            set_optional_string(mapping, "review_feedback", Some(text));
338        }
339        if target_state == TaskState::Blocked {
340            let reason = feedback.unwrap_or(&default_reject_reason);
341            set_blocked_reason(mapping, Some(reason), Some(reason));
342        } else {
343            clear_blocked(mapping);
344        }
345    })?;
346
347    // Update workflow metadata outcome
348    let mut metadata = read_workflow_metadata(&task_path)?;
349    metadata.outcome = Some(disposition_str.to_string());
350    if disposition == "approve" {
351        metadata.review_blockers.clear();
352    }
353    write_workflow_metadata(&task_path, &metadata)?;
354
355    // Deliver feedback to engineer inbox on request-changes
356    if disposition == "request-changes" || disposition == "request_changes" {
357        if let Some(text) = feedback {
358            if let Some(engineer) = task.claimed_by.as_deref() {
359                if let Some(project_root) = board_dir
360                    .parent()
361                    .and_then(|p| p.parent())
362                    .and_then(|p| p.parent())
363                {
364                    let inbox_root = super::inbox::inboxes_root(project_root);
365                    if let Ok(()) = queue_review_feedback(&inbox_root, engineer, task_id, text) {
366                        println!("Review feedback delivered to {engineer}'s inbox.");
367                    }
368                }
369            }
370        }
371    }
372
373    println!("Task #{task_id} review recorded as {disposition_str} by {reviewer}.");
374    Ok(())
375}
376
377pub fn cmd_update(board_dir: &Path, task_id: u32, fields: HashMap<String, String>) -> Result<()> {
378    if fields.is_empty() {
379        bail!("no workflow fields provided");
380    }
381
382    let task_path = find_task_path(board_dir, task_id)?;
383    let mut metadata = read_workflow_metadata(&task_path)?;
384    let mut metadata_changed = false;
385
386    if let Some(branch) = fields.get("branch") {
387        metadata.branch = normalize_optional(branch).map(str::to_string);
388        metadata_changed = true;
389    }
390    if let Some(commit) = fields.get("commit") {
391        metadata.commit = normalize_optional(commit).map(str::to_string);
392        metadata_changed = true;
393    }
394    if metadata_changed {
395        write_workflow_metadata(&task_path, &metadata)?;
396    }
397
398    let blocked_on = fields.get("blocked_on").cloned();
399    let block_reason = fields.get("block_reason").cloned();
400    let should_clear_blocked = fields.contains_key("clear_blocked");
401    if blocked_on.is_some() || block_reason.is_some() || should_clear_blocked {
402        update_task_frontmatter(&task_path, |mapping| {
403            if should_clear_blocked {
404                clear_blocked(mapping);
405            }
406            let normalized_reason = block_reason
407                .as_deref()
408                .and_then(normalize_optional)
409                .or_else(|| blocked_on.as_deref().and_then(normalize_optional));
410            let normalized_blocked_on = blocked_on.as_deref().and_then(normalize_optional);
411            if normalized_reason.is_some() || normalized_blocked_on.is_some() {
412                set_blocked_reason(mapping, normalized_reason, normalized_blocked_on);
413            }
414        })?;
415    }
416
417    println!("Task #{task_id} metadata updated.");
418    Ok(())
419}
420
421pub fn cmd_schedule(
422    board_dir: &Path,
423    task_id: u32,
424    at: Option<&str>,
425    cron_expr: Option<&str>,
426    clear: bool,
427) -> Result<()> {
428    if !clear && at.is_none() && cron_expr.is_none() {
429        bail!("at least one of --at, --cron, or --clear is required");
430    }
431
432    // Validate --at as RFC3339
433    if let Some(ts) = at {
434        chrono::DateTime::parse_from_rfc3339(ts)
435            .with_context(|| format!("invalid RFC3339 timestamp: {ts}"))?;
436    }
437
438    // Validate --cron expression (the cron crate requires 6-7 fields with seconds;
439    // auto-prepend "0 " for standard 5-field cron expressions)
440    if let Some(expr) = cron_expr {
441        use std::str::FromStr;
442        let normalized = normalize_cron(expr);
443        cron::Schedule::from_str(&normalized)
444            .map_err(|e| anyhow::anyhow!("invalid cron expression: {e}"))?;
445    }
446
447    let task_path = find_task_path(board_dir, task_id)?;
448    update_task_frontmatter(&task_path, |mapping| {
449        if clear {
450            mapping.remove(yaml_key("scheduled_for"));
451            mapping.remove(yaml_key("cron_schedule"));
452        } else {
453            if let Some(ts) = at {
454                mapping.insert(yaml_key("scheduled_for"), Value::String(ts.to_string()));
455            }
456            if let Some(expr) = cron_expr {
457                mapping.insert(yaml_key("cron_schedule"), Value::String(expr.to_string()));
458            }
459        }
460    })?;
461
462    if clear {
463        println!("Task #{task_id} schedule cleared.");
464    } else {
465        let mut parts = Vec::new();
466        if let Some(ts) = at {
467            parts.push(format!("scheduled_for={ts}"));
468        }
469        if let Some(expr) = cron_expr {
470            parts.push(format!("cron_schedule={expr}"));
471        }
472        println!("Task #{task_id} schedule updated: {}", parts.join(", "));
473    }
474    Ok(())
475}
476
477pub fn cmd_auto_merge(task_id: u32, enabled: bool, project_root: &Path) -> Result<()> {
478    super::auto_merge::save_override(project_root, task_id, enabled)?;
479    let action = if enabled { "enabled" } else { "disabled" };
480    println!(
481        "Auto-merge {action} for task #{task_id}. The daemon will pick this up on its next completion evaluation."
482    );
483    Ok(())
484}
485
486pub(crate) fn find_task_path(board_dir: &Path, task_id: u32) -> Result<PathBuf> {
487    crate::task::find_task_path_by_id(&board_dir.join("tasks"), task_id)
488}
489
490fn parse_task_state(value: &str) -> Result<TaskState> {
491    match value.trim().replace('-', "_").as_str() {
492        "backlog" => Ok(TaskState::Backlog),
493        "todo" => Ok(TaskState::Todo),
494        "in_progress" => Ok(TaskState::InProgress),
495        "review" => Ok(TaskState::Review),
496        "blocked" => Ok(TaskState::Blocked),
497        "done" => Ok(TaskState::Done),
498        "archived" => Ok(TaskState::Archived),
499        other => bail!("unknown task state `{other}`"),
500    }
501}
502
503fn parse_review_disposition(value: &str) -> Result<ReviewDisposition> {
504    match value.trim().replace('-', "_").as_str() {
505        "approved" => Ok(ReviewDisposition::Approved),
506        "changes_requested" => Ok(ReviewDisposition::ChangesRequested),
507        "rejected" => Ok(ReviewDisposition::Rejected),
508        other => bail!("unknown review disposition `{other}`"),
509    }
510}
511
512fn state_name(state: TaskState) -> &'static str {
513    match state {
514        TaskState::Backlog => "backlog",
515        TaskState::Todo => "todo",
516        TaskState::InProgress => "in-progress",
517        TaskState::Review => "review",
518        TaskState::Blocked => "blocked",
519        TaskState::Done => "done",
520        TaskState::Archived => "archived",
521    }
522}
523
524fn review_disposition_name(disposition: ReviewDisposition) -> &'static str {
525    match disposition {
526        ReviewDisposition::Approved => "approved",
527        ReviewDisposition::ChangesRequested => "changes_requested",
528        ReviewDisposition::Rejected => "rejected",
529    }
530}
531
532pub(crate) fn update_task_frontmatter<F>(task_path: &Path, mutator: F) -> Result<()>
533where
534    F: FnOnce(&mut Mapping),
535{
536    let content = std::fs::read_to_string(task_path)
537        .with_context(|| format!("failed to read {}", task_path.display()))?;
538    let (frontmatter, body) = split_task_frontmatter(&content)?;
539    let mut mapping: Mapping =
540        serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
541    mutator(&mut mapping);
542
543    let mut rendered =
544        serde_yaml::to_string(&mapping).context("failed to serialize task frontmatter")?;
545    if let Some(stripped) = rendered.strip_prefix("---\n") {
546        rendered = stripped.to_string();
547    }
548
549    let mut updated = String::from("---\n");
550    updated.push_str(&rendered);
551    if !updated.ends_with('\n') {
552        updated.push('\n');
553    }
554    updated.push_str("---\n");
555    updated.push_str(body);
556
557    std::fs::write(task_path, updated)
558        .with_context(|| format!("failed to write {}", task_path.display()))?;
559    Ok(())
560}
561
562fn split_task_frontmatter(content: &str) -> Result<(&str, &str)> {
563    let trimmed = content.trim_start();
564    if !trimmed.starts_with("---") {
565        bail!("task file missing YAML frontmatter (no opening ---)");
566    }
567
568    let after_open = &trimmed[3..];
569    let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
570    let close_pos = after_open
571        .find("\n---")
572        .context("task file missing closing --- for frontmatter")?;
573
574    let frontmatter = &after_open[..close_pos];
575    let body = &after_open[close_pos + 4..];
576    Ok((frontmatter, body.strip_prefix('\n').unwrap_or(body)))
577}
578
579fn set_status(mapping: &mut Mapping, state: TaskState) {
580    mapping.insert(
581        yaml_key("status"),
582        Value::String(state_name(state).to_string()),
583    );
584}
585
586fn clear_blocked(mapping: &mut Mapping) {
587    mapping.remove(yaml_key("blocked"));
588    mapping.remove(yaml_key("block_reason"));
589    mapping.remove(yaml_key("blocked_on"));
590}
591
592fn set_blocked_reason(mapping: &mut Mapping, reason: Option<&str>, blocked_on: Option<&str>) {
593    if reason.is_none() && blocked_on.is_none() {
594        clear_blocked(mapping);
595        return;
596    }
597
598    mapping.insert(yaml_key("blocked"), Value::Bool(true));
599    set_optional_string(mapping, "block_reason", reason);
600    set_optional_string(mapping, "blocked_on", blocked_on.or(reason));
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub(crate) struct TaskFrontmatterRepair {
605    pub(crate) task_id: Option<u32>,
606    pub(crate) status: Option<String>,
607    pub(crate) reason: Option<String>,
608    pub(crate) repaired_fields: Vec<String>,
609    pub(crate) path: PathBuf,
610}
611
612pub(crate) fn normalize_blocked_frontmatter(
613    task_path: &Path,
614) -> Result<Option<TaskFrontmatterRepair>> {
615    let Some(repair) = crate::task::repair_task_frontmatter_compat(task_path)? else {
616        return Ok(None);
617    };
618    let task = Task::from_file(task_path)?;
619    let reason = if repair
620        .repaired_fields
621        .iter()
622        .any(|field| matches!(field.as_str(), "blocked" | "block_reason" | "blocked_on"))
623    {
624        repair
625            .blocked_reason
626            .clone()
627            .or_else(|| Some("normalized blocked frontmatter".to_string()))
628    } else if repair.repaired_fields.is_empty() {
629        None
630    } else {
631        Some(format!(
632            "normalized timestamp fields: {}",
633            repair.repaired_fields.join(", ")
634        ))
635    };
636    Ok(Some(TaskFrontmatterRepair {
637        task_id: Some(task.id),
638        status: Some(task.status),
639        reason,
640        repaired_fields: repair.repaired_fields,
641        path: task_path.to_path_buf(),
642    }))
643}
644
645pub(crate) fn repair_board_frontmatter_compat(
646    board_dir: &Path,
647) -> Result<Vec<TaskFrontmatterRepair>> {
648    let tasks_dir = board_dir.join("tasks");
649    let Ok(entries) = std::fs::read_dir(&tasks_dir) else {
650        return Ok(Vec::new());
651    };
652
653    let mut repairs = Vec::new();
654    for entry in entries.flatten() {
655        let path = entry.path();
656        if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
657            continue;
658        }
659        if let Some(repair) = normalize_blocked_frontmatter(&path)? {
660            repairs.push(repair);
661        }
662    }
663
664    Ok(repairs)
665}
666
667pub(crate) fn set_optional_string(mapping: &mut Mapping, key: &str, value: Option<&str>) {
668    let key = yaml_key(key);
669    match value {
670        Some(value) => {
671            mapping.insert(key, Value::String(value.to_string()));
672        }
673        None => {
674            mapping.remove(key);
675        }
676    }
677}
678
679pub(crate) fn set_optional_u64(mapping: &mut Mapping, key: &str, value: Option<u64>) {
680    let key = yaml_key(key);
681    match value {
682        Some(value) => {
683            mapping.insert(key, Value::Number(value.into()));
684        }
685        None => {
686            mapping.remove(key);
687        }
688    }
689}
690
691pub(crate) fn set_optional_u32(mapping: &mut Mapping, key: &str, value: Option<u32>) {
692    set_optional_u64(mapping, key, value.map(u64::from));
693}
694
695pub(crate) fn yaml_key(name: &str) -> Value {
696    Value::String(name.to_string())
697}
698
699/// Normalize a cron expression for the `cron` crate which requires 6-7 fields
700/// (sec min hour dom month dow [year]). If the user provides a standard 5-field
701/// expression, prepend "0 " to add a seconds field.
702fn normalize_cron(expr: &str) -> String {
703    let fields: Vec<&str> = expr.split_whitespace().collect();
704    if fields.len() == 5 {
705        format!("0 {expr}")
706    } else {
707        expr.to_string()
708    }
709}
710
711fn normalize_optional(value: &str) -> Option<&str> {
712    let trimmed = value.trim();
713    if trimmed.is_empty() {
714        None
715    } else {
716        Some(trimmed)
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn write_task_file(dir: &Path, id: u32, status: &str) -> PathBuf {
725        let tasks_dir = dir.join("tasks");
726        std::fs::create_dir_all(&tasks_dir).unwrap();
727        let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
728        std::fs::write(
729            &path,
730            format!(
731                "---\nid: {id}\ntitle: Task {id}\nstatus: {status}\npriority: high\nclass: standard\n---\n\nTask body.\n"
732            ),
733        )
734        .unwrap();
735        path
736    }
737
738    #[test]
739    fn transition_updates_task_status() {
740        let tmp = tempfile::tempdir().unwrap();
741        let board_dir = tmp.path();
742        let task_path = write_task_file(board_dir, 7, "todo");
743
744        cmd_transition(board_dir, 7, "in-progress").unwrap();
745
746        let task = Task::from_file(&task_path).unwrap();
747        assert_eq!(task.status, "in-progress");
748    }
749
750    #[test]
751    fn illegal_transition_returns_error() {
752        let tmp = tempfile::tempdir().unwrap();
753        let board_dir = tmp.path();
754        write_task_file(board_dir, 8, "backlog");
755
756        let error = cmd_transition(board_dir, 8, "done")
757            .unwrap_err()
758            .to_string();
759        assert!(error.contains("illegal task state transition"));
760    }
761
762    #[test]
763    fn assign_updates_execution_and_review_owners() {
764        let tmp = tempfile::tempdir().unwrap();
765        let board_dir = tmp.path();
766        let task_path = write_task_file(board_dir, 9, "todo");
767
768        cmd_assign(board_dir, 9, Some("eng-1-2"), Some("manager-1")).unwrap();
769
770        let task = Task::from_file(&task_path).unwrap();
771        assert_eq!(task.claimed_by.as_deref(), Some("eng-1-2"));
772        assert_eq!(task.review_owner.as_deref(), Some("manager-1"));
773    }
774
775    #[test]
776    fn review_updates_status_and_outcome() {
777        let tmp = tempfile::tempdir().unwrap();
778        let board_dir = tmp.path();
779        let task_path = write_task_file(board_dir, 10, "review");
780
781        cmd_review(board_dir, 10, "approved", None).unwrap();
782
783        let task = Task::from_file(&task_path).unwrap();
784        assert_eq!(task.status, "done");
785        let metadata = read_workflow_metadata(&task_path).unwrap();
786        assert_eq!(metadata.outcome.as_deref(), Some("approved"));
787    }
788
789    #[test]
790    fn update_writes_board_metadata_and_block_reason() {
791        let tmp = tempfile::tempdir().unwrap();
792        let board_dir = tmp.path();
793        let task_path = write_task_file(board_dir, 11, "blocked");
794
795        let fields = HashMap::from([
796            ("branch".to_string(), "eng-1-2/task-11".to_string()),
797            ("commit".to_string(), "abc1234".to_string()),
798            ("blocked_on".to_string(), "waiting for review".to_string()),
799        ]);
800        cmd_update(board_dir, 11, fields).unwrap();
801
802        let metadata = read_workflow_metadata(&task_path).unwrap();
803        assert_eq!(metadata.branch.as_deref(), Some("eng-1-2/task-11"));
804        assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
805
806        let task = Task::from_file(&task_path).unwrap();
807        assert_eq!(task.blocked.as_deref(), Some("waiting for review"));
808        assert_eq!(task.blocked_on.as_deref(), Some("waiting for review"));
809        let content = std::fs::read_to_string(&task_path).unwrap();
810        assert!(content.contains("blocked: true"));
811        assert!(content.contains("block_reason: waiting for review"));
812
813        cmd_update(
814            board_dir,
815            11,
816            HashMap::from([("clear_blocked".to_string(), "true".to_string())]),
817        )
818        .unwrap();
819
820        let task = Task::from_file(&task_path).unwrap();
821        assert!(task.blocked.is_none());
822        assert!(task.blocked_on.is_none());
823    }
824
825    #[test]
826    fn normalize_blocked_frontmatter_repairs_legacy_string_shape() {
827        let tmp = tempfile::tempdir().unwrap();
828        let board_dir = tmp.path();
829        let task_path = write_task_file(board_dir, 14, "blocked");
830        std::fs::write(
831            &task_path,
832            "---\nid: 14\ntitle: Task 14\nstatus: blocked\npriority: high\nblocked: legacy verification reason\nclass: standard\n---\n\nTask body.\n",
833        )
834        .unwrap();
835
836        let repair = normalize_blocked_frontmatter(&task_path).unwrap();
837
838        assert_eq!(repair.as_ref().and_then(|entry| entry.task_id), Some(14));
839        let content = std::fs::read_to_string(&task_path).unwrap();
840        assert!(content.contains("blocked: true"));
841        assert!(content.contains("block_reason: legacy verification reason"));
842        let task = Task::from_file(&task_path).unwrap();
843        assert_eq!(task.blocked.as_deref(), Some("legacy verification reason"));
844    }
845
846    #[test]
847    fn normalize_blocked_frontmatter_repairs_hidden_in_progress_task_without_changing_status() {
848        let tmp = tempfile::tempdir().unwrap();
849        let board_dir = tmp.path();
850        let task_path = write_task_file(board_dir, 15, "in-progress");
851        std::fs::write(
852            &task_path,
853            "---\nid: 15\ntitle: Task 15\nstatus: in-progress\npriority: high\nclaimed_by: eng-1-2\nblocked: waiting on reviewer\nclass: standard\n---\n\nTask body.\n",
854        )
855        .unwrap();
856
857        let repair = normalize_blocked_frontmatter(&task_path).unwrap();
858
859        assert_eq!(repair.as_ref().and_then(|entry| entry.task_id), Some(15));
860        assert_eq!(
861            repair.as_ref().and_then(|entry| entry.status.as_deref()),
862            Some("in-progress")
863        );
864        let content = std::fs::read_to_string(&task_path).unwrap();
865        assert!(content.contains("status: in-progress"));
866        assert!(content.contains("blocked: true"));
867        assert!(content.contains("block_reason: waiting on reviewer"));
868        assert!(content.contains("blocked_on: waiting on reviewer"));
869    }
870
871    #[test]
872    fn normalize_blocked_frontmatter_is_idempotent_after_first_repair() {
873        let tmp = tempfile::tempdir().unwrap();
874        let board_dir = tmp.path();
875        let task_path = write_task_file(board_dir, 16, "todo");
876        std::fs::write(
877            &task_path,
878            "---\nid: 16\ntitle: Task 16\nstatus: todo\npriority: high\nblocked: waiting on manager\nclass: standard\n---\n\nTask body.\n",
879        )
880        .unwrap();
881
882        let first = normalize_blocked_frontmatter(&task_path).unwrap();
883        let second = normalize_blocked_frontmatter(&task_path).unwrap();
884
885        assert!(first.is_some());
886        assert!(second.is_none());
887    }
888
889    #[test]
890    fn normalize_blocked_frontmatter_is_idempotent_on_canonical_blocked_status() {
891        // Regression: a blocked task with the canonical shape
892        // (status: blocked + blocked: true + block_reason + blocked_on all
893        // consistent) used to trigger rewrites on every status scan because
894        // `rewrites_incomplete_blocked_task` only checked
895        // `status_is_blocked && legacy_reason.is_some()`. Observed 2026-04-10:
896        // status loop fired "repaired malformed board task frontmatter"
897        // warnings on every call for 4 tasks.
898        let tmp = tempfile::tempdir().unwrap();
899        let board_dir = tmp.path();
900        let task_path = write_task_file(board_dir, 42, "todo");
901        std::fs::write(
902            &task_path,
903            "---\n\
904id: 42\n\
905title: Already canonical\n\
906status: blocked\n\
907priority: high\n\
908blocked: true\n\
909block_reason: 'verification escalation after 2 attempts: narration-only completion'\n\
910blocked_on: 'verification escalation after 2 attempts: narration-only completion'\n\
911class: standard\n\
912---\n\n\
913Body.\n",
914        )
915        .unwrap();
916
917        let first = normalize_blocked_frontmatter(&task_path).unwrap();
918        let second = normalize_blocked_frontmatter(&task_path).unwrap();
919        let third = normalize_blocked_frontmatter(&task_path).unwrap();
920
921        assert!(
922            first.is_none(),
923            "canonical blocked task must not trigger a repair on first call"
924        );
925        assert!(second.is_none());
926        assert!(third.is_none());
927    }
928
929    #[test]
930    fn repair_board_frontmatter_compat_repairs_legacy_timestamp_offsets_idempotently() {
931        let tmp = tempfile::tempdir().unwrap();
932        let board_dir = tmp.path();
933        let task_path = write_task_file(board_dir, 623, "review");
934        std::fs::write(
935            &task_path,
936            "---\nid: 623\ntitle: stale review\nstatus: review\npriority: high\ncreated: 2026-04-10T16:31:02.743151-04:00\nupdated: 2026-04-10T19:26:40-0400\nartifacts:\n  - .batty/reports/verification/completion/task-623-eng-1-1-attempt-1.json\nreview_disposition: approved\nreviewed_by: architect\nreviewed_at: 2026-04-10T23:26:40+00:00\nclass: standard\n---\n\nTask body.\n",
937        )
938        .unwrap();
939
940        let first = repair_board_frontmatter_compat(board_dir).unwrap();
941        let second = repair_board_frontmatter_compat(board_dir).unwrap();
942
943        assert_eq!(first.len(), 1);
944        assert_eq!(first[0].task_id, Some(623));
945        assert_eq!(
946            first[0].reason.as_deref(),
947            Some("normalized timestamp fields: updated")
948        );
949        assert_eq!(first[0].repaired_fields, vec!["updated".to_string()]);
950        assert!(second.is_empty(), "timestamp repair must be idempotent");
951
952        let content = std::fs::read_to_string(&task_path).unwrap();
953        assert!(content.contains("updated: 2026-04-10T19:26:40-04:00"));
954        assert!(content.contains("reviewed_by: architect"));
955        assert!(content.contains("review_disposition: approved"));
956        assert!(
957            content.contains(
958                "- .batty/reports/verification/completion/task-623-eng-1-1-attempt-1.json"
959            )
960        );
961        assert!(content.ends_with("\n\nTask body.\n"));
962    }
963
964    #[test]
965    fn update_requires_at_least_one_field() {
966        let tmp = tempfile::tempdir().unwrap();
967        let board_dir = tmp.path();
968        write_task_file(board_dir, 12, "todo");
969
970        let error = cmd_update(board_dir, 12, HashMap::new())
971            .unwrap_err()
972            .to_string();
973        assert!(error.contains("no workflow fields provided"));
974    }
975
976    #[test]
977    fn task_commands_work_without_orchestrator_runtime() {
978        let tmp = tempfile::tempdir().unwrap();
979        let board_dir = tmp.path();
980        let task_path = write_task_file(board_dir, 13, "todo");
981
982        cmd_assign(board_dir, 13, Some("eng-1-2"), Some("manager-1")).unwrap();
983        cmd_transition(board_dir, 13, "in-progress").unwrap();
984        cmd_transition(board_dir, 13, "review").unwrap();
985        cmd_update(
986            board_dir,
987            13,
988            HashMap::from([
989                ("branch".to_string(), "eng-1-2/task-13".to_string()),
990                ("commit".to_string(), "deadbeef".to_string()),
991            ]),
992        )
993        .unwrap();
994        cmd_review(board_dir, 13, "approved", None).unwrap();
995
996        let task = Task::from_file(&task_path).unwrap();
997        let metadata = read_workflow_metadata(&task_path).unwrap();
998        assert_eq!(task.status, "done");
999        assert_eq!(task.claimed_by.as_deref(), Some("eng-1-2"));
1000        assert_eq!(task.review_owner.as_deref(), Some("manager-1"));
1001        assert_eq!(metadata.branch.as_deref(), Some("eng-1-2/task-13"));
1002        assert_eq!(metadata.commit.as_deref(), Some("deadbeef"));
1003        assert_eq!(metadata.outcome.as_deref(), Some("approved"));
1004    }
1005
1006    fn write_review_task_with_engineer(dir: &Path, id: u32, engineer: &str) -> PathBuf {
1007        let tasks_dir = dir.join("tasks");
1008        std::fs::create_dir_all(&tasks_dir).unwrap();
1009        let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
1010        std::fs::write(
1011            &path,
1012            format!(
1013                "---\nid: {id}\ntitle: Task {id}\nstatus: review\npriority: high\nclass: standard\nclaimed_by: {engineer}\n---\n\nTask body.\n"
1014            ),
1015        )
1016        .unwrap();
1017        path
1018    }
1019
1020    #[test]
1021    fn review_feedback_stored_in_task() {
1022        let tmp = tempfile::tempdir().unwrap();
1023        let board_dir = tmp.path();
1024        let task_path = write_review_task_with_engineer(board_dir, 42, "eng-1-2");
1025
1026        cmd_review(
1027            board_dir,
1028            42,
1029            "changes_requested",
1030            Some("fix the error handling"),
1031        )
1032        .unwrap();
1033
1034        let content = std::fs::read_to_string(&task_path).unwrap();
1035        assert!(
1036            content.contains("fix the error handling"),
1037            "feedback should be stored in task frontmatter"
1038        );
1039    }
1040
1041    #[test]
1042    fn review_feedback_delivered_to_engineer() {
1043        let tmp = tempfile::tempdir().unwrap();
1044
1045        // Create project structure: board_dir must be at <root>/.batty/team_config/board
1046        let project_root = tmp.path().join("project");
1047        let actual_board_dir = project_root
1048            .join(".batty")
1049            .join("team_config")
1050            .join("board");
1051        std::fs::create_dir_all(actual_board_dir.join("tasks")).unwrap();
1052
1053        // Create inbox for engineer
1054        let inbox_root = crate::team::inbox::inboxes_root(&project_root);
1055        crate::team::inbox::init_inbox(&inbox_root, "eng-1-2").unwrap();
1056
1057        // Write task in the actual board dir
1058        let task_path = actual_board_dir.join("tasks").join("042-task-42.md");
1059        std::fs::write(
1060            &task_path,
1061            "---\nid: 42\ntitle: Task 42\nstatus: review\npriority: high\nclass: standard\nclaimed_by: eng-1-2\n---\n\nTask body.\n",
1062        )
1063        .unwrap();
1064
1065        cmd_review(
1066            &actual_board_dir,
1067            42,
1068            "changes_requested",
1069            Some("fix the error handling"),
1070        )
1071        .unwrap();
1072
1073        let pending = crate::team::inbox::pending_messages(&inbox_root, "eng-1-2").unwrap();
1074        assert_eq!(pending.len(), 1);
1075        assert!(
1076            pending[0].body.contains("fix the error handling"),
1077            "feedback message should be delivered to engineer inbox"
1078        );
1079        assert!(pending[0].body.contains("#42"));
1080    }
1081
1082    #[test]
1083    fn schedule_task_sets_scheduled_for() {
1084        let tmp = tempfile::tempdir().unwrap();
1085        let board_dir = tmp.path();
1086        let task_path = write_task_file(board_dir, 60, "todo");
1087
1088        cmd_schedule(
1089            board_dir,
1090            60,
1091            Some("2026-03-25T09:00:00-04:00"),
1092            None,
1093            false,
1094        )
1095        .unwrap();
1096
1097        let task = Task::from_file(&task_path).unwrap();
1098        assert_eq!(
1099            task.scheduled_for.as_deref(),
1100            Some("2026-03-25T09:00:00-04:00")
1101        );
1102        assert!(task.cron_schedule.is_none());
1103    }
1104
1105    #[test]
1106    fn schedule_task_sets_cron_schedule() {
1107        let tmp = tempfile::tempdir().unwrap();
1108        let board_dir = tmp.path();
1109        let task_path = write_task_file(board_dir, 61, "todo");
1110
1111        cmd_schedule(board_dir, 61, None, Some("0 9 * * *"), false).unwrap();
1112
1113        let task = Task::from_file(&task_path).unwrap();
1114        assert!(task.scheduled_for.is_none());
1115        assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * *"));
1116    }
1117
1118    #[test]
1119    fn schedule_task_clear_removes_fields() {
1120        let tmp = tempfile::tempdir().unwrap();
1121        let board_dir = tmp.path();
1122        let task_path = write_task_file(board_dir, 62, "todo");
1123
1124        // Set both fields first
1125        cmd_schedule(
1126            board_dir,
1127            62,
1128            Some("2026-04-01T00:00:00Z"),
1129            Some("0 9 * * 1"),
1130            false,
1131        )
1132        .unwrap();
1133        let task = Task::from_file(&task_path).unwrap();
1134        assert!(task.scheduled_for.is_some());
1135        assert!(task.cron_schedule.is_some());
1136
1137        // Clear
1138        cmd_schedule(board_dir, 62, None, None, true).unwrap();
1139        let task = Task::from_file(&task_path).unwrap();
1140        assert!(task.scheduled_for.is_none());
1141        assert!(task.cron_schedule.is_none());
1142    }
1143
1144    #[test]
1145    fn schedule_task_sets_both() {
1146        let tmp = tempfile::tempdir().unwrap();
1147        let board_dir = tmp.path();
1148        let task_path = write_task_file(board_dir, 63, "todo");
1149
1150        cmd_schedule(
1151            board_dir,
1152            63,
1153            Some("2026-04-01T00:00:00Z"),
1154            Some("0 9 * * 1"),
1155            false,
1156        )
1157        .unwrap();
1158
1159        let task = Task::from_file(&task_path).unwrap();
1160        assert_eq!(task.scheduled_for.as_deref(), Some("2026-04-01T00:00:00Z"));
1161        assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * 1"));
1162    }
1163
1164    #[test]
1165    fn schedule_rejects_invalid_timestamp() {
1166        let tmp = tempfile::tempdir().unwrap();
1167        let board_dir = tmp.path();
1168        write_task_file(board_dir, 64, "todo");
1169
1170        let err = cmd_schedule(board_dir, 64, Some("not-a-date"), None, false)
1171            .unwrap_err()
1172            .to_string();
1173        assert!(err.contains("invalid RFC3339 timestamp"));
1174    }
1175
1176    #[test]
1177    fn schedule_rejects_invalid_cron() {
1178        let tmp = tempfile::tempdir().unwrap();
1179        let board_dir = tmp.path();
1180        write_task_file(board_dir, 65, "todo");
1181
1182        let err = cmd_schedule(board_dir, 65, None, Some("not-a-cron"), false)
1183            .unwrap_err()
1184            .to_string();
1185        assert!(err.contains("invalid cron expression"));
1186    }
1187
1188    #[test]
1189    fn schedule_requires_at_least_one_flag() {
1190        let tmp = tempfile::tempdir().unwrap();
1191        let board_dir = tmp.path();
1192        write_task_file(board_dir, 66, "todo");
1193
1194        let err = cmd_schedule(board_dir, 66, None, None, false)
1195            .unwrap_err()
1196            .to_string();
1197        assert!(err.contains("at least one of --at, --cron, or --clear"));
1198    }
1199
1200    // --- Structured review tests ---
1201
1202    #[test]
1203    fn structured_review_approve_stores_frontmatter_and_moves_to_done() {
1204        let tmp = tempfile::tempdir().unwrap();
1205        let board_dir = tmp.path();
1206        let task_path = write_task_file(board_dir, 70, "review");
1207
1208        cmd_review_structured(board_dir, 70, "approve", None, "manager-1").unwrap();
1209
1210        let task = Task::from_file(&task_path).unwrap();
1211        assert_eq!(task.status, "done");
1212
1213        let content = std::fs::read_to_string(&task_path).unwrap();
1214        assert!(content.contains("review_disposition: approved"));
1215        assert!(content.contains("reviewed_by: manager-1"));
1216        assert!(content.contains("reviewed_at:"));
1217
1218        let metadata = read_workflow_metadata(&task_path).unwrap();
1219        assert_eq!(metadata.outcome.as_deref(), Some("approved"));
1220    }
1221
1222    #[test]
1223    fn structured_review_request_changes_stores_feedback_and_moves_to_in_progress() {
1224        let tmp = tempfile::tempdir().unwrap();
1225        let board_dir = tmp.path();
1226        let task_path = write_task_file(board_dir, 71, "review");
1227
1228        cmd_review_structured(
1229            board_dir,
1230            71,
1231            "request-changes",
1232            Some("fix the error handling"),
1233            "manager-1",
1234        )
1235        .unwrap();
1236
1237        let task = Task::from_file(&task_path).unwrap();
1238        assert_eq!(task.status, "in-progress");
1239
1240        let content = std::fs::read_to_string(&task_path).unwrap();
1241        assert!(content.contains("review_disposition: changes_requested"));
1242        assert!(content.contains("review_feedback: fix the error handling"));
1243        assert!(content.contains("reviewed_by: manager-1"));
1244        assert!(content.contains("reviewed_at:"));
1245    }
1246
1247    #[test]
1248    fn structured_review_reject_moves_to_blocked_with_reason() {
1249        let tmp = tempfile::tempdir().unwrap();
1250        let board_dir = tmp.path();
1251        let task_path = write_task_file(board_dir, 72, "review");
1252
1253        cmd_review_structured(
1254            board_dir,
1255            72,
1256            "reject",
1257            Some("does not meet requirements"),
1258            "manager-1",
1259        )
1260        .unwrap();
1261
1262        let task = Task::from_file(&task_path).unwrap();
1263        assert_eq!(task.status, "blocked");
1264
1265        let content = std::fs::read_to_string(&task_path).unwrap();
1266        assert!(content.contains("review_disposition: rejected"));
1267        assert!(content.contains("review_feedback: does not meet requirements"));
1268        assert!(content.contains("reviewed_by: manager-1"));
1269        assert!(content.contains("blocked_on: does not meet requirements"));
1270    }
1271
1272    #[test]
1273    fn structured_review_reject_without_feedback_uses_default_reason() {
1274        let tmp = tempfile::tempdir().unwrap();
1275        let board_dir = tmp.path();
1276        let task_path = write_task_file(board_dir, 73, "review");
1277
1278        cmd_review_structured(board_dir, 73, "reject", None, "manager-1").unwrap();
1279
1280        let task = Task::from_file(&task_path).unwrap();
1281        assert_eq!(task.status, "blocked");
1282
1283        let content = std::fs::read_to_string(&task_path).unwrap();
1284        assert!(content.contains("blocked_on: rejected by manager-1"));
1285    }
1286
1287    #[test]
1288    fn structured_review_rejects_non_review_state() {
1289        let tmp = tempfile::tempdir().unwrap();
1290        let board_dir = tmp.path();
1291        write_task_file(board_dir, 74, "in-progress");
1292
1293        let err = cmd_review_structured(board_dir, 74, "approve", None, "manager-1")
1294            .unwrap_err()
1295            .to_string();
1296        assert!(err.contains("illegal task state transition"));
1297    }
1298
1299    #[test]
1300    fn structured_review_feedback_delivered_to_engineer_inbox() {
1301        let tmp = tempfile::tempdir().unwrap();
1302
1303        // Create project structure: board_dir must be at <root>/.batty/team_config/board
1304        let project_root = tmp.path().join("project");
1305        let actual_board_dir = project_root
1306            .join(".batty")
1307            .join("team_config")
1308            .join("board");
1309        std::fs::create_dir_all(actual_board_dir.join("tasks")).unwrap();
1310
1311        // Create inbox for engineer
1312        let inbox_root = crate::team::inbox::inboxes_root(&project_root);
1313        crate::team::inbox::init_inbox(&inbox_root, "eng-1-2").unwrap();
1314
1315        // Write task in the actual board dir
1316        let task_path = actual_board_dir.join("tasks").join("075-task-75.md");
1317        std::fs::write(
1318            &task_path,
1319            "---\nid: 75\ntitle: Task 75\nstatus: review\npriority: high\nclass: standard\nclaimed_by: eng-1-2\n---\n\nTask body.\n",
1320        )
1321        .unwrap();
1322
1323        cmd_review_structured(
1324            &actual_board_dir,
1325            75,
1326            "request-changes",
1327            Some("add more tests"),
1328            "manager-1",
1329        )
1330        .unwrap();
1331
1332        let pending = crate::team::inbox::pending_messages(&inbox_root, "eng-1-2").unwrap();
1333        assert_eq!(pending.len(), 1);
1334        assert!(pending[0].body.contains("add more tests"));
1335        assert!(pending[0].body.contains("#75"));
1336    }
1337}