Skip to main content

track/models/
workflow.rs

1use crate::models::jj::jj_slug;
2use crate::models::{Task, TaskRepo, TaskStatus, Todo, TodoStatus, VcsMode, Worktree};
3use crate::services::{git_worktree, jj_task};
4use jj_task::RepoWorkspaceStatus;
5use serde::Serialize;
6
7/// High-level workflow phase for agents and humans.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum WorkflowPhase {
11    Setup,
12    SyncRequired,
13    Execute,
14    TaskComplete,
15    Archived,
16}
17
18/// Suggested next step kind for an agent.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum NextActionKind {
22    RunCommand,
23    ExecuteTodo,
24    UseJjSkill,
25    WaitHuman,
26}
27
28/// Suggested next step for an agent.
29#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
30pub struct NextAction {
31    pub kind: NextActionKind,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub command: Option<String>,
34    pub reason: String,
35}
36
37/// A single item in the setup/sync checklist (agents and WebUI).
38#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
39pub struct WorkflowStep {
40    pub id: &'static str,
41    pub label: String,
42    pub done: bool,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub command: Option<String>,
45}
46
47/// Derived workflow context (not persisted).
48#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct WorkflowContext {
50    pub phase: WorkflowPhase,
51    pub next_action: NextAction,
52    #[serde(skip_serializing_if = "Vec::is_empty")]
53    pub checklist: Vec<WorkflowStep>,
54}
55
56/// Lifecycle of a TODO's JJ workspace.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum WorkspaceLifecycle {
60    NotRequested,
61    Requested,
62    Ready,
63    Merged,
64}
65
66/// Agent-oriented view of a TODO item.
67#[derive(Debug, Clone, Serialize)]
68pub struct TodoAgentView {
69    pub todo_id: i64,
70    pub content: String,
71    pub status: TodoStatus,
72    pub is_next: bool,
73    pub allowed_actions: Vec<String>,
74    pub workspace: WorkspaceAgentView,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct WorkspaceAgentView {
79    pub lifecycle: WorkspaceLifecycle,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub path: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub bookmark: Option<String>,
84}
85
86/// jj-task / agent-skill-jj context for the current task.
87#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
88pub struct JjAgentContext {
89    pub slug: String,
90    pub skill: &'static str,
91    pub workspace_registered: bool,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub workspace_path: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub task_phase: Option<String>,
96    pub repos: Vec<RepoWorkspaceStatus>,
97    pub start_command: String,
98    pub path_command: String,
99    pub repo_init_command: &'static str,
100}
101
102/// Git worktree context for the current task.
103#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
104pub struct GitAgentContext {
105    pub slug: String,
106    pub branch: String,
107    pub workspace_ready: bool,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub workspace_path: Option<String>,
110    pub sync_command: String,
111}
112
113/// Guardrails exposed to agents via JSON status output.
114#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
115pub struct AgentGuardrails {
116    /// Commits, squash, push, and PR workflow belong to the `$jj` skill (jj mode only).
117    pub must_use_jj_skill: bool,
118    pub jj_skill_name: &'static str,
119    pub reopen_forbidden: bool,
120    /// True only for legacy per-TODO `--worktree` items managed by track.
121    pub complete_requires_jj_merge: bool,
122}
123
124impl AgentGuardrails {
125    pub fn for_mode(vcs_mode: VcsMode, complete_requires_jj_merge: bool) -> Self {
126        match vcs_mode {
127            VcsMode::Jj => Self {
128                must_use_jj_skill: true,
129                jj_skill_name: "jj",
130                reopen_forbidden: true,
131                complete_requires_jj_merge,
132            },
133            VcsMode::Git => Self {
134                must_use_jj_skill: false,
135                jj_skill_name: "jj",
136                reopen_forbidden: true,
137                complete_requires_jj_merge: false,
138            },
139        }
140    }
141}
142
143impl Default for AgentGuardrails {
144    fn default() -> Self {
145        Self::for_mode(VcsMode::Jj, false)
146    }
147}
148
149fn repo_paths(repos: &[TaskRepo]) -> Vec<String> {
150    repos.iter().map(|repo| repo.repo_path.clone()).collect()
151}
152
153fn pending_needs_workspace(todos: &[Todo]) -> bool {
154    todos
155        .iter()
156        .any(|todo| todo.status == TodoStatus::Pending && todo.requires_workspace)
157}
158
159/// True when pending TODOs still use the legacy per-TODO `--worktree` model.
160pub fn legacy_worktree_pending(todos: &[Todo]) -> bool {
161    todos
162        .iter()
163        .any(|todo| todo.status == TodoStatus::Pending && todo.worktree_requested)
164}
165
166/// True when legacy TODOs need `track sync` to create missing workspaces.
167pub fn legacy_worktree_sync_needed(todos: &[Todo], worktrees: &[Worktree]) -> bool {
168    todos.iter().any(|todo| {
169        todo.status == TodoStatus::Pending
170            && todo.worktree_requested
171            && !worktrees.iter().any(|wt| wt.todo_id == Some(todo.id))
172    })
173}
174
175fn jj_workspace_needed(task: &Task, todos: &[Todo], repos: &[TaskRepo]) -> bool {
176    if repos.is_empty() || !pending_needs_workspace(todos) {
177        return false;
178    }
179    let slug = jj_slug(task);
180    !jj_task::all_repos_registered(&slug, &repo_paths(repos))
181}
182
183fn git_workspace_needed(task: &Task, todos: &[Todo], repos: &[TaskRepo]) -> bool {
184    if repos.is_empty() || !pending_needs_workspace(todos) {
185        return false;
186    }
187    let slug = jj_slug(task);
188    !repos.iter().any(|repo| {
189        let path = git_worktree::git_worktree_path(&repo.repo_path, &slug);
190        git_worktree::git_worktree_exists(&path)
191    })
192}
193
194/// Computes the workflow phase from current task state.
195pub fn compute_workflow_phase(
196    vcs_mode: VcsMode,
197    task: &Task,
198    todos: &[Todo],
199    worktrees: &[Worktree],
200    repos: &[TaskRepo],
201) -> WorkflowPhase {
202    if task.status == TaskStatus::Archived {
203        return WorkflowPhase::Archived;
204    }
205
206    if repos.is_empty() {
207        return WorkflowPhase::Setup;
208    }
209
210    let sync_needed = match vcs_mode {
211        VcsMode::Jj => {
212            legacy_worktree_sync_needed(todos, worktrees) || jj_workspace_needed(task, todos, repos)
213        }
214        VcsMode::Git => git_workspace_needed(task, todos, repos),
215    };
216
217    if sync_needed {
218        return WorkflowPhase::SyncRequired;
219    }
220
221    if todos.iter().any(|todo| todo.status == TodoStatus::Pending) {
222        return WorkflowPhase::Execute;
223    }
224
225    WorkflowPhase::TaskComplete
226}
227
228/// Builds the full workflow context including checklist steps.
229pub fn build_workflow_context(
230    vcs_mode: VcsMode,
231    task: &Task,
232    todos: &[Todo],
233    worktrees: &[Worktree],
234    repos: &[TaskRepo],
235) -> WorkflowContext {
236    let phase = compute_workflow_phase(vcs_mode, task, todos, worktrees, repos);
237    WorkflowContext {
238        next_action: build_next_action(vcs_mode, phase, task, todos, worktrees, repos),
239        checklist: build_workflow_checklist(vcs_mode, phase, task, todos, repos),
240        phase,
241    }
242}
243
244/// Builds a progress checklist for setup and sync phases.
245pub fn build_workflow_checklist(
246    vcs_mode: VcsMode,
247    phase: WorkflowPhase,
248    task: &Task,
249    todos: &[Todo],
250    repos: &[TaskRepo],
251) -> Vec<WorkflowStep> {
252    let slug = jj_slug(task);
253    let paths = repo_paths(repos);
254
255    match phase {
256        WorkflowPhase::Setup => {
257            let mut steps = vec![WorkflowStep {
258                id: "repo",
259                label: "Register at least one repository".to_string(),
260                done: !repos.is_empty(),
261                command: Some("track repo add".to_string()),
262            }];
263            if vcs_mode == VcsMode::Jj && !repos.is_empty() {
264                let all_init = repos
265                    .iter()
266                    .all(|repo| jj_task::repo_initialized(&repo.repo_path));
267                steps.push(WorkflowStep {
268                    id: "jj_repo_init",
269                    label: "Initialize jj-task in each repo (once)".to_string(),
270                    done: all_init,
271                    command: Some("jj-task repo init".to_string()),
272                });
273            }
274            steps.push(WorkflowStep {
275                id: "todos",
276                label: "Add TODOs for this task".to_string(),
277                done: !todos.is_empty(),
278                command: Some("track todo add \"...\"".to_string()),
279            });
280            steps
281        }
282        WorkflowPhase::SyncRequired => {
283            let mut steps = Vec::new();
284            if vcs_mode == VcsMode::Jj && !repos.is_empty() {
285                let statuses = jj_task::repos_workspace_status(&slug, &paths);
286                for status in statuses {
287                    let label = format!("jj-task start in {}", status.repo_path);
288                    steps.push(WorkflowStep {
289                        id: "jj_task_start",
290                        label,
291                        done: status.registered,
292                        command: Some(format!("jj-task start {slug}")),
293                    });
294                }
295            } else if vcs_mode == VcsMode::Git {
296                steps.push(WorkflowStep {
297                    id: "git_sync",
298                    label: "Create git worktree for this task".to_string(),
299                    done: false,
300                    command: Some("track sync".to_string()),
301                });
302            }
303            steps
304        }
305        _ => Vec::new(),
306    }
307}
308
309/// Builds the suggested next action for the current workflow phase.
310pub fn build_next_action(
311    vcs_mode: VcsMode,
312    phase: WorkflowPhase,
313    task: &Task,
314    todos: &[Todo],
315    worktrees: &[Worktree],
316    repos: &[TaskRepo],
317) -> NextAction {
318    let slug = jj_slug(task);
319    let paths = repo_paths(repos);
320
321    match phase {
322        WorkflowPhase::Setup => NextAction {
323            kind: NextActionKind::RunCommand,
324            command: Some("track repo add [path]".to_string()),
325            reason: match vcs_mode {
326                VcsMode::Jj => {
327                    "Register at least one repository, then run jj-task repo init from the main workspace".to_string()
328                }
329                VcsMode::Git => {
330                    "Register at least one git repository, then run track sync to create a worktree".to_string()
331                }
332            },
333        },
334        WorkflowPhase::SyncRequired => match vcs_mode {
335            VcsMode::Jj => {
336                if legacy_worktree_sync_needed(todos, worktrees) {
337                    NextAction {
338                        kind: NextActionKind::RunCommand,
339                        command: Some("track sync".to_string()),
340                        reason: "Legacy per-TODO --worktree workspaces are pending (prefer jj-task for new tasks)".to_string(),
341                    }
342                } else {
343                    let missing = jj_task::unregistered_repo_paths(&slug, &paths);
344                    let reason = if missing.len() > 1 {
345                        format!(
346                            "Start jj-task workspace in each repo ({}/{} ready). Run jj-task repo init once from each main workspace if needed.",
347                            paths.len() - missing.len(),
348                            paths.len()
349                        )
350                    } else {
351                        format!(
352                            "Start a jj-task workspace at .worktrees/{slug}. Run jj-task repo init once from the main repo if needed. Load the $jj skill for commits and PR."
353                        )
354                    };
355                    NextAction {
356                        kind: NextActionKind::RunCommand,
357                        command: Some(format!("jj-task start {slug}")),
358                        reason,
359                    }
360                }
361            }
362            VcsMode::Git => NextAction {
363                kind: NextActionKind::RunCommand,
364                command: Some("track sync".to_string()),
365                reason: format!(
366                    "Create git worktree at .worktrees/{slug} on branch track/{slug}"
367                ),
368            },
369        },
370        WorkflowPhase::Execute => {
371            let next_todo = oldest_pending_todo(todos);
372            if let Some(todo) = next_todo {
373                match vcs_mode {
374                    VcsMode::Jj => {
375                        if todo.requires_workspace {
376                            if jj_task::all_repos_registered(&slug, &paths) {
377                                return NextAction {
378                                    kind: NextActionKind::RunCommand,
379                                    command: Some(format!("cd \"$(jj-task path {slug})\"")),
380                                    reason: format!(
381                                        "Work on TODO #{} in jj-task workspace. Use $jj skill for jj commit/squash/push — not jj describe alone.",
382                                        todo.task_index
383                                    ),
384                                };
385                            }
386
387                            return NextAction {
388                                kind: NextActionKind::RunCommand,
389                                command: Some(format!("jj-task start {slug}")),
390                                reason: format!(
391                                    "Workspace required for TODO #{} — run jj-task start first",
392                                    todo.task_index
393                                ),
394                            };
395                        }
396
397                        if jj_task::slug_registered(&slug, &paths) {
398                            return NextAction {
399                                kind: NextActionKind::RunCommand,
400                                command: Some(format!("cd \"$(jj-task path {slug})\"")),
401                                reason: format!(
402                                    "Optional: work in jj-task workspace for TODO #{}",
403                                    todo.task_index
404                                ),
405                            };
406                        }
407
408                        let has_workspace = worktrees.iter().any(|wt| wt.todo_id == Some(todo.id));
409                        if todo.worktree_requested && has_workspace {
410                            return NextAction {
411                                kind: NextActionKind::RunCommand,
412                                command: Some(format!("track todo workspace {}", todo.task_index)),
413                                reason: format!(
414                                    "Legacy TODO workspace for #{}: {}",
415                                    todo.task_index, todo.content
416                                ),
417                            };
418                        }
419                    }
420                    VcsMode::Git => {
421                        if todo.requires_workspace {
422                            if let Some(repo) = repos.first() {
423                                let worktree_path =
424                                    git_worktree::git_worktree_path(&repo.repo_path, &slug);
425                                if git_worktree::git_worktree_exists(&worktree_path) {
426                                    return NextAction {
427                                        kind: NextActionKind::RunCommand,
428                                        command: Some(format!("cd \"{worktree_path}\"")),
429                                        reason: format!(
430                                            "Work on TODO #{} in git worktree. Commit and push with standard git commands.",
431                                            todo.task_index
432                                        ),
433                                    };
434                                }
435                            }
436                            return NextAction {
437                                kind: NextActionKind::RunCommand,
438                                command: Some("track sync".to_string()),
439                                reason: format!(
440                                    "Git worktree required for TODO #{} — run track sync first",
441                                    todo.task_index
442                                ),
443                            };
444                        }
445                        if let Some(repo) = repos.first() {
446                            let worktree_path =
447                                git_worktree::git_worktree_path(&repo.repo_path, &slug);
448                            if git_worktree::git_worktree_exists(&worktree_path) {
449                                return NextAction {
450                                    kind: NextActionKind::RunCommand,
451                                    command: Some(format!("cd \"{worktree_path}\"")),
452                                    reason: format!(
453                                        "Work on TODO #{} in git worktree. Commit and push with standard git commands.",
454                                        todo.task_index
455                                    ),
456                                };
457                            }
458                        }
459                    }
460                }
461
462                NextAction {
463                    kind: NextActionKind::ExecuteTodo,
464                    command: Some(format!("track todo done {}", todo.task_index)),
465                    reason: format!("Continue with TODO #{}: {}", todo.task_index, todo.content),
466                }
467            } else {
468                NextAction {
469                    kind: NextActionKind::WaitHuman,
470                    command: None,
471                    reason: "No pending TODOs found".to_string(),
472                }
473            }
474        }
475        WorkflowPhase::TaskComplete => match vcs_mode {
476            VcsMode::Jj => {
477                let had_workspace_todos = todos.iter().any(|t| t.requires_workspace);
478                let registered = jj_task::all_repos_registered(&slug, &paths);
479                let phase = jj_task::task_phase(&slug, &paths);
480                if had_workspace_todos && registered && phase.as_deref() != Some("done") {
481                    NextAction {
482                        kind: NextActionKind::UseJjSkill,
483                        command: None,
484                        reason: format!(
485                            "All TODOs done — use $jj skill to push/merge PR, then `jj-task done {slug}` and `track archive`"
486                        ),
487                    }
488                } else {
489                    NextAction {
490                        kind: NextActionKind::RunCommand,
491                        command: Some(format!("jj-task done {slug}; track archive")),
492                        reason: "All TODOs done — use $jj skill to push/merge PR, then jj-task done and track archive".to_string(),
493                    }
494                }
495            }
496            VcsMode::Git => NextAction {
497                kind: NextActionKind::RunCommand,
498                command: Some("track archive".to_string()),
499                reason: "All TODOs done — push/merge your PR with git, then archive the task".to_string(),
500            },
501        },
502        WorkflowPhase::Archived => NextAction {
503            kind: NextActionKind::WaitHuman,
504            command: None,
505            reason: "Task is archived".to_string(),
506        },
507    }
508}
509
510pub fn build_jj_context(task: &Task, repos: &[TaskRepo]) -> JjAgentContext {
511    let slug = jj_slug(task);
512    let paths = repo_paths(repos);
513    let repo_statuses = jj_task::repos_workspace_status(&slug, &paths);
514    let workspace_registered = jj_task::all_repos_registered(&slug, &paths);
515    let task_phase = jj_task::task_phase(&slug, &paths);
516    let workspace_path = if workspace_registered {
517        repo_statuses
518            .iter()
519            .find_map(|status| status.workspace_path.clone())
520    } else {
521        paths
522            .first()
523            .map(|first_repo| jj_task::expected_workspace_path(first_repo, &slug))
524    };
525
526    JjAgentContext {
527        slug: slug.clone(),
528        skill: "jj",
529        workspace_registered,
530        workspace_path,
531        task_phase,
532        repos: repo_statuses,
533        start_command: format!("jj-task start {slug}"),
534        path_command: format!("jj-task path {slug}"),
535        repo_init_command: "jj-task repo init",
536    }
537}
538
539pub fn build_git_context(task: &Task, repos: &[TaskRepo]) -> GitAgentContext {
540    let slug = jj_slug(task);
541    let branch = git_worktree::git_branch_name(&slug);
542    let workspace_path = repos
543        .first()
544        .map(|repo| git_worktree::git_worktree_path(&repo.repo_path, &slug));
545    let workspace_ready = workspace_path
546        .as_deref()
547        .is_some_and(git_worktree::git_worktree_exists);
548
549    GitAgentContext {
550        slug,
551        branch,
552        workspace_ready,
553        workspace_path,
554        sync_command: "track sync".to_string(),
555    }
556}
557
558pub fn oldest_pending_todo(todos: &[Todo]) -> Option<&Todo> {
559    todos
560        .iter()
561        .filter(|todo| todo.status == TodoStatus::Pending)
562        .min_by_key(|todo| todo.task_index)
563}
564
565pub fn workspace_lifecycle(todo: &Todo, worktrees: &[Worktree]) -> WorkspaceLifecycle {
566    if !todo.worktree_requested {
567        return WorkspaceLifecycle::NotRequested;
568    }
569
570    if todo.status == TodoStatus::Done {
571        return WorkspaceLifecycle::Merged;
572    }
573
574    if worktrees.iter().any(|wt| wt.todo_id == Some(todo.id)) {
575        WorkspaceLifecycle::Ready
576    } else {
577        WorkspaceLifecycle::Requested
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use chrono::Utc;
585
586    fn sample_task(status: TaskStatus) -> Task {
587        Task {
588            id: 1,
589            name: "Task".to_string(),
590            description: None,
591            status,
592            ticket_id: Some("PROJ-1".to_string()),
593            ticket_url: None,
594            alias: None,
595            is_today_task: false,
596            created_at: Utc::now(),
597        }
598    }
599
600    fn sample_todo(index: i64, worktree_requested: bool) -> Todo {
601        Todo {
602            id: index,
603            task_id: 1,
604            task_index: index,
605            content: format!("Todo {}", index),
606            status: TodoStatus::Pending,
607            worktree_requested,
608            requires_workspace: true,
609            created_at: Utc::now(),
610            completed_at: None,
611        }
612    }
613
614    fn sample_research_todo(index: i64) -> Todo {
615        Todo {
616            id: index,
617            task_id: 1,
618            task_index: index,
619            content: format!("Research {}", index),
620            status: TodoStatus::Pending,
621            worktree_requested: false,
622            requires_workspace: false,
623            created_at: Utc::now(),
624            completed_at: None,
625        }
626    }
627
628    fn sample_repo() -> TaskRepo {
629        TaskRepo {
630            id: 1,
631            task_id: 1,
632            task_index: 1,
633            repo_path: "/repo".to_string(),
634            base_branch: None,
635            base_commit_hash: None,
636            created_at: Utc::now(),
637        }
638    }
639
640    #[test]
641    fn workflow_phase_detects_legacy_sync_required() {
642        let task = sample_task(TaskStatus::Active);
643        let todos = vec![sample_todo(1, true)];
644        let repos = vec![sample_repo()];
645
646        assert_eq!(
647            compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
648            WorkflowPhase::SyncRequired
649        );
650    }
651
652    #[test]
653    fn workflow_phase_jj_needs_workspace_without_map() {
654        let task = sample_task(TaskStatus::Active);
655        let todos = vec![sample_todo(1, false)];
656        let repos = vec![sample_repo()];
657
658        assert_eq!(
659            compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
660            WorkflowPhase::SyncRequired
661        );
662    }
663
664    #[test]
665    fn workflow_phase_git_needs_sync_without_worktree() {
666        let task = sample_task(TaskStatus::Active);
667        let todos = vec![sample_todo(1, false)];
668        let repos = vec![sample_repo()];
669
670        assert_eq!(
671            compute_workflow_phase(VcsMode::Git, &task, &todos, &[], &repos),
672            WorkflowPhase::SyncRequired
673        );
674    }
675
676    #[test]
677    fn sync_required_action_prefers_jj_task_start() {
678        let task = sample_task(TaskStatus::Active);
679        let todos = vec![sample_todo(1, false)];
680        let repos = vec![sample_repo()];
681
682        let action = build_next_action(
683            VcsMode::Jj,
684            WorkflowPhase::SyncRequired,
685            &task,
686            &todos,
687            &[],
688            &repos,
689        );
690        assert_eq!(action.command.as_deref(), Some("jj-task start proj-1"));
691    }
692
693    #[test]
694    fn sync_required_action_uses_track_sync_in_git_mode() {
695        let task = sample_task(TaskStatus::Active);
696        let todos = vec![sample_todo(1, false)];
697        let repos = vec![sample_repo()];
698
699        let action = build_next_action(
700            VcsMode::Git,
701            WorkflowPhase::SyncRequired,
702            &task,
703            &todos,
704            &[],
705            &repos,
706        );
707        assert_eq!(action.command.as_deref(), Some("track sync"));
708    }
709
710    #[test]
711    fn build_jj_context_includes_slug_and_commands() {
712        let task = sample_task(TaskStatus::Active);
713        let ctx = build_jj_context(&task, &[]);
714        assert_eq!(ctx.slug, "proj-1");
715        assert_eq!(ctx.skill, "jj");
716        assert_eq!(ctx.start_command, "jj-task start proj-1");
717    }
718
719    #[test]
720    fn build_git_context_includes_branch_and_sync() {
721        let task = sample_task(TaskStatus::Active);
722        let repos = vec![sample_repo()];
723        let ctx = build_git_context(&task, &repos);
724        assert_eq!(ctx.slug, "proj-1");
725        assert_eq!(ctx.branch, "track/proj-1");
726        assert_eq!(ctx.sync_command, "track sync");
727        assert!(!ctx.workspace_ready);
728    }
729
730    #[test]
731    fn research_todo_skips_sync_required_without_workspace() {
732        let task = sample_task(TaskStatus::Active);
733        let todos = vec![sample_research_todo(1)];
734        let repos = vec![sample_repo()];
735
736        assert_eq!(
737            compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
738            WorkflowPhase::Execute
739        );
740    }
741
742    #[test]
743    fn guardrails_disable_jj_skill_in_git_mode() {
744        let guardrails = AgentGuardrails::for_mode(VcsMode::Git, false);
745        assert!(!guardrails.must_use_jj_skill);
746        assert!(guardrails.reopen_forbidden);
747    }
748
749    #[test]
750    fn research_todo_without_workspace_skips_sync_in_jj_mode() {
751        let task = sample_task(TaskStatus::Active);
752        let todos = vec![sample_research_todo(1)];
753        let repos = vec![sample_repo()];
754
755        assert_eq!(
756            compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
757            WorkflowPhase::Execute
758        );
759    }
760
761    #[test]
762    fn execute_action_for_no_workspace_todo_suggests_done() {
763        let task = sample_task(TaskStatus::Active);
764        let todos = vec![sample_research_todo(1)];
765        let repos = vec![sample_repo()];
766
767        let action = build_next_action(
768            VcsMode::Jj,
769            WorkflowPhase::Execute,
770            &task,
771            &todos,
772            &[],
773            &repos,
774        );
775        assert_eq!(action.kind, NextActionKind::ExecuteTodo);
776        assert_eq!(action.command.as_deref(), Some("track todo done 1"));
777    }
778
779    #[test]
780    fn next_action_kind_serializes_as_snake_case() {
781        let action = NextAction {
782            kind: NextActionKind::UseJjSkill,
783            command: None,
784            reason: "test".to_string(),
785        };
786        let json = serde_json::to_value(action).unwrap();
787        assert_eq!(json["kind"], "use_jj_skill");
788    }
789}