Skip to main content

plan_issue/
task_spec.rs

1//! Task-spec builder and TSV writer for the plan-issue runtime.
2//!
3//! This module is intentionally **not** a Markdown emitter: the only
4//! string output below is [`render_tsv`], which produces a
5//! tab-separated `.tsv` file consumed by downstream subagent
6//! lanes. The two
7//! [`nils_common::markdown::canonicalize_table_cell`] calls inside
8//! `build_task_spec` (notes joined for newly-built rows and notes
9//! copied from already-parsed `TaskSpecRow` values) keep
10//! `TaskSpecRow.notes` safe for a single-line TSV cell by collapsing
11//! embedded newlines and replacing `|` with `/`. They are kept here
12//! at struct-construction time, not at TSV emission time, so any
13//! consumer that reads `TaskSpecRow.notes` directly sees the same
14//! pre-canonicalized value.
15//!
16//! The Markdown emitters live in [`crate::render`] and
17//! [`crate::issue_body::render_task_decomposition_block`]; both rely
18//! on the [`nils_common::markdown` v1 contract] idempotency of
19//! `canonicalize_table_cell` so a notes value that has already been
20//! canonicalized here can pass through the template-side `| md_cell`
21//! filter (registered by `nils-markdown`) without double-escaping.
22//! This is the Sprint 2 plan's Task 2.2 audit deliverable: no
23//! Markdown migration is required for this file because no Markdown
24//! is rendered here.
25
26use std::collections::{BTreeSet, HashMap};
27use std::path::{Path, PathBuf};
28
29use nils_common::{fs as common_fs, git as common_git, markdown as common_markdown};
30use plan_tooling::parse::{Sprint as ParsedSprint, parse_plan_with_display};
31use plan_tooling::split_prs::{
32    SplitPlanOptions, SplitPlanRecord, SplitPrGrouping, SplitPrStrategy, SplitScope,
33    build_split_plan_records, resolve_pr_grouping_by_sprint, select_sprints_for_scope,
34};
35
36use crate::commands::{PrGroupMapping, PrGrouping, SplitStrategy};
37
38pub const TASK_SPEC_HEADER: &str = "# task_id\tsummary\tbranch\tworktree\towner\tnotes\tpr_group";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TaskSpecScope {
42    Plan,
43    Sprint(i32),
44}
45
46#[derive(Debug, Clone)]
47pub struct TaskSpecBuildOptions {
48    pub owner_prefix: String,
49    pub branch_prefix: String,
50    pub worktree_prefix: String,
51    pub pr_grouping: Option<PrGrouping>,
52    pub default_pr_grouping: Option<PrGrouping>,
53    pub strategy: SplitStrategy,
54    pub pr_group: Vec<PrGroupMapping>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct TaskSpecRow {
59    pub task_id: String,
60    pub summary: String,
61    pub branch: String,
62    pub worktree: String,
63    pub owner: String,
64    pub notes: String,
65    pub pr_group: String,
66    pub sprint: i32,
67    pub grouping: PrGrouping,
68}
69
70#[derive(Debug, Clone)]
71pub struct TaskSpecBuild {
72    pub plan_title: String,
73    pub display_plan_path: String,
74    pub sprint_name: Option<String>,
75    pub rows: Vec<TaskSpecRow>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct RuntimeLaneMetadata {
80    pub execution_mode: String,
81    pub owner: String,
82    pub branch: String,
83    pub worktree: String,
84    pub notes: String,
85}
86
87pub fn build_task_spec(
88    plan_file: &Path,
89    scope: TaskSpecScope,
90    options: &TaskSpecBuildOptions,
91) -> Result<TaskSpecBuild, String> {
92    let display_path = plan_file.to_string_lossy().to_string();
93    let resolved_plan_path = resolve_plan_file(plan_file);
94    if !resolved_plan_path.is_file() {
95        return Err(format!("plan file not found: {display_path}"));
96    }
97
98    let (plan, parse_errors) = parse_plan_with_display(&resolved_plan_path, &display_path)
99        .map_err(|err| format!("{display_path}: {err}"))?;
100    if !parse_errors.is_empty() {
101        return Err(format!("{display_path}: {}", parse_errors.join(" | ")));
102    }
103
104    let split_scope = match scope {
105        TaskSpecScope::Plan => SplitScope::Plan,
106        TaskSpecScope::Sprint(sprint) => SplitScope::Sprint(sprint),
107    };
108
109    let selected_sprints = select_sprints_for_scope(&plan, split_scope)?;
110    let sprint_name = match scope {
111        TaskSpecScope::Plan => None,
112        TaskSpecScope::Sprint(_) => selected_sprints.first().map(|sprint| sprint.name.clone()),
113    };
114
115    let split_options = SplitPlanOptions {
116        pr_grouping: options.pr_grouping.map(to_split_grouping),
117        default_pr_grouping: options.default_pr_grouping.map(to_split_grouping),
118        strategy: to_split_strategy(options.strategy),
119        pr_group_entries: options
120            .pr_group
121            .iter()
122            .map(|entry| format!("{}={}", entry.task, entry.group))
123            .collect(),
124        owner_prefix: options.owner_prefix.clone(),
125        branch_prefix: options.branch_prefix.clone(),
126        worktree_prefix: options.worktree_prefix.clone(),
127    };
128    let grouping_by_sprint =
129        resolve_pr_grouping_by_sprint(&selected_sprints, &split_options).map(|resolved| {
130            resolved
131                .into_iter()
132                .map(|(sprint, value)| (sprint, from_split_grouping(value.grouping)))
133                .collect::<HashMap<_, _>>()
134        })?;
135
136    let split_records = build_split_plan_records(&selected_sprints, &split_options)?;
137    let rows = RuntimeMetadataMaterializer::new(&selected_sprints, options, grouping_by_sprint)?
138        .materialize_rows(&split_records)?;
139
140    Ok(TaskSpecBuild {
141        plan_title: plan.title,
142        display_plan_path: display_path,
143        sprint_name,
144        rows,
145    })
146}
147
148#[derive(Debug, Clone)]
149struct RuntimeTaskSeed {
150    sprint: i32,
151    ordinal: usize,
152    plan_task_id: String,
153    dependencies: Vec<String>,
154    first_validation: Option<String>,
155}
156
157#[derive(Debug, Clone)]
158struct RuntimeMetadataMaterializer {
159    owner_prefix: String,
160    branch_prefix: String,
161    worktree_prefix: String,
162    grouping_by_sprint: HashMap<i32, PrGrouping>,
163    strategy: SplitStrategy,
164    task_seed_by_id: HashMap<String, RuntimeTaskSeed>,
165}
166
167impl RuntimeMetadataMaterializer {
168    fn new(
169        selected_sprints: &[ParsedSprint],
170        options: &TaskSpecBuildOptions,
171        grouping_by_sprint: HashMap<i32, PrGrouping>,
172    ) -> Result<Self, String> {
173        let mut task_seed_by_id: HashMap<String, RuntimeTaskSeed> = HashMap::new();
174
175        for sprint in selected_sprints {
176            for (idx, task) in sprint.tasks.iter().enumerate() {
177                let ordinal = idx + 1;
178                let task_id = format!("S{}T{ordinal}", sprint.number);
179                let plan_task_id = if task.id.trim().is_empty() {
180                    task_id.clone()
181                } else {
182                    task.id.trim().to_string()
183                };
184                let dependencies = task
185                    .dependencies
186                    .clone()
187                    .unwrap_or_default()
188                    .into_iter()
189                    .map(|dep| dep.id.trim().to_string())
190                    .filter(|dep| !dep.is_empty())
191                    .filter(|dep| !is_plan_placeholder(dep))
192                    .collect::<Vec<_>>();
193                let first_validation = task
194                    .validation
195                    .iter()
196                    .map(|validation| validation.trim().to_string())
197                    .find(|validation| !validation.is_empty() && !is_plan_placeholder(validation));
198
199                let inserted = task_seed_by_id.insert(
200                    task_id.clone(),
201                    RuntimeTaskSeed {
202                        sprint: sprint.number,
203                        ordinal,
204                        plan_task_id,
205                        dependencies,
206                        first_validation,
207                    },
208                );
209                if inserted.is_some() {
210                    return Err(format!(
211                        "duplicate synthesized task id while materializing runtime metadata: {task_id}"
212                    ));
213                }
214            }
215        }
216
217        Ok(Self {
218            owner_prefix: normalize_owner_prefix(&options.owner_prefix),
219            branch_prefix: normalize_branch_prefix(&options.branch_prefix),
220            worktree_prefix: normalize_worktree_prefix(&options.worktree_prefix),
221            grouping_by_sprint,
222            strategy: options.strategy,
223            task_seed_by_id,
224        })
225    }
226
227    fn materialize_rows(
228        &self,
229        split_records: &[SplitPlanRecord],
230    ) -> Result<Vec<TaskSpecRow>, String> {
231        let mut group_sizes: HashMap<(i32, String), usize> = HashMap::new();
232        let mut anchor_by_lane: HashMap<(i32, String), String> = HashMap::new();
233        for record in split_records {
234            let lane_key = (record.sprint, record.pr_group.clone());
235            *group_sizes.entry(lane_key.clone()).or_insert(0) += 1;
236            anchor_by_lane
237                .entry(lane_key)
238                .or_insert_with(|| record.task_id.clone());
239        }
240
241        let mut rows = Vec::with_capacity(split_records.len());
242        for record in split_records {
243            let seed = self
244                .task_seed_by_id
245                .get(&record.task_id)
246                .ok_or_else(|| format!("{}: missing parsed plan task metadata", record.task_id))?;
247            let lane_key = (record.sprint, record.pr_group.clone());
248            let shared_anchor = if group_sizes.get(&lane_key).copied().unwrap_or(0) > 1 {
249                anchor_by_lane.get(&lane_key).cloned()
250            } else {
251                None
252            };
253
254            let slug_fallback = format!("task-{}", seed.ordinal);
255            let slug = normalize_token(&record.summary, &slug_fallback, 48);
256            let grouping = *self.grouping_by_sprint.get(&record.sprint).ok_or_else(|| {
257                format!(
258                    "{}: missing resolved grouping for sprint {}",
259                    record.task_id, record.sprint
260                )
261            })?;
262            let notes =
263                synthesize_notes(seed, grouping, &record.pr_group, shared_anchor.as_deref());
264
265            rows.push(TaskSpecRow {
266                task_id: record.task_id.clone(),
267                summary: record.summary.clone(),
268                branch: format!(
269                    "{}/s{}-t{}-{}",
270                    self.branch_prefix, seed.sprint, seed.ordinal, slug
271                ),
272                worktree: format!(
273                    "{}-s{}-t{}",
274                    self.worktree_prefix, seed.sprint, seed.ordinal
275                ),
276                owner: format!("{}-s{}-t{}", self.owner_prefix, seed.sprint, seed.ordinal),
277                notes,
278                pr_group: record.pr_group.clone(),
279                sprint: record.sprint,
280                grouping,
281            });
282        }
283
284        let runtime_lane_metadata = runtime_lane_metadata_by_task(&rows, self.strategy);
285        for row in &mut rows {
286            let metadata = runtime_lane_metadata.get(&row.task_id).ok_or_else(|| {
287                format!(
288                    "{}: missing runtime lane metadata after materialization",
289                    row.task_id
290                )
291            })?;
292            row.owner = metadata.owner.clone();
293            row.branch = metadata.branch.clone();
294            row.worktree = metadata.worktree.clone();
295            row.notes = metadata.notes.clone();
296        }
297
298        Ok(rows)
299    }
300}
301
302fn synthesize_notes(
303    seed: &RuntimeTaskSeed,
304    grouping: PrGrouping,
305    pr_group: &str,
306    shared_anchor: Option<&str>,
307) -> String {
308    let mut notes = vec![
309        format!("sprint=S{}", seed.sprint),
310        format!("plan-task:{}", seed.plan_task_id),
311    ];
312    if !seed.dependencies.is_empty() {
313        notes.push(format!("deps={}", seed.dependencies.join(",")));
314    }
315    if let Some(first_validation) = &seed.first_validation {
316        notes.push(format!("validate={first_validation}"));
317    }
318    notes.push(format!("pr-grouping={}", pr_grouping_label(grouping)));
319    notes.push(format!("pr-group={pr_group}"));
320    if let Some(anchor) = shared_anchor {
321        notes.push(format!("shared-pr-anchor={anchor}"));
322    }
323
324    common_markdown::canonicalize_table_cell(&notes.join("; "))
325}
326
327fn pr_grouping_label(grouping: PrGrouping) -> &'static str {
328    match grouping {
329        PrGrouping::PerSprint => "per-sprint",
330        PrGrouping::Group => "group",
331    }
332}
333
334fn normalize_branch_prefix(value: &str) -> String {
335    let trimmed = value.trim().trim_end_matches('/');
336    if trimmed.is_empty() {
337        "feat".to_string()
338    } else {
339        trimmed.to_string()
340    }
341}
342
343fn normalize_worktree_prefix(value: &str) -> String {
344    let trimmed = value.trim().trim_end_matches(['-', '_']);
345    if trimmed.is_empty() {
346        "feat".to_string()
347    } else {
348        trimmed.to_string()
349    }
350}
351
352fn normalize_owner_prefix(value: &str) -> String {
353    let trimmed = value.trim();
354    if trimmed.is_empty() {
355        "subagent".to_string()
356    } else if trimmed.to_ascii_lowercase().contains("subagent") {
357        trimmed.to_string()
358    } else {
359        format!("subagent-{trimmed}")
360    }
361}
362
363fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
364    let mut out = String::new();
365    let mut last_dash = false;
366    for ch in value.chars().flat_map(char::to_lowercase) {
367        if ch.is_ascii_alphanumeric() {
368            out.push(ch);
369            last_dash = false;
370        } else if !last_dash {
371            out.push('-');
372            last_dash = true;
373        }
374    }
375    let normalized = out.trim_matches('-').to_string();
376    let mut final_token = if normalized.is_empty() {
377        fallback.to_string()
378    } else {
379        normalized
380    };
381    if final_token.len() > max_len {
382        final_token.truncate(max_len);
383        final_token = final_token.trim_matches('-').to_string();
384    }
385    final_token
386}
387
388fn is_plan_placeholder(value: &str) -> bool {
389    let token = value.trim().to_ascii_lowercase();
390    if matches!(token.as_str(), "" | "-" | "none" | "n/a" | "na" | "...") {
391        return true;
392    }
393    if token.starts_with('<') && token.ends_with('>') {
394        return true;
395    }
396    token.contains("task ids")
397}
398
399pub fn render_tsv(rows: &[TaskSpecRow]) -> String {
400    let mut out = String::new();
401    out.push_str(TASK_SPEC_HEADER);
402    out.push('\n');
403    for row in rows {
404        out.push_str(&format!(
405            "{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
406            row.task_id.replace('\t', " "),
407            row.summary.replace('\t', " "),
408            row.branch.replace('\t', " "),
409            row.worktree.replace('\t', " "),
410            row.owner.replace('\t', " "),
411            row.notes.replace('\t', " "),
412            row.pr_group.replace('\t', " "),
413        ));
414    }
415    out
416}
417
418pub fn write_tsv(path: &Path, rows: &[TaskSpecRow]) -> Result<(), String> {
419    common_fs::write_text(path, &render_tsv(rows)).map_err(|err| match err {
420        common_fs::WriteTextError::CreateParentDir { path, source } => {
421            format!(
422                "failed to create output directory {}: {source}",
423                path.display()
424            )
425        }
426        common_fs::WriteTextError::WriteFile { source, .. } => {
427            format!("failed to write task-spec {}: {source}", path.display())
428        }
429    })
430}
431
432pub fn execution_mode_by_task(
433    rows: &[TaskSpecRow],
434    strategy: SplitStrategy,
435) -> HashMap<String, String> {
436    runtime_lane_metadata_by_task(rows, strategy)
437        .into_iter()
438        .map(|(task_id, lane)| (task_id, lane.execution_mode))
439        .collect()
440}
441
442pub fn runtime_lane_metadata_by_task(
443    rows: &[TaskSpecRow],
444    strategy: SplitStrategy,
445) -> HashMap<String, RuntimeLaneMetadata> {
446    let execution_modes = execution_mode_from_rows(rows, strategy);
447    let row_by_task: HashMap<&str, &TaskSpecRow> =
448        rows.iter().map(|row| (row.task_id.as_str(), row)).collect();
449
450    let mut anchor_by_lane: HashMap<(i32, String), String> = HashMap::new();
451    for row in rows {
452        anchor_by_lane
453            .entry((row.sprint, row.pr_group.clone()))
454            .or_insert_with(|| {
455                canonical_lane_anchor_task_id(rows, row.sprint, &row.pr_group)
456                    .unwrap_or_else(|| row.task_id.clone())
457            });
458    }
459
460    let mut out = HashMap::new();
461    for row in rows {
462        let execution_mode = execution_modes
463            .get(&row.task_id)
464            .cloned()
465            .unwrap_or_else(|| "pr-isolated".to_string());
466        let lane_key = (row.sprint, row.pr_group.clone());
467        let anchor_row = if execution_mode == "pr-isolated" {
468            row
469        } else {
470            anchor_by_lane
471                .get(&lane_key)
472                .and_then(|task_id| row_by_task.get(task_id.as_str()))
473                .copied()
474                .unwrap_or(row)
475        };
476
477        out.insert(
478            row.task_id.clone(),
479            RuntimeLaneMetadata {
480                execution_mode,
481                owner: anchor_row.owner.clone(),
482                branch: anchor_row.branch.clone(),
483                worktree: anchor_row.worktree.clone(),
484                notes: common_markdown::canonicalize_table_cell(&row.notes),
485            },
486        );
487    }
488
489    out
490}
491
492fn execution_mode_from_rows(
493    rows: &[TaskSpecRow],
494    _strategy: SplitStrategy,
495) -> HashMap<String, String> {
496    let mut sprint_group_set: HashMap<i32, BTreeSet<String>> = HashMap::new();
497    let mut sprint_group_sizes: HashMap<(i32, String), usize> = HashMap::new();
498    for row in rows {
499        sprint_group_set
500            .entry(row.sprint)
501            .or_default()
502            .insert(row.pr_group.clone());
503        *sprint_group_sizes
504            .entry((row.sprint, row.pr_group.clone()))
505            .or_insert(0) += 1;
506    }
507
508    let mut out = HashMap::new();
509    for row in rows {
510        let sprint_group_count = sprint_group_set
511            .get(&row.sprint)
512            .map(BTreeSet::len)
513            .unwrap_or(0);
514        let group_size = sprint_group_sizes
515            .get(&(row.sprint, row.pr_group.clone()))
516            .copied()
517            .unwrap_or(0);
518
519        let mode = if row.grouping == PrGrouping::PerSprint {
520            "per-sprint"
521        } else if sprint_group_count == 1 && group_size > 1 {
522            // Group mode (auto or deterministic) can converge to a single shared PR lane.
523            // Expose that as per-sprint so downstream execution semantics match explicit
524            // per-sprint mode.
525            "per-sprint"
526        } else if group_size > 1 {
527            "pr-shared"
528        } else {
529            "pr-isolated"
530        };
531        out.insert(row.task_id.clone(), mode.to_string());
532    }
533
534    out
535}
536
537fn canonical_lane_anchor_task_id(
538    rows: &[TaskSpecRow],
539    sprint: i32,
540    pr_group: &str,
541) -> Option<String> {
542    let mut lane_rows = rows
543        .iter()
544        .filter(|row| row.sprint == sprint && row.pr_group == pr_group)
545        .collect::<Vec<_>>();
546    if lane_rows.is_empty() {
547        return None;
548    }
549
550    lane_rows.sort_unstable_by(|a, b| a.task_id.cmp(&b.task_id));
551    lane_rows.first().map(|row| row.task_id.clone())
552}
553
554pub fn default_plan_task_spec_path(plan_file: &Path) -> PathBuf {
555    let plan_stem = plan_file
556        .file_stem()
557        .and_then(|name| name.to_str())
558        .unwrap_or("plan")
559        .to_string();
560
561    state_dir()
562        .join("out")
563        .join("plan-issue-delivery")
564        .join(format!("{plan_stem}-plan-tasks.tsv"))
565}
566
567pub fn default_sprint_task_spec_path(plan_file: &Path, sprint: i32) -> PathBuf {
568    let plan_stem = plan_file
569        .file_stem()
570        .and_then(|name| name.to_str())
571        .unwrap_or("plan")
572        .to_string();
573
574    state_dir()
575        .join("out")
576        .join("plan-issue-delivery")
577        .join(format!("{plan_stem}-sprint-{sprint}-tasks.tsv"))
578}
579
580pub fn state_dir() -> PathBuf {
581    crate::state::state_dir()
582}
583
584pub fn resolve_plan_file(plan_file: &Path) -> PathBuf {
585    let repo_root = detect_repo_root();
586    resolve_repo_relative(&repo_root, plan_file)
587}
588
589fn detect_repo_root() -> PathBuf {
590    common_git::repo_root_or_cwd()
591}
592
593fn resolve_repo_relative(repo_root: &Path, path: &Path) -> PathBuf {
594    if path.is_absolute() {
595        return path.to_path_buf();
596    }
597    repo_root.join(path)
598}
599
600fn to_split_grouping(grouping: PrGrouping) -> SplitPrGrouping {
601    match grouping {
602        PrGrouping::PerSprint => SplitPrGrouping::PerSprint,
603        PrGrouping::Group => SplitPrGrouping::Group,
604    }
605}
606
607fn from_split_grouping(grouping: SplitPrGrouping) -> PrGrouping {
608    match grouping {
609        SplitPrGrouping::PerSprint => PrGrouping::PerSprint,
610        SplitPrGrouping::Group => PrGrouping::Group,
611    }
612}
613
614fn to_split_strategy(strategy: SplitStrategy) -> SplitPrStrategy {
615    match strategy {
616        SplitStrategy::Deterministic => SplitPrStrategy::Deterministic,
617        SplitStrategy::Auto => SplitPrStrategy::Auto,
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[allow(clippy::too_many_arguments)]
626    fn spec_row(
627        task_id: &str,
628        sprint: i32,
629        pr_group: &str,
630        grouping: PrGrouping,
631        owner: &str,
632        branch: &str,
633        worktree: &str,
634        notes: &str,
635    ) -> TaskSpecRow {
636        TaskSpecRow {
637            task_id: task_id.to_string(),
638            summary: format!("Summary for {task_id}"),
639            branch: branch.to_string(),
640            worktree: worktree.to_string(),
641            owner: owner.to_string(),
642            notes: notes.to_string(),
643            pr_group: pr_group.to_string(),
644            sprint,
645            grouping,
646        }
647    }
648
649    #[test]
650    fn execution_mode_by_task_auto_single_lane_uses_per_sprint() {
651        let rows = vec![
652            spec_row(
653                "S1T1",
654                1,
655                "s1-auto-g1",
656                PrGrouping::Group,
657                "subagent-s1-t1",
658                "issue/s1-t1",
659                "wt-1",
660                "sprint=S1; plan-task:Task 1.1; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
661            ),
662            spec_row(
663                "S1T2",
664                1,
665                "s1-auto-g1",
666                PrGrouping::Group,
667                "subagent-s1-t2",
668                "issue/s1-t2",
669                "wt-2",
670                "sprint=S1; plan-task:Task 1.2; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
671            ),
672        ];
673
674        let modes = execution_mode_by_task(&rows, SplitStrategy::Auto);
675        assert_eq!(modes.get("S1T1").map(String::as_str), Some("per-sprint"));
676        assert_eq!(modes.get("S1T2").map(String::as_str), Some("per-sprint"));
677    }
678
679    #[test]
680    fn execution_mode_by_task_deterministic_single_lane_uses_per_sprint() {
681        let rows = vec![
682            spec_row(
683                "S1T1",
684                1,
685                "s1-serial",
686                PrGrouping::Group,
687                "subagent-s1-t1",
688                "issue/s1-t1",
689                "wt-1",
690                "sprint=S1; plan-task:Task 1.1; pr-group=s1-serial; shared-pr-anchor=S1T1",
691            ),
692            spec_row(
693                "S1T2",
694                1,
695                "s1-serial",
696                PrGrouping::Group,
697                "subagent-s1-t1",
698                "issue/s1-t1",
699                "wt-1",
700                "sprint=S1; plan-task:Task 1.2; pr-group=s1-serial; shared-pr-anchor=S1T1",
701            ),
702        ];
703
704        let modes = execution_mode_by_task(&rows, SplitStrategy::Deterministic);
705        assert_eq!(modes.get("S1T1").map(String::as_str), Some("per-sprint"));
706        assert_eq!(modes.get("S1T2").map(String::as_str), Some("per-sprint"));
707    }
708
709    #[test]
710    fn execution_mode_by_task_auto_multi_group_keeps_group_modes() {
711        let rows = vec![
712            spec_row(
713                "S2T1",
714                2,
715                "s2-auto-g1",
716                PrGrouping::Group,
717                "subagent-s2-t1",
718                "issue/s2-t1",
719                "wt-1",
720                "sprint=S2; plan-task:Task 2.1; pr-group=s2-auto-g1",
721            ),
722            spec_row(
723                "S2T2",
724                2,
725                "s2-auto-g1",
726                PrGrouping::Group,
727                "subagent-s2-t2",
728                "issue/s2-t2",
729                "wt-2",
730                "sprint=S2; plan-task:Task 2.2; pr-group=s2-auto-g1",
731            ),
732            spec_row(
733                "S2T3",
734                2,
735                "s2-auto-g2",
736                PrGrouping::Group,
737                "subagent-s2-t3",
738                "issue/s2-t3",
739                "wt-3",
740                "sprint=S2; plan-task:Task 2.3; pr-group=s2-auto-g2",
741            ),
742        ];
743
744        let modes = execution_mode_by_task(&rows, SplitStrategy::Auto);
745        assert_eq!(modes.get("S2T1").map(String::as_str), Some("pr-shared"));
746        assert_eq!(modes.get("S2T2").map(String::as_str), Some("pr-shared"));
747        assert_eq!(modes.get("S2T3").map(String::as_str), Some("pr-isolated"));
748    }
749
750    #[test]
751    fn canonical_lane_anchor_uses_stable_task_order_even_when_notes_disagree() {
752        let rows = vec![
753            spec_row(
754                "S1T1",
755                1,
756                "s1-auto-g1",
757                PrGrouping::Group,
758                "subagent-s1-t1",
759                "issue/s1-t1",
760                "wt-1",
761                "sprint=S1; plan-task:Task 1.1; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
762            ),
763            spec_row(
764                "S1T2",
765                1,
766                "s1-auto-g1",
767                PrGrouping::Group,
768                "subagent-s1-t2",
769                "issue/s1-t2",
770                "wt-2",
771                "sprint=S1; plan-task:Task 1.2; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
772            ),
773        ];
774
775        assert_eq!(
776            canonical_lane_anchor_task_id(&rows, 1, "s1-auto-g1"),
777            Some("S1T1".to_string())
778        );
779    }
780
781    #[test]
782    fn canonical_lane_anchor_uses_deterministic_task_id_fallback_when_note_absent() {
783        let rows = vec![
784            spec_row(
785                "S4T3",
786                4,
787                "s4-auto-g2",
788                PrGrouping::Group,
789                "subagent-s4-t3",
790                "issue/s4-t3",
791                "wt-3",
792                "sprint=S4; plan-task:Task 4.3; pr-group=s4-auto-g2",
793            ),
794            spec_row(
795                "S4T1",
796                4,
797                "s4-auto-g2",
798                PrGrouping::Group,
799                "subagent-s4-t1",
800                "issue/s4-t1",
801                "wt-1",
802                "sprint=S4; plan-task:Task 4.1; pr-group=s4-auto-g2",
803            ),
804            spec_row(
805                "S4T2",
806                4,
807                "s4-auto-g2",
808                PrGrouping::Group,
809                "subagent-s4-t2",
810                "issue/s4-t2",
811                "wt-2",
812                "sprint=S4; plan-task:Task 4.2; pr-group=s4-auto-g2",
813            ),
814        ];
815
816        assert_eq!(
817            canonical_lane_anchor_task_id(&rows, 4, "s4-auto-g2"),
818            Some("S4T1".to_string())
819        );
820    }
821
822    #[test]
823    fn runtime_lane_canonicalization_uses_shared_anchor_metadata() {
824        let rows = vec![
825            spec_row(
826                "S1T1",
827                1,
828                "s1-auto-g1",
829                PrGrouping::Group,
830                "subagent-s1-t1",
831                "issue/s1-t1",
832                "wt-1",
833                "sprint=S1; plan-task:Task 1.1; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
834            ),
835            spec_row(
836                "S1T2",
837                1,
838                "s1-auto-g1",
839                PrGrouping::Group,
840                "subagent-s1-t2",
841                "issue/s1-t2",
842                "wt-2",
843                "sprint=S1; plan-task:Task 1.2; pr-group=s1-auto-g1; shared-pr-anchor=S1T2",
844            ),
845        ];
846
847        let runtime_by_task = runtime_lane_metadata_by_task(&rows, SplitStrategy::Auto);
848        let expected_anchor = runtime_by_task
849            .get("S1T2")
850            .expect("anchor runtime lane metadata")
851            .clone();
852
853        for row in rows
854            .iter()
855            .filter(|row| row.sprint == 1 && row.pr_group == "s1-auto-g1")
856        {
857            let lane = runtime_by_task
858                .get(&row.task_id)
859                .expect("runtime lane metadata");
860            assert_eq!(lane.execution_mode, "per-sprint");
861            assert_eq!(
862                lane.owner, expected_anchor.owner,
863                "task {} owner should match anchor",
864                row.task_id
865            );
866            assert_eq!(
867                lane.branch, expected_anchor.branch,
868                "task {} branch should match anchor",
869                row.task_id
870            );
871            assert_eq!(
872                lane.worktree, expected_anchor.worktree,
873                "task {} worktree should match anchor",
874                row.task_id
875            );
876        }
877
878        let rerun = runtime_lane_metadata_by_task(&rows, SplitStrategy::Auto);
879        assert_eq!(runtime_by_task, rerun);
880    }
881}