task-agent 0.1.311__tar.gz → 0.1.321__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {task_agent-0.1.311 → task_agent-0.1.321}/CLAUDE.md +13 -11
  2. {task_agent-0.1.311 → task_agent-0.1.321}/PKG-INFO +1 -1
  3. {task_agent-0.1.311 → task_agent-0.1.321}/pyproject.toml +2 -2
  4. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/cli.py +282 -17
  5. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/manager.py +129 -2
  6. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/mcp.py +33 -0
  7. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_cli.py +78 -0
  8. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_manager.py +31 -0
  9. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_mcp.py +41 -0
  10. {task_agent-0.1.311 → task_agent-0.1.321}/uv.lock +1 -1
  11. {task_agent-0.1.311 → task_agent-0.1.321}/.env +0 -0
  12. {task_agent-0.1.311 → task_agent-0.1.321}/.gemini/settings.json +0 -0
  13. {task_agent-0.1.311 → task_agent-0.1.321}/.github/workflows/ci.yml +0 -0
  14. {task_agent-0.1.311 → task_agent-0.1.321}/.github/workflows/publish.yml +0 -0
  15. {task_agent-0.1.311 → task_agent-0.1.321}/.gitignore +0 -0
  16. {task_agent-0.1.311 → task_agent-0.1.321}/.mise.toml +0 -0
  17. {task_agent-0.1.311 → task_agent-0.1.321}/.pre-commit-config.yaml +0 -0
  18. {task_agent-0.1.311 → task_agent-0.1.321}/.python-version +0 -0
  19. {task_agent-0.1.311 → task_agent-0.1.321}/.ta/agents/gh/meta.toml +0 -0
  20. {task_agent-0.1.311 → task_agent-0.1.321}/.ta/agents/minimal/meta.toml +0 -0
  21. {task_agent-0.1.311 → task_agent-0.1.321}/.ta/agents/uat-aws/meta.toml +0 -0
  22. {task_agent-0.1.311 → task_agent-0.1.321}/.ta/worker +0 -0
  23. {task_agent-0.1.311 → task_agent-0.1.321}/.task-agent/worktree-config.json +0 -0
  24. {task_agent-0.1.311 → task_agent-0.1.321}/GEMINI.md +0 -0
  25. {task_agent-0.1.311 → task_agent-0.1.321}/Makefile +0 -0
  26. {task_agent-0.1.311 → task_agent-0.1.321}/README.md +0 -0
  27. {task_agent-0.1.311 → task_agent-0.1.321}/agent.env +0 -0
  28. {task_agent-0.1.311 → task_agent-0.1.321}/docs/README.md +0 -0
  29. {task_agent-0.1.311 → task_agent-0.1.321}/docs/architecture/README.md +0 -0
  30. {task_agent-0.1.311 → task_agent-0.1.321}/docs/chats/gemini-conversation-1773283034591.json +0 -0
  31. {task_agent-0.1.311 → task_agent-0.1.321}/docs/chats/project-init-to-v0.1.46.md +0 -0
  32. {task_agent-0.1.311 → task_agent-0.1.321}/docs/cli/README.md +0 -0
  33. {task_agent-0.1.311 → task_agent-0.1.321}/docs/development/README.md +0 -0
  34. {task_agent-0.1.311 → task_agent-0.1.321}/docs/task-agents.md +0 -0
  35. {task_agent-0.1.311 → task_agent-0.1.321}/docs/workflow/README.md +0 -0
  36. {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/.env.example +0 -0
  37. {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/README.md +0 -0
  38. {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/instructions/manager.md +0 -0
  39. {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/instructions/validator.md +0 -0
  40. {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/instructions/worker.md +0 -0
  41. {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/profiles/default.json +0 -0
  42. {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/pyproject.toml +0 -0
  43. {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/worker.py +0 -0
  44. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/__init__.py +0 -0
  45. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/__main__.py +0 -0
  46. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/agent.py +0 -0
  47. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/audit.py +0 -0
  48. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/config.py +0 -0
  49. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/discovery.py +0 -0
  50. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/models/__init__.py +0 -0
  51. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/models/issue.py +0 -0
  52. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/models/metric.py +0 -0
  53. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/plugins/__init__.py +0 -0
  54. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/plugins/github.py +0 -0
  55. {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/templates.py +0 -0
  56. {task_agent-0.1.311 → task_agent-0.1.321}/test-dir/opencode.json +0 -0
  57. {task_agent-0.1.311 → task_agent-0.1.321}/tests/e2e/test_agent_sandboxing.sh +0 -0
  58. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_agent.py +0 -0
  59. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_cli_windows.py +0 -0
  60. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_discovery.py +0 -0
  61. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_manager_git.py +0 -0
  62. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_metrics.py +0 -0
  63. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_migration.py +0 -0
  64. {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_templates.py +0 -0
@@ -214,30 +214,32 @@ Dev dependencies:
214
214
 
215
215
  ## Release & Versioning
216
216
 
217
- Versions in `pyproject.toml` follow semver. The release workflow is **atomic**:
217
+ Versions in `pyproject.toml` follow semver. The release workflow is **two commands**:
218
218
 
219
219
  ```bash
220
- ta version promote patch # or minor/major
221
- # Auto-commits: pyproject.toml, uv.lock
222
- # Shows success/failure of commit
223
- ta version tag # Create git tag (matches code version)
224
- git push origin vX.Y.Z # Push tag to trigger CI publish
220
+ ta version promote patch # or minor/major: bumps version AND amends previous commit
221
+ ta version tag # Creates tag AND pushes to trigger CI publish
225
222
  ```
226
223
 
227
224
  ### How It Works
228
225
 
229
- 1. **`ta version promote`**: Bumps version, auto-commits with message `chore: bump version to X.Y.Z`, warns if auto-commit fails
230
- 2. **`ta version tag`**: Creates git tag matching the version in code
231
- 3. **`git push origin vX.Y.Z`**: Triggers GitHub Actions publish workflow
226
+ 1. **Commit your work** with a meaningful message (e.g., `feat: add new feature`, `fix: resolve issue`)
227
+ 2. **`ta version promote patch`**:
228
+ - Bumps version in `pyproject.toml` and `uv.lock`
229
+ - **Amends previous commit** with `--amend --no-edit` (preserves original message)
230
+ - Version bump is metadata—release notes on PyPI reflect your actual work
231
+ 3. **`ta version tag`**:
232
+ - Creates git tag matching the version in code
233
+ - Pushes tag to trigger GitHub Actions publish to PyPI
232
234
 
233
235
  ### Critical: Version-Tag Matching
234
236
 
235
- The tag **must** point to a commit where `pyproject.toml` has the matching version. If they mismatch, PyPI will reject with "File already exists" (different file hash for same version).
237
+ The tag **must** point to a commit where `pyproject.toml` has the matching version. The amended commit ensures they always stay in sync.
236
238
 
237
239
  **If publishing fails:**
238
240
  - Check PyPI version: `ta version` shows "Latest PyPI version"
241
+ - Verify with: `git show HEAD:pyproject.toml | grep version`
239
242
  - If mismatch: delete bad tag (`git tag -d vX.Y.Z`), bump to next version, re-tag
240
- - The `ta version` command queries PyPI in real-time, so you always know what's published
241
243
 
242
244
  ## Common Tasks for Claude
243
245
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: task-agent
3
- Version: 0.1.311
3
+ Version: 0.1.321
4
4
  Summary: A prioritized, file-based task queue for autonomous agentic workers
5
5
  Author-email: Mark Stouffer <1802850+InTEGr8or@users.noreply.github.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "task-agent"
3
- version = "0.1.311"
3
+ version = "0.1.321"
4
4
  description = "A prioritized, file-based task queue for autonomous agentic workers"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -39,7 +39,7 @@ dev = [
39
39
  ]
40
40
 
41
41
  [tool.bumpversion]
42
- current_version = "0.1.311"
42
+ current_version = "0.1.321"
43
43
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
44
44
  serialize = ["{major}.{minor}.{patch}"]
45
45
  search = "version = \"{current_version}\""
@@ -87,8 +87,43 @@ def display_version_info(console: Console):
87
87
  pass
88
88
 
89
89
 
90
+ def get_committed_version() -> Tuple[str, Optional[str]]:
91
+ """Read the version from HEAD (committed code), not working tree."""
92
+ files_to_check = [
93
+ ("pyproject.toml", r'version\s*=\s*"(.*?)"'),
94
+ ("package.json", None), # Special handling below
95
+ ("Cargo.toml", r'^version\s*=\s*"(.*?)"'),
96
+ ]
97
+
98
+ for filename, pattern in files_to_check:
99
+ try:
100
+ result = subprocess.run(
101
+ ["git", "show", f"HEAD:{filename}"],
102
+ capture_output=True,
103
+ text=True,
104
+ check=False,
105
+ shell=(os.name == "nt"),
106
+ )
107
+ if result.returncode == 0:
108
+ if filename == "package.json":
109
+ try:
110
+ data = json.loads(result.stdout)
111
+ if "version" in data:
112
+ return data["version"], "package.json"
113
+ except Exception:
114
+ pass
115
+ elif pattern:
116
+ match = re.search(pattern, result.stdout, re.MULTILINE)
117
+ if match:
118
+ return match.group(1), filename
119
+ except Exception:
120
+ pass
121
+
122
+ return "unknown", None
123
+
124
+
90
125
  def get_project_version(root: Optional[Path] = None) -> Tuple[str, Optional[str]]:
91
- """Read the current project version from various project files."""
126
+ """Read the current project version from various project files (working tree)."""
92
127
  root = root or Path.cwd()
93
128
 
94
129
  # Check pyproject.toml
@@ -684,16 +719,78 @@ def cmd_soft_delete(console: Console, manager: TaskAgent, slug: str):
684
719
  sys.exit(1)
685
720
 
686
721
 
722
+ def detect_current_slug_from_git() -> Optional[str]:
723
+ """Detect the current task slug from the current git branch name."""
724
+ try:
725
+ result = subprocess.run(
726
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
727
+ capture_output=True,
728
+ text=True,
729
+ check=True,
730
+ )
731
+ branch = result.stdout.strip()
732
+ if branch.startswith("issue/"):
733
+ return branch[len("issue/") :]
734
+ except Exception:
735
+ pass
736
+ return None
737
+
738
+
739
+ def find_worktree_path_for_slug(slug: str) -> Optional[Path]:
740
+ """Find the registered worktree path for a given task slug."""
741
+ try:
742
+ result = subprocess.run(
743
+ ["git", "worktree", "list", "--porcelain"],
744
+ capture_output=True,
745
+ text=True,
746
+ check=True,
747
+ )
748
+ current_worktree = None
749
+ for line in result.stdout.splitlines():
750
+ if line.startswith("worktree "):
751
+ current_worktree = Path(line[len("worktree ") :].strip())
752
+ elif line.startswith("branch refs/heads/issue/"):
753
+ branch_slug = line[len("branch refs/heads/issue/") :].strip()
754
+ if branch_slug == slug:
755
+ return current_worktree
756
+ except Exception:
757
+ pass
758
+ # Fallback to local .gwt/slug if it exists
759
+ p = Path(".gwt") / slug
760
+ if p.exists():
761
+ return p
762
+ return None
763
+
764
+
687
765
  def cmd_done(
688
766
  console: Console,
689
767
  manager: TaskAgent,
690
- slug: str,
768
+ slug: Optional[str] = None,
691
769
  commit_message: Optional[str] = None,
692
770
  should_commit: bool = True,
693
771
  push_mission: bool = False,
694
772
  solution: Optional[str] = None,
695
773
  ):
696
774
  """Mark an issue as done."""
775
+ if not slug:
776
+ slug = detect_current_slug_from_git()
777
+ if not slug:
778
+ console.print(
779
+ "[red]Error: Please specify the task slug or run this command from within the task's worktree/branch.[/red]"
780
+ )
781
+ sys.exit(1)
782
+
783
+ worktree_path = find_worktree_path_for_slug(slug)
784
+ abs_worktree = (
785
+ worktree_path.resolve() if worktree_path and worktree_path.exists() else None
786
+ )
787
+ is_cwd_inside_worktree = False
788
+ if abs_worktree:
789
+ try:
790
+ is_cwd_inside_worktree = Path.cwd().resolve().is_relative_to(abs_worktree)
791
+ except Exception:
792
+ pass
793
+
697
794
  try:
698
795
  issue, commit_hash = manager.complete_issue(
699
796
  slug,
@@ -717,6 +814,78 @@ def cmd_done(
717
814
  # Destroy per-task agent even if commit fails
718
815
  agent.destroy_per_task_agent(slug)
719
816
 
817
+ # Perform git worktree and branch cleanup after successful completion
818
+ if worktree_path and worktree_path.exists():
819
+ if is_cwd_inside_worktree:
820
+ console.print(
821
+ f"[yellow]Note: Worktree at '{worktree_path}' and branch 'issue/{slug}' were not removed because your shell is currently inside the worktree directory.[/yellow]"
822
+ )
823
+ console.print(
824
+ "[yellow]To clean up, please change directory to the main repository directory and run:[/yellow]"
825
+ )
826
+ console.print(" [bold]git worktree prune[/bold]")
827
+ console.print(f" [bold]git branch -D issue/{slug}[/bold]")
828
+ else:
829
+ console.print(f"[blue]Cleaning up git worktree for '{slug}'...[/blue]")
830
+ try:
831
+ # Remove worktree
832
+ subprocess.run(
833
+ ["git", "worktree", "remove", str(worktree_path)],
834
+ check=True,
835
+ capture_output=True,
836
+ text=True,
837
+ )
838
+ console.print(
839
+ f"[green]Successfully removed worktree at '{worktree_path}'.[/green]"
840
+ )
841
+
842
+ # Delete branch
843
+ branch_name = f"issue/{slug}"
844
+ console.print(f"[blue]Deleting local branch '{branch_name}'...[/blue]")
845
+ subprocess.run(
846
+ ["git", "branch", "-d", branch_name],
847
+ check=True,
848
+ capture_output=True,
849
+ text=True,
850
+ )
851
+ console.print(
852
+ f"[green]Successfully deleted branch '{branch_name}'.[/green]"
853
+ )
854
+ except subprocess.CalledProcessError as e:
855
+ console.print(
856
+ f"[yellow]Warning: Cleanup failed. {e.stderr.strip()}[/yellow]"
857
+ )
858
+ console.print(
859
+ f"[yellow]You can clean up manually by running: git worktree remove --force {worktree_path} && git branch -D issue/{slug}[/yellow]"
860
+ )
861
+ else:
862
+ # If worktree doesn't exist, we still try to delete the branch if it exists
863
+ branch_name = f"issue/{slug}"
864
+ try:
865
+ result = subprocess.run(
866
+ ["git", "show-ref", "--verify", f"refs/heads/{branch_name}"],
867
+ capture_output=True,
868
+ text=True,
869
+ )
870
+ if result.returncode == 0:
871
+ console.print(f"[blue]Deleting local branch '{branch_name}'...[/blue]")
872
+ subprocess.run(
873
+ ["git", "branch", "-d", branch_name],
874
+ check=True,
875
+ capture_output=True,
876
+ text=True,
877
+ )
878
+ console.print(
879
+ f"[green]Successfully deleted branch '{branch_name}'.[/green]"
880
+ )
881
+ except subprocess.CalledProcessError as e:
882
+ console.print(
883
+ f"[yellow]Warning: Could not delete branch '{branch_name}' safely: {e.stderr.strip()}[/yellow]"
884
+ )
885
+ console.print(
886
+ f"[yellow]Run 'git branch -D {branch_name}' to force delete it.[/yellow]"
887
+ )
888
+
720
889
 
721
890
  def cmd_push(console: Console, manager: TaskAgent):
722
891
  """Push the mission repository."""
@@ -1382,9 +1551,28 @@ def cmd_active(
1382
1551
  manager: TaskAgent,
1383
1552
  slug_part: Optional[str] = None,
1384
1553
  silent: bool = False,
1554
+ list_if_none: bool = False,
1385
1555
  ) -> Optional[Issue]:
1386
- """Move an issue to active status."""
1556
+ """Move an issue to active status, or list active tasks."""
1387
1557
  issues = manager.load_mission()
1558
+ if not slug_part and list_if_none:
1559
+ active_issues = [i for i in issues if i.status == "active"]
1560
+ if not silent:
1561
+ if active_issues:
1562
+ from rich.table import Table
1563
+
1564
+ table = Table(title="Active Tasks")
1565
+ table.add_column("Priority")
1566
+ table.add_column("Slug")
1567
+ table.add_column("Dependencies")
1568
+ for i in active_issues:
1569
+ deps = ", ".join(i.dependencies) if i.dependencies else "-"
1570
+ table.add_row(str(i.priority), i.slug, deps)
1571
+ console.print(table)
1572
+ else:
1573
+ console.print("[yellow]No active tasks found.[/yellow]")
1574
+ return None
1575
+
1388
1576
  target = select_issue(
1389
1577
  console, issues, slug_part, status_filter=["pending", "draft", "active"]
1390
1578
  )
@@ -1410,6 +1598,38 @@ def cmd_active(
1410
1598
  return None
1411
1599
 
1412
1600
 
1601
+ def cmd_update(
1602
+ console: Console,
1603
+ manager: TaskAgent,
1604
+ slug_part: str,
1605
+ depends_on: Optional[str] = None,
1606
+ ):
1607
+ """Update task properties."""
1608
+ issues = manager.load_mission()
1609
+ target = select_issue(
1610
+ console, issues, slug_part, status_filter=["pending", "draft", "active"]
1611
+ )
1612
+ if not target:
1613
+ console.print(
1614
+ f"[red]No active/pending/draft task found matching '{slug_part}'.[/red]"
1615
+ )
1616
+ sys.exit(1)
1617
+
1618
+ if depends_on is not None:
1619
+ try:
1620
+ manager.update_dependencies(target.slug, depends_on)
1621
+ console.print(
1622
+ f"[bold green]Successfully updated dependencies for task '{target.slug}'.[/bold green]"
1623
+ )
1624
+ except Exception as e:
1625
+ console.print(f"[red]Error: {e}[/red]")
1626
+ sys.exit(1)
1627
+ else:
1628
+ console.print(
1629
+ "[yellow]No updates specified. Use --depends-on to update dependencies.[/yellow]"
1630
+ )
1631
+
1632
+
1413
1633
  def cmd_start(
1414
1634
  console: Console,
1415
1635
  manager: TaskAgent,
@@ -1855,13 +2075,24 @@ def cmd_version(
1855
2075
  display_version_info(console)
1856
2076
 
1857
2077
  if tag:
1858
- v, _ = get_project_version()
2078
+ v, source = get_committed_version()
1859
2079
  if v == "unknown":
1860
2080
  console.print(
1861
2081
  "[red]Error: Could not determine project version to tag.[/red]"
1862
2082
  )
1863
2083
  return
1864
2084
 
2085
+ # Warn if working tree differs from committed version
2086
+ working_v, _ = get_project_version()
2087
+ if working_v != v:
2088
+ console.print(
2089
+ f"[yellow]Warning: Working tree has version {working_v}, "
2090
+ f"but HEAD has {v}[/yellow]"
2091
+ )
2092
+ console.print(
2093
+ "[yellow]Committing changes with 'ta version promote' first is recommended.[/yellow]"
2094
+ )
2095
+
1865
2096
  tag_name = f"v{v}"
1866
2097
  result = subprocess.run(
1867
2098
  ["git", "tag", tag_name],
@@ -1911,7 +2142,7 @@ def cmd_version(
1911
2142
  new_v, _ = get_project_version()
1912
2143
  console.print(f"[bold green]Promoted to version {new_v}[/bold green]")
1913
2144
 
1914
- # Auto-commit version bump
2145
+ # Auto-amend version bump into previous commit
1915
2146
  try:
1916
2147
  for file in ["pyproject.toml", "package.json", "uv.lock"]:
1917
2148
  if Path(file).exists():
@@ -1922,30 +2153,30 @@ def cmd_version(
1922
2153
  shell=(os.name == "nt"),
1923
2154
  )
1924
2155
  result = subprocess.run(
1925
- ["git", "commit", "-m", f"chore: bump version to {new_v}"],
2156
+ ["git", "commit", "--amend", "--no-edit"],
1926
2157
  capture_output=True,
1927
2158
  shell=(os.name == "nt"),
1928
2159
  )
1929
2160
  if result.returncode == 0:
1930
- console.print("[dim]Committed version bump[/dim]")
2161
+ console.print("[dim]Version bump amended into previous commit[/dim]")
1931
2162
  elif (
1932
2163
  b"nothing to commit" in result.stdout
1933
2164
  or b"nothing to commit" in result.stderr
1934
2165
  ):
1935
2166
  console.print(
1936
- "[yellow]No changes to commit (already committed?)[/yellow]"
2167
+ "[yellow]No changes to commit (already amended?)[/yellow]"
1937
2168
  )
1938
2169
  else:
1939
2170
  console.print(
1940
- "[yellow]Warning: Could not auto-commit version bump. "
1941
- "Run 'git add' and 'git commit' manually.[/yellow]"
2171
+ "[yellow]Warning: Could not auto-amend version bump. "
2172
+ "Run 'git add' and 'git commit --amend --no-edit' manually.[/yellow]"
1942
2173
  )
1943
2174
  if result.stderr:
1944
2175
  console.print(f"[dim]{result.stderr.decode()}[/dim]")
1945
2176
  except Exception as e:
1946
2177
  console.print(
1947
- f"[yellow]Warning: Auto-commit failed: {e}. "
1948
- "Run 'git commit' manually.[/yellow]"
2178
+ f"[yellow]Warning: Auto-amend failed: {e}. "
2179
+ "Run 'git commit --amend --no-edit' manually.[/yellow]"
1949
2180
  )
1950
2181
 
1951
2182
 
@@ -2870,10 +3101,42 @@ def main():
2870
3101
  promote_parser.add_argument("slug")
2871
3102
  demote_parser = subparsers.add_parser("demote")
2872
3103
  demote_parser.add_argument("slug")
2873
- active_parser = subparsers.add_parser("active")
2874
- active_parser.add_argument("slug", nargs="?")
2875
- start_parser = subparsers.add_parser("start")
2876
- start_parser.add_argument("slug", nargs="?")
3104
+ active_parser = subparsers.add_parser(
3105
+ "active",
3106
+ help="Move an issue to active status, or list active tasks if no slug is provided",
3107
+ )
3108
+ active_parser.add_argument(
3109
+ "slug", nargs="?", help="Optional slug of the task to mark as active"
3110
+ )
3111
+ update_parser = subparsers.add_parser(
3112
+ "update", help="Update task properties (e.g. dependencies)"
3113
+ )
3114
+ update_parser.add_argument("slug", help="Slug of the task to update")
3115
+ update_parser.add_argument(
3116
+ "-d",
3117
+ "--depends-on",
3118
+ help="Comma-separated list of task slugs this task depends on (use empty string to clear)",
3119
+ )
3120
+ start_parser = subparsers.add_parser(
3121
+ "start",
3122
+ help="Activate a task and set up its git worktree/branch",
3123
+ description="""
3124
+ Start working on a task. This command automates the following workflow:
3125
+ 1. Marks the task (identified by its slug) as ACTIVE in the task manager.
3126
+ If no slug is provided, prompts you to select one from pending tasks.
3127
+ 2. Creates a new git branch named 'issue/<slug>' (branched from main/current).
3128
+ 3. Creates and checks out a new git worktree located at '.gwt/<slug>'.
3129
+ 4. If --agent is specified:
3130
+ - If it matches a template in '.ta/agents/<agent_name>/meta.toml',
3131
+ creates a dedicated per-task agent user 'agent-<slug>-<agent_name>'.
3132
+ - Otherwise, configures worktree permissions for an existing agent user.
3133
+ 5. If --run is specified, immediately runs the sidecar worker on the worktree.
3134
+ """,
3135
+ formatter_class=argparse.RawDescriptionHelpFormatter,
3136
+ )
3137
+ start_parser.add_argument(
3138
+ "slug", nargs="?", help="Slug or partial slug of the task to start"
3139
+ )
2877
3140
  start_parser.add_argument(
2878
3141
  "--run", action="store_true", help="Immediately run the sidecar worker"
2879
3142
  )
@@ -3094,7 +3357,9 @@ def main():
3094
3357
  elif args.command == "demote":
3095
3358
  cmd_demote(console, manager, args.slug)
3096
3359
  elif args.command == "active":
3097
- cmd_active(console, manager, args.slug)
3360
+ cmd_active(console, manager, args.slug, list_if_none=True)
3361
+ elif args.command == "update":
3362
+ cmd_update(console, manager, args.slug, depends_on=args.depends_on)
3098
3363
  elif args.command == "start":
3099
3364
  cmd_start(console, manager, args.slug, run=args.run, agent_name=args.agent)
3100
3365
  elif args.command == "run":
@@ -35,6 +35,36 @@ class TaskAgent:
35
35
  except subprocess.CalledProcessError:
36
36
  return None
37
37
 
38
+ @staticmethod
39
+ def _check_dependency_cycle(
40
+ issues: List[Issue], target_slug: str, new_deps: List[str]
41
+ ) -> bool:
42
+ """Check if adding new_deps to target_slug creates a cycle.
43
+
44
+ Returns True if a cycle is detected, False otherwise.
45
+ """
46
+ dep_map = {i.slug: list(i.dependencies) for i in issues}
47
+ dep_map[target_slug] = list(new_deps)
48
+ visited = {} # None: unvisited, 0: visiting, 1: visited
49
+
50
+ def has_cycle(node: str) -> bool:
51
+ visited[node] = 0
52
+ for neighbor in dep_map.get(node, []):
53
+ state = visited.get(neighbor)
54
+ if state == 0:
55
+ return True
56
+ elif state is None:
57
+ if has_cycle(neighbor):
58
+ return True
59
+ visited[node] = 1
60
+ return False
61
+
62
+ for node in dep_map:
63
+ if visited.get(node) is None:
64
+ if has_cycle(node):
65
+ return True
66
+ return False
67
+
38
68
  @property
39
69
  def is_dual_repo(self) -> bool:
40
70
  """Check if mission files live in a different repo than the code."""
@@ -1019,10 +1049,27 @@ class TaskAgent:
1019
1049
  if not issue_file:
1020
1050
  raise FileNotFoundError(f"Issue '{slug}' not found.")
1021
1051
 
1052
+ # Extract temporary deps from new content to validate them
1053
+ match = re.search(r"\*\*Depends on:\*\*\s*(.*)", content)
1054
+ new_deps = []
1055
+ if match:
1056
+ new_deps = [d.strip() for d in match.group(1).split(",") if d.strip()]
1057
+
1058
+ if slug in new_deps:
1059
+ raise ValueError("A task cannot depend on itself.")
1060
+
1061
+ issues = self.load_mission()
1062
+ existing_slugs = {i.slug for i in issues}
1063
+ for dep in new_deps:
1064
+ if dep not in existing_slugs:
1065
+ raise ValueError(f"Dependency task '{dep}' does not exist.")
1066
+
1067
+ if self._check_dependency_cycle(issues, slug, new_deps):
1068
+ raise ValueError("Adding these dependencies would introduce a cycle.")
1069
+
1022
1070
  issue_file.write_text(content, encoding="utf-8")
1023
1071
 
1024
1072
  # Re-extract name and deps in case they changed
1025
- issues = self.load_mission()
1026
1073
  updated = False
1027
1074
  for i in issues:
1028
1075
  if i.slug == slug:
@@ -1040,7 +1087,87 @@ class TaskAgent:
1040
1087
  return i
1041
1088
 
1042
1089
  # If it was completed, it's not in mission.usv
1043
- return Issue(name=self.extract_title(issue_file), slug=slug, status="completed")
1090
+ return Issue(
1091
+ name=self.extract_title(issue_file),
1092
+ slug=slug,
1093
+ dependencies=self.extract_deps(issue_file),
1094
+ status="completed",
1095
+ )
1096
+
1097
+ def update_dependencies(self, slug: str, depends_on: str) -> Issue:
1098
+ """Update dependencies of an issue."""
1099
+ issue_file = self.find_issue_file(slug, include_completed=True)
1100
+ if not issue_file:
1101
+ raise FileNotFoundError(f"Issue '{slug}' not found.")
1102
+
1103
+ # Parse new dependencies
1104
+ new_deps = [d.strip() for d in depends_on.split(",") if d.strip()]
1105
+
1106
+ if slug in new_deps:
1107
+ raise ValueError("A task cannot depend on itself.")
1108
+
1109
+ issues = self.load_mission()
1110
+ existing_slugs = {i.slug for i in issues}
1111
+ for dep in new_deps:
1112
+ if dep not in existing_slugs:
1113
+ raise ValueError(f"Dependency task '{dep}' does not exist.")
1114
+
1115
+ # Check for cycles
1116
+ if self._check_dependency_cycle(issues, slug, new_deps):
1117
+ raise ValueError("Adding these dependencies would introduce a cycle.")
1118
+
1119
+ # Update Markdown content
1120
+ content = issue_file.read_text(encoding="utf-8")
1121
+ if re.search(r"\*\*Depends on:\*\*\s*(.*)", content):
1122
+ if new_deps:
1123
+ content = re.sub(
1124
+ r"\*\*Depends on:\*\*\s*(.*)",
1125
+ f"**Depends on:** {', '.join(new_deps)}",
1126
+ content,
1127
+ )
1128
+ else:
1129
+ # Remove the line and any trailing blank lines
1130
+ content = re.sub(r"\*\*Depends on:\*\*\s*(.*)\n*", "", content)
1131
+ else:
1132
+ if new_deps:
1133
+ # Insert it right after the H1 title
1134
+ lines = content.splitlines()
1135
+ inserted = False
1136
+ for idx, line in enumerate(lines):
1137
+ if line.strip().startswith("# "):
1138
+ lines.insert(idx + 1, "")
1139
+ lines.insert(idx + 2, f"**Depends on:** {', '.join(new_deps)}")
1140
+ inserted = True
1141
+ break
1142
+ if inserted:
1143
+ content = "\n".join(lines) + "\n"
1144
+ else:
1145
+ content = f"**Depends on:** {', '.join(new_deps)}\n\n" + content
1146
+
1147
+ issue_file.write_text(content, encoding="utf-8")
1148
+
1149
+ # Sync and reload
1150
+ updated = False
1151
+ for i in issues:
1152
+ if i.slug == slug:
1153
+ i.dependencies = new_deps
1154
+ updated = True
1155
+ break
1156
+
1157
+ if updated:
1158
+ self.save_mission(issues)
1159
+
1160
+ # Return the updated issue object
1161
+ for i in issues:
1162
+ if i.slug == slug:
1163
+ return i
1164
+
1165
+ return Issue(
1166
+ name=self.extract_title(issue_file),
1167
+ slug=slug,
1168
+ dependencies=new_deps,
1169
+ status="completed",
1170
+ )
1044
1171
 
1045
1172
  def ingest_issues(self) -> Tuple[int, int]:
1046
1173
  """Ingest existing markdown files. Returns (num_new, num_removed)."""
@@ -37,6 +37,22 @@ def list_tasks() -> str:
37
37
  return "\n".join(lines)
38
38
 
39
39
 
40
+ @mcp.tool()
41
+ def list_active_tasks() -> str:
42
+ """List only the currently active tasks in the mission queue."""
43
+ manager = get_manager()
44
+ issues = manager.sync_mission()
45
+ active_issues = [i for i in issues if i.status == "active"]
46
+ if not active_issues:
47
+ return "No active tasks found."
48
+
49
+ lines = []
50
+ for i in active_issues:
51
+ deps = f" (depends on: {', '.join(i.dependencies)})" if i.dependencies else ""
52
+ lines.append(f"[{i.priority}] ACTIVE: {i.name}{deps}")
53
+ return "\n".join(lines)
54
+
55
+
40
56
  @mcp.tool()
41
57
  def create_task(
42
58
  title: str,
@@ -226,6 +242,23 @@ def update_task(name: str, content: str) -> str:
226
242
  return f"Error updating task: {e}"
227
243
 
228
244
 
245
+ @mcp.tool()
246
+ def update_task_dependencies(name: str, depends_on: str) -> str:
247
+ """Update the dependencies of a task.
248
+
249
+ Args:
250
+ name: The title or partial name of the task to update.
251
+ depends_on: Comma-separated list of task slugs this task depends on (use empty string to clear).
252
+ """
253
+ manager = get_manager()
254
+ slug = manager.slugify(name)
255
+ try:
256
+ manager.update_dependencies(slug, depends_on)
257
+ return f"Successfully updated dependencies for task '{slug}'."
258
+ except Exception as e:
259
+ return f"Error updating task dependencies: {e}"
260
+
261
+
229
262
  @mcp.tool()
230
263
  def commit_repo(message: str = "", push: bool = False) -> str:
231
264
  """Commit changes to the current project's tasks directory (host repo).
@@ -1,4 +1,5 @@
1
1
  import pytest
2
+ from pathlib import Path
2
3
  from taskagent.cli import (
3
4
  cmd_new,
4
5
  cmd_done,
@@ -285,3 +286,80 @@ def test_cmd_init_mcp_claude(tmp_path):
285
286
  assert call_args[4] == "--"
286
287
  assert call_args[5] == "uv"
287
288
  assert "run" in call_args
289
+
290
+
291
+ def test_detect_current_slug_from_git():
292
+ from unittest.mock import patch, MagicMock
293
+ from taskagent.cli import detect_current_slug_from_git
294
+
295
+ with patch("subprocess.run") as mock_run:
296
+ mock_run.return_value = MagicMock(stdout="issue/my-cool-task\n", returncode=0)
297
+ assert detect_current_slug_from_git() == "my-cool-task"
298
+
299
+ mock_run.return_value = MagicMock(stdout="main\n", returncode=0)
300
+ assert detect_current_slug_from_git() is None
301
+
302
+ mock_run.side_effect = Exception("git failed")
303
+ assert detect_current_slug_from_git() is None
304
+
305
+
306
+ def test_find_worktree_path_for_slug(tmp_path):
307
+ from unittest.mock import patch, MagicMock
308
+ from taskagent.cli import find_worktree_path_for_slug
309
+
310
+ with patch("subprocess.run") as mock_run:
311
+ mock_output = (
312
+ "worktree /path/to/main-repo\n"
313
+ "branch refs/heads/main\n"
314
+ "\n"
315
+ "worktree /path/to/worktrees/some-slug\n"
316
+ "branch refs/heads/issue/some-slug\n"
317
+ )
318
+ mock_run.return_value = MagicMock(stdout=mock_output, returncode=0)
319
+ assert find_worktree_path_for_slug("some-slug") == Path(
320
+ "/path/to/worktrees/some-slug"
321
+ )
322
+ assert find_worktree_path_for_slug("other-slug") is None
323
+
324
+
325
+ def test_cmd_done_cleanup(manager, temp_issues_dir, tmp_path):
326
+ from unittest.mock import patch
327
+ from taskagent.cli import cmd_done
328
+
329
+ console = Console()
330
+ cmd_new(console, manager, "Done Clean Task", "Body", draft=False)
331
+
332
+ # Let's mock subprocess.run to simulate:
333
+ # 1. find_worktree_path_for_slug finds a registered worktree under tmp_path
334
+ # 2. CWD is NOT inside the worktree
335
+ # 3. Removing the worktree and branch succeeds
336
+ worktree_mock_path = tmp_path / "gwt-mock"
337
+ worktree_mock_path.mkdir()
338
+
339
+ mock_worktree_output = (
340
+ f"worktree {worktree_mock_path}\nbranch refs/heads/issue/done-clean-task\n"
341
+ )
342
+
343
+ def mock_run(cmd, **kwargs):
344
+ cmd_str = " ".join(cmd) if isinstance(cmd, list) else cmd
345
+
346
+ class MockCompletedProcess:
347
+ returncode = 0
348
+ stdout = ""
349
+ stderr = ""
350
+
351
+ res = MockCompletedProcess()
352
+ if "worktree list" in cmd_str:
353
+ res.stdout = mock_worktree_output
354
+ elif "worktree remove" in cmd_str:
355
+ # Delete mock directory to simulate success
356
+ import shutil
357
+
358
+ shutil.rmtree(worktree_mock_path)
359
+ elif "rev-parse" in cmd_str:
360
+ res.stdout = "abcdef12"
361
+ return res
362
+
363
+ with patch("subprocess.run", side_effect=mock_run):
364
+ cmd_done(console, manager, "done-clean-task", should_commit=False)
365
+ assert not worktree_mock_path.exists()
@@ -353,3 +353,34 @@ def test_api_soft_delete(manager):
353
353
  # Should be removed from mission
354
354
  issues = manager.load_mission()
355
355
  assert not any(i.slug == "delete-me" for i in issues)
356
+
357
+
358
+ def test_api_update_dependencies(manager):
359
+ manager.create_issue("Task A")
360
+ manager.create_issue("Task B")
361
+ manager.create_issue("Task C")
362
+
363
+ # Update dependencies of B to depend on A
364
+ manager.update_dependencies("task-b", "task-a")
365
+ issue_file = manager.find_issue_file("task-b")
366
+ assert manager.extract_deps(issue_file) == ["task-a"]
367
+
368
+ # Try updating B to depend on C and A
369
+ manager.update_dependencies("task-b", "task-c, task-a")
370
+ assert manager.extract_deps(issue_file) == ["task-c", "task-a"]
371
+
372
+ # Try introducing a self-loop (should fail)
373
+ with pytest.raises(ValueError, match="cannot depend on itself"):
374
+ manager.update_dependencies("task-b", "task-b")
375
+
376
+ # Try introducing a cycle: A depends on B (B already depends on A)
377
+ with pytest.raises(ValueError, match="introduce a cycle"):
378
+ manager.update_dependencies("task-a", "task-b")
379
+
380
+ # Try updating with non-existent task
381
+ with pytest.raises(ValueError, match="does not exist"):
382
+ manager.update_dependencies("task-b", "non-existent-task")
383
+
384
+ # Clear dependencies
385
+ manager.update_dependencies("task-b", "")
386
+ assert manager.extract_deps(issue_file) == []
@@ -159,8 +159,48 @@ def test_mcp_commit_tasks_success():
159
159
  assert "Committed: test commit" in result
160
160
 
161
161
 
162
+ def test_mcp_list_active_tasks(monkeypatch):
163
+ class DummyIssue:
164
+ def __init__(self, priority, status, name, dependencies=None):
165
+ self.priority = priority
166
+ self.status = status
167
+ self.name = name
168
+ self.dependencies = dependencies or []
169
+
170
+ class DummyManager:
171
+ def sync_mission(self):
172
+ return [
173
+ DummyIssue(1, "active", "Task 1"),
174
+ DummyIssue(2, "pending", "Task 2"),
175
+ DummyIssue(3, "active", "Task 3", ["Task 1"]),
176
+ ]
177
+
178
+ monkeypatch.setattr(mcp, "get_manager", lambda: DummyManager())
179
+ result = mcp.list_active_tasks()
180
+ assert "[1] ACTIVE: Task 1" in result
181
+ assert "Task 2" not in result
182
+ assert "[3] ACTIVE: Task 3 (depends on: Task 1)" in result
183
+
184
+
185
+ def test_mcp_update_task_dependencies(monkeypatch):
186
+ called = []
187
+
188
+ class DummyManager:
189
+ def slugify(self, name):
190
+ return name.lower().replace(" ", "-")
191
+
192
+ def update_dependencies(self, slug, depends_on):
193
+ called.append((slug, depends_on))
194
+
195
+ monkeypatch.setattr(mcp, "get_manager", lambda: DummyManager())
196
+ result = mcp.update_task_dependencies("Task One", "task-two,task-three")
197
+ assert result == "Successfully updated dependencies for task 'task-one'."
198
+ assert called == [("task-one", "task-two,task-three")]
199
+
200
+
162
201
  EXPECTED_TOOLS = {
163
202
  "list_tasks",
203
+ "list_active_tasks",
164
204
  "create_task",
165
205
  "promote_task",
166
206
  "demote_task",
@@ -170,6 +210,7 @@ EXPECTED_TOOLS = {
170
210
  "restore_task",
171
211
  "get_task_details",
172
212
  "update_task",
213
+ "update_task_dependencies",
173
214
  "commit_repo",
174
215
  "commit_tasks",
175
216
  }
@@ -1080,7 +1080,7 @@ wheels = [
1080
1080
 
1081
1081
  [[package]]
1082
1082
  name = "task-agent"
1083
- version = "0.1.311"
1083
+ version = "0.1.321"
1084
1084
  source = { editable = "." }
1085
1085
  dependencies = [
1086
1086
  { name = "githubkit" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes