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.
- {task_agent-0.1.311 → task_agent-0.1.321}/CLAUDE.md +13 -11
- {task_agent-0.1.311 → task_agent-0.1.321}/PKG-INFO +1 -1
- {task_agent-0.1.311 → task_agent-0.1.321}/pyproject.toml +2 -2
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/cli.py +282 -17
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/manager.py +129 -2
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/mcp.py +33 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_cli.py +78 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_manager.py +31 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_mcp.py +41 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/uv.lock +1 -1
- {task_agent-0.1.311 → task_agent-0.1.321}/.env +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.gemini/settings.json +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.github/workflows/ci.yml +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.github/workflows/publish.yml +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.gitignore +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.mise.toml +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.pre-commit-config.yaml +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.python-version +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.ta/agents/gh/meta.toml +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.ta/agents/minimal/meta.toml +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.ta/agents/uat-aws/meta.toml +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.ta/worker +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/.task-agent/worktree-config.json +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/GEMINI.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/Makefile +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/README.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/agent.env +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/docs/README.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/docs/architecture/README.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/docs/chats/gemini-conversation-1773283034591.json +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/docs/chats/project-init-to-v0.1.46.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/docs/cli/README.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/docs/development/README.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/docs/task-agents.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/docs/workflow/README.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/.env.example +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/README.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/instructions/manager.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/instructions/validator.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/instructions/worker.md +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/profiles/default.json +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/pyproject.toml +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/sidecars/adk-worker/worker.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/__init__.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/__main__.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/agent.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/audit.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/config.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/discovery.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/models/__init__.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/models/issue.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/models/metric.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/plugins/__init__.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/plugins/github.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/src/taskagent/templates.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/test-dir/opencode.json +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/e2e/test_agent_sandboxing.sh +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_agent.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_cli_windows.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_discovery.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_manager_git.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_metrics.py +0 -0
- {task_agent-0.1.311 → task_agent-0.1.321}/tests/test_migration.py +0 -0
- {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 **
|
|
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
|
-
|
|
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.
|
|
230
|
-
2. **`ta version
|
|
231
|
-
|
|
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.
|
|
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
|
[project]
|
|
2
2
|
name = "task-agent"
|
|
3
|
-
version = "0.1.
|
|
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.
|
|
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,
|
|
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-
|
|
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", "
|
|
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]
|
|
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
|
|
2167
|
+
"[yellow]No changes to commit (already amended?)[/yellow]"
|
|
1937
2168
|
)
|
|
1938
2169
|
else:
|
|
1939
2170
|
console.print(
|
|
1940
|
-
"[yellow]Warning: Could not auto-
|
|
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-
|
|
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(
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
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(
|
|
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
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|