docs: 更新 HTTP 错误码、用户积分、占卜运行及用户资料协议文档
This commit is contained in:
Executable
+5
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Multi-Agent Pipeline Scripts.
|
||||
|
||||
This module provides orchestration for multi-agent workflows.
|
||||
"""
|
||||
Executable
+403
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Pipeline: Cleanup Worktree.
|
||||
|
||||
Usage:
|
||||
python3 cleanup.py <branch-name> Remove specific worktree
|
||||
python3 cleanup.py --list List all worktrees
|
||||
python3 cleanup.py --merged Remove merged worktrees
|
||||
python3 cleanup.py --all Remove all worktrees (with confirmation)
|
||||
|
||||
Options:
|
||||
-y, --yes Skip confirmation prompts
|
||||
--keep-branch Don't delete the git branch
|
||||
|
||||
This script:
|
||||
1. Archives task directory to archive/{YYYY-MM}/
|
||||
2. Removes agent from registry
|
||||
3. Removes git worktree
|
||||
4. Optionally deletes git branch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from common.git_context import _run_git_command
|
||||
from common.paths import get_repo_root
|
||||
from common.registry import (
|
||||
registry_get_file,
|
||||
registry_get_task_dir,
|
||||
registry_remove_by_id,
|
||||
registry_remove_by_worktree,
|
||||
registry_search_agent,
|
||||
)
|
||||
from common.task_utils import (
|
||||
archive_task_complete,
|
||||
is_safe_task_path,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Colors
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Colors:
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m"
|
||||
|
||||
|
||||
def log_info(msg: str) -> None:
|
||||
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str) -> None:
|
||||
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_warn(msg: str) -> None:
|
||||
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_error(msg: str) -> None:
|
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def confirm(prompt: str, skip_confirm: bool) -> bool:
|
||||
"""Ask for confirmation."""
|
||||
if skip_confirm:
|
||||
return True
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
log_error("Non-interactive mode detected. Use -y to skip confirmation.")
|
||||
return False
|
||||
|
||||
response = input(f"{prompt} [y/N] ")
|
||||
return response.lower() in ("y", "yes")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Commands
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def cmd_list(repo_root: Path) -> int:
|
||||
"""List worktrees."""
|
||||
print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}")
|
||||
print()
|
||||
|
||||
subprocess.run(["git", "worktree", "list"], cwd=repo_root)
|
||||
print()
|
||||
|
||||
# Show registry info
|
||||
registry_file = registry_get_file(repo_root)
|
||||
if registry_file and registry_file.is_file():
|
||||
print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}")
|
||||
print()
|
||||
|
||||
import json
|
||||
|
||||
data = json.loads(registry_file.read_text(encoding="utf-8"))
|
||||
agents = data.get("agents", [])
|
||||
|
||||
if agents:
|
||||
for agent in agents:
|
||||
print(
|
||||
f" {agent.get('id', '?')}: PID={agent.get('pid', '?')} [{agent.get('worktree_path', '?')}]"
|
||||
)
|
||||
else:
|
||||
print(" (none)")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def archive_task(worktree_path: str, repo_root: Path) -> None:
|
||||
"""Archive task directory."""
|
||||
task_dir = registry_get_task_dir(worktree_path, repo_root)
|
||||
|
||||
if not task_dir or not is_safe_task_path(task_dir, repo_root):
|
||||
return
|
||||
|
||||
task_dir_abs = repo_root / task_dir
|
||||
if not task_dir_abs.is_dir():
|
||||
return
|
||||
|
||||
result = archive_task_complete(task_dir_abs, repo_root)
|
||||
if "archived_to" in result:
|
||||
dest = Path(result["archived_to"])
|
||||
log_success(f"Archived task: {dest.name} -> archive/{dest.parent.name}/")
|
||||
|
||||
|
||||
def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> int:
|
||||
"""Cleanup from registry only (no worktree)."""
|
||||
agent_info = registry_search_agent(search, repo_root)
|
||||
|
||||
if not agent_info:
|
||||
log_error(f"No agent found in registry matching: {search}")
|
||||
return 1
|
||||
|
||||
agent_id = agent_info.get("id", "?")
|
||||
task_dir = agent_info.get("task_dir", "?")
|
||||
|
||||
print()
|
||||
print(f"{Colors.BLUE}=== Cleanup Agent (no worktree) ==={Colors.NC}")
|
||||
print(f" Agent ID: {agent_id}")
|
||||
print(f" Task Dir: {task_dir}")
|
||||
print()
|
||||
|
||||
if not confirm("Archive task and remove from registry?", skip_confirm):
|
||||
log_info("Aborted")
|
||||
return 0
|
||||
|
||||
# Archive task directory if exists
|
||||
if task_dir and is_safe_task_path(task_dir, repo_root):
|
||||
task_dir_abs = repo_root / task_dir
|
||||
if task_dir_abs.is_dir():
|
||||
result = archive_task_complete(task_dir_abs, repo_root)
|
||||
if "archived_to" in result:
|
||||
dest = Path(result["archived_to"])
|
||||
log_success(
|
||||
f"Archived task: {dest.name} -> archive/{dest.parent.name}/"
|
||||
)
|
||||
else:
|
||||
log_warn("Invalid task_dir in registry, skipping archive")
|
||||
|
||||
# Remove from registry
|
||||
registry_remove_by_id(agent_id, repo_root)
|
||||
log_success(f"Removed from registry: {agent_id}")
|
||||
|
||||
log_success("Cleanup complete")
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_worktree(
|
||||
branch: str, repo_root: Path, skip_confirm: bool, keep_branch: bool
|
||||
) -> int:
|
||||
"""Cleanup single worktree."""
|
||||
# Find worktree path for branch
|
||||
_, worktree_list, _ = _run_git_command(
|
||||
["worktree", "list", "--porcelain"], cwd=repo_root
|
||||
)
|
||||
|
||||
worktree_path = None
|
||||
current_worktree = None
|
||||
|
||||
for line in worktree_list.splitlines():
|
||||
if line.startswith("worktree "):
|
||||
current_worktree = line[9:] # Remove "worktree " prefix
|
||||
elif line.startswith("branch refs/heads/"):
|
||||
current_branch = line[18:] # Remove "branch refs/heads/" prefix
|
||||
if current_branch == branch:
|
||||
worktree_path = current_worktree
|
||||
break
|
||||
|
||||
if not worktree_path:
|
||||
# No worktree found, try to cleanup from registry only
|
||||
log_warn(f"No worktree found for: {branch}")
|
||||
log_info("Trying to cleanup from registry...")
|
||||
return cleanup_registry_only(branch, repo_root, skip_confirm)
|
||||
|
||||
print()
|
||||
print(f"{Colors.BLUE}=== Cleanup Worktree ==={Colors.NC}")
|
||||
print(f" Branch: {branch}")
|
||||
print(f" Worktree: {worktree_path}")
|
||||
print()
|
||||
|
||||
if not confirm("Remove this worktree?", skip_confirm):
|
||||
log_info("Aborted")
|
||||
return 0
|
||||
|
||||
# 1. Archive task
|
||||
archive_task(worktree_path, repo_root)
|
||||
|
||||
# 2. Remove from registry
|
||||
registry_remove_by_worktree(worktree_path, repo_root)
|
||||
log_info("Removed from registry")
|
||||
|
||||
# 3. Remove worktree
|
||||
log_info("Removing worktree...")
|
||||
ret, _, _ = _run_git_command(
|
||||
["worktree", "remove", worktree_path, "--force"], cwd=repo_root
|
||||
)
|
||||
if ret != 0:
|
||||
# Try removing directory manually
|
||||
try:
|
||||
shutil.rmtree(worktree_path)
|
||||
except Exception as e:
|
||||
log_error(f"Failed to remove worktree: {e}")
|
||||
|
||||
log_success("Worktree removed")
|
||||
|
||||
# 4. Delete branch (optional)
|
||||
if not keep_branch:
|
||||
log_info("Deleting branch...")
|
||||
ret, _, _ = _run_git_command(["branch", "-D", branch], cwd=repo_root)
|
||||
if ret != 0:
|
||||
log_warn("Could not delete branch (may be checked out elsewhere)")
|
||||
|
||||
log_success(f"Cleanup complete for: {branch}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
|
||||
"""Cleanup merged worktrees."""
|
||||
# Get main branch
|
||||
_, head_out, _ = _run_git_command(
|
||||
["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root
|
||||
)
|
||||
main_branch = head_out.strip().replace("refs/remotes/origin/", "") or "main"
|
||||
|
||||
print(f"{Colors.BLUE}=== Finding Merged Worktrees ==={Colors.NC}")
|
||||
print()
|
||||
|
||||
# Get merged branches
|
||||
_, merged_out, _ = _run_git_command(
|
||||
["branch", "--merged", main_branch], cwd=repo_root
|
||||
)
|
||||
merged_branches = []
|
||||
for line in merged_out.splitlines():
|
||||
branch = line.strip().lstrip("* ")
|
||||
if branch and branch != main_branch:
|
||||
merged_branches.append(branch)
|
||||
|
||||
if not merged_branches:
|
||||
log_info("No merged branches found")
|
||||
return 0
|
||||
|
||||
# Get worktree list
|
||||
_, worktree_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root)
|
||||
|
||||
worktree_branches = []
|
||||
for branch in merged_branches:
|
||||
if f"[{branch}]" in worktree_list:
|
||||
worktree_branches.append(branch)
|
||||
print(f" - {branch}")
|
||||
|
||||
if not worktree_branches:
|
||||
log_info("No merged worktrees found")
|
||||
return 0
|
||||
|
||||
print()
|
||||
if not confirm("Remove these merged worktrees?", skip_confirm):
|
||||
log_info("Aborted")
|
||||
return 0
|
||||
|
||||
for branch in worktree_branches:
|
||||
cleanup_worktree(branch, repo_root, True, keep_branch)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
|
||||
"""Cleanup all worktrees."""
|
||||
print(f"{Colors.BLUE}=== All Worktrees ==={Colors.NC}")
|
||||
print()
|
||||
|
||||
# Get worktree list
|
||||
_, worktree_list, _ = _run_git_command(
|
||||
["worktree", "list", "--porcelain"], cwd=repo_root
|
||||
)
|
||||
|
||||
worktrees = []
|
||||
main_worktree = str(repo_root.resolve())
|
||||
|
||||
for line in worktree_list.splitlines():
|
||||
if line.startswith("worktree "):
|
||||
wt = line[9:]
|
||||
if wt != main_worktree:
|
||||
worktrees.append(wt)
|
||||
|
||||
if not worktrees:
|
||||
log_info("No worktrees to remove")
|
||||
return 0
|
||||
|
||||
for wt in worktrees:
|
||||
print(f" - {wt}")
|
||||
|
||||
print()
|
||||
print(f"{Colors.RED}WARNING: This will remove ALL worktrees!{Colors.NC}")
|
||||
|
||||
if not confirm("Are you sure?", skip_confirm):
|
||||
log_info("Aborted")
|
||||
return 0
|
||||
|
||||
# Get branch for each worktree
|
||||
for wt in worktrees:
|
||||
# Find branch name from worktree list
|
||||
_, wt_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root)
|
||||
for line in wt_list.splitlines():
|
||||
if wt in line:
|
||||
# Extract branch from [branch] format
|
||||
import re
|
||||
|
||||
match = re.search(r"\[([^\]]+)\]", line)
|
||||
if match:
|
||||
branch = match.group(1)
|
||||
cleanup_worktree(branch, repo_root, True, keep_branch)
|
||||
break
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Multi-Agent Pipeline: Cleanup Worktree"
|
||||
)
|
||||
parser.add_argument("branch", nargs="?", help="Branch name to cleanup")
|
||||
parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
||||
parser.add_argument(
|
||||
"--keep-branch", action="store_true", help="Don't delete git branch"
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="List all worktrees")
|
||||
parser.add_argument("--merged", action="store_true", help="Remove merged worktrees")
|
||||
parser.add_argument("--all", action="store_true", help="Remove all worktrees")
|
||||
|
||||
args = parser.parse_args()
|
||||
repo_root = get_repo_root()
|
||||
|
||||
if args.list:
|
||||
return cmd_list(repo_root)
|
||||
elif args.merged:
|
||||
return cmd_merged(repo_root, args.yes, args.keep_branch)
|
||||
elif args.all:
|
||||
return cmd_all(repo_root, args.yes, args.keep_branch)
|
||||
elif args.branch:
|
||||
return cleanup_worktree(args.branch, repo_root, args.yes, args.keep_branch)
|
||||
else:
|
||||
print("""Usage:
|
||||
python3 cleanup.py <branch-name> Remove specific worktree
|
||||
python3 cleanup.py --list List all worktrees
|
||||
python3 cleanup.py --merged Remove merged worktrees
|
||||
python3 cleanup.py --all Remove all worktrees
|
||||
|
||||
Options:
|
||||
-y, --yes Skip confirmation
|
||||
--keep-branch Don't delete git branch
|
||||
""")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+329
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Pipeline: Create PR.
|
||||
|
||||
Usage:
|
||||
python3 create_pr.py [task-dir] [--dry-run]
|
||||
|
||||
This script:
|
||||
1. Stages and commits all changes (excluding workspace/)
|
||||
2. Pushes to origin
|
||||
3. Creates a Draft PR using `gh pr create`
|
||||
4. Updates task.json with status="completed", pr_url, and current_phase
|
||||
|
||||
Note: This is the only action that performs git commit, as it's the final
|
||||
step after all implementation and checks are complete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from common.git_context import _run_git_command
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
FILE_TASK_JSON,
|
||||
get_current_task,
|
||||
get_repo_root,
|
||||
)
|
||||
from common.phase import get_phase_for_action
|
||||
|
||||
# =============================================================================
|
||||
# Colors
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Colors:
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> dict | None:
|
||||
"""Read and parse a JSON file."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_json_file(path: Path, data: dict) -> bool:
|
||||
"""Write dict to JSON file."""
|
||||
try:
|
||||
path.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Create PR")
|
||||
parser.add_argument("dir", nargs="?", help="Task directory")
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Show what would be done"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
repo_root = get_repo_root()
|
||||
|
||||
# =============================================================================
|
||||
# Get Task Directory
|
||||
# =============================================================================
|
||||
target_dir = args.dir
|
||||
if not target_dir:
|
||||
# Try to get from .current-task
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
target_dir = current_task
|
||||
|
||||
if not target_dir:
|
||||
print(
|
||||
f"{Colors.RED}Error: No task directory specified and no current task set{Colors.NC}"
|
||||
)
|
||||
print("Usage: python3 create_pr.py [task-dir] [--dry-run]")
|
||||
return 1
|
||||
|
||||
# Support relative paths
|
||||
if not target_dir.startswith("/"):
|
||||
target_dir_path = repo_root / target_dir
|
||||
else:
|
||||
target_dir_path = Path(target_dir)
|
||||
|
||||
task_json = target_dir_path / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
print(f"{Colors.RED}Error: task.json not found at {target_dir_path}{Colors.NC}")
|
||||
return 1
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
print(f"{Colors.BLUE}=== Create PR ==={Colors.NC}")
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"{Colors.YELLOW}[DRY-RUN MODE] No actual changes will be made{Colors.NC}"
|
||||
)
|
||||
print()
|
||||
|
||||
# Read task config
|
||||
task_data = _read_json_file(task_json)
|
||||
if not task_data:
|
||||
print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}")
|
||||
return 1
|
||||
|
||||
task_name = task_data.get("name", "")
|
||||
base_branch = task_data.get("base_branch", "main")
|
||||
scope = task_data.get("scope", "core")
|
||||
dev_type = task_data.get("dev_type", "feature")
|
||||
|
||||
# Map dev_type to commit prefix
|
||||
prefix_map = {
|
||||
"feature": "feat",
|
||||
"frontend": "feat",
|
||||
"backend": "feat",
|
||||
"fullstack": "feat",
|
||||
"bugfix": "fix",
|
||||
"fix": "fix",
|
||||
"refactor": "refactor",
|
||||
"docs": "docs",
|
||||
"test": "test",
|
||||
}
|
||||
commit_prefix = prefix_map.get(dev_type, "feat")
|
||||
|
||||
print(f"Task: {task_name}")
|
||||
print(f"Base branch: {base_branch}")
|
||||
print(f"Scope: {scope}")
|
||||
print(f"Commit prefix: {commit_prefix}")
|
||||
print()
|
||||
|
||||
# Get current branch
|
||||
_, branch_out, _ = _run_git_command(["branch", "--show-current"])
|
||||
current_branch = branch_out.strip()
|
||||
print(f"Current branch: {current_branch}")
|
||||
|
||||
# Check for changes
|
||||
print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}")
|
||||
|
||||
# Stage changes
|
||||
_run_git_command(["add", "-A"])
|
||||
|
||||
# Exclude workspace and temp files
|
||||
_run_git_command(["reset", f"{DIR_WORKFLOW}/workspace/"])
|
||||
_run_git_command(["reset", ".agent-log", ".session-id"])
|
||||
|
||||
# Check if there are staged changes
|
||||
ret, _, _ = _run_git_command(["diff", "--cached", "--quiet"])
|
||||
has_staged_changes = ret != 0
|
||||
|
||||
if not has_staged_changes:
|
||||
print(f"{Colors.YELLOW}No staged changes to commit{Colors.NC}")
|
||||
|
||||
# Check for unpushed commits
|
||||
ret, log_out, _ = _run_git_command(
|
||||
["log", f"origin/{current_branch}..HEAD", "--oneline"]
|
||||
)
|
||||
unpushed = len([line for line in log_out.splitlines() if line.strip()])
|
||||
|
||||
if unpushed == 0:
|
||||
if args.dry_run:
|
||||
_run_git_command(["reset", "HEAD"])
|
||||
print(f"{Colors.RED}No changes to create PR{Colors.NC}")
|
||||
return 1
|
||||
|
||||
print(f"Found {unpushed} unpushed commit(s)")
|
||||
else:
|
||||
# Commit changes
|
||||
print(f"{Colors.YELLOW}Committing changes...{Colors.NC}")
|
||||
commit_msg = f"{commit_prefix}({scope}): {task_name}"
|
||||
|
||||
if args.dry_run:
|
||||
print(f"[DRY-RUN] Would commit with message: {commit_msg}")
|
||||
print("[DRY-RUN] Staged files:")
|
||||
_, staged_out, _ = _run_git_command(["diff", "--cached", "--name-only"])
|
||||
for line in staged_out.splitlines():
|
||||
print(f" - {line}")
|
||||
else:
|
||||
_run_git_command(["commit", "-m", commit_msg])
|
||||
print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}")
|
||||
|
||||
# Push to remote
|
||||
print(f"{Colors.YELLOW}Pushing to remote...{Colors.NC}")
|
||||
if args.dry_run:
|
||||
print(f"[DRY-RUN] Would push to: origin/{current_branch}")
|
||||
else:
|
||||
ret, _, err = _run_git_command(["push", "-u", "origin", current_branch])
|
||||
if ret != 0:
|
||||
print(f"{Colors.RED}Failed to push: {err}{Colors.NC}")
|
||||
return 1
|
||||
print(f"{Colors.GREEN}Pushed to origin/{current_branch}{Colors.NC}")
|
||||
|
||||
# Create PR
|
||||
print(f"{Colors.YELLOW}Creating PR...{Colors.NC}")
|
||||
pr_title = f"{commit_prefix}({scope}): {task_name}"
|
||||
pr_url = ""
|
||||
|
||||
if args.dry_run:
|
||||
print("[DRY-RUN] Would create PR:")
|
||||
print(f" Title: {pr_title}")
|
||||
print(f" Base: {base_branch}")
|
||||
print(f" Head: {current_branch}")
|
||||
prd_file = target_dir_path / "prd.md"
|
||||
if prd_file.is_file():
|
||||
print(" Body: (from prd.md)")
|
||||
pr_url = "https://github.com/example/repo/pull/DRY-RUN"
|
||||
else:
|
||||
# Check if PR already exists
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"list",
|
||||
"--head",
|
||||
current_branch,
|
||||
"--base",
|
||||
base_branch,
|
||||
"--json",
|
||||
"url",
|
||||
"--jq",
|
||||
".[0].url",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
existing_pr = result.stdout.strip()
|
||||
|
||||
if existing_pr:
|
||||
print(f"{Colors.YELLOW}PR already exists: {existing_pr}{Colors.NC}")
|
||||
pr_url = existing_pr
|
||||
else:
|
||||
# Read PRD as PR body
|
||||
pr_body = ""
|
||||
prd_file = target_dir_path / "prd.md"
|
||||
if prd_file.is_file():
|
||||
pr_body = prd_file.read_text(encoding="utf-8")
|
||||
|
||||
# Create PR
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"create",
|
||||
"--draft",
|
||||
"--base",
|
||||
base_branch,
|
||||
"--title",
|
||||
pr_title,
|
||||
"--body",
|
||||
pr_body,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"{Colors.RED}Failed to create PR: {result.stderr}{Colors.NC}")
|
||||
return 1
|
||||
|
||||
pr_url = result.stdout.strip()
|
||||
print(f"{Colors.GREEN}PR created: {pr_url}{Colors.NC}")
|
||||
|
||||
# Update task.json
|
||||
print(f"{Colors.YELLOW}Updating task status...{Colors.NC}")
|
||||
if args.dry_run:
|
||||
print("[DRY-RUN] Would update task.json:")
|
||||
print(" status: completed")
|
||||
print(f" pr_url: {pr_url}")
|
||||
print(" current_phase: (set to create-pr phase)")
|
||||
else:
|
||||
# Get the phase number for create-pr action
|
||||
create_pr_phase = get_phase_for_action(task_json, "create-pr")
|
||||
if not create_pr_phase:
|
||||
create_pr_phase = 4 # Default fallback
|
||||
|
||||
task_data["status"] = "completed"
|
||||
task_data["pr_url"] = pr_url
|
||||
task_data["current_phase"] = create_pr_phase
|
||||
|
||||
_write_json_file(task_json, task_data)
|
||||
print(
|
||||
f"{Colors.GREEN}Task status updated to 'completed', phase {create_pr_phase}{Colors.NC}"
|
||||
)
|
||||
|
||||
# In dry-run, reset the staging area
|
||||
if args.dry_run:
|
||||
_run_git_command(["reset", "HEAD"])
|
||||
|
||||
print()
|
||||
print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}")
|
||||
print(f"PR URL: {pr_url}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Pipeline: Plan Agent Launcher.
|
||||
|
||||
Usage: python3 plan.py --name <task-name> --type <dev-type> --requirement "<requirement>"
|
||||
|
||||
This script:
|
||||
1. Creates task directory
|
||||
2. Starts Plan Agent in background
|
||||
3. Plan Agent produces fully configured task directory
|
||||
|
||||
After completion, use start.py to launch the Dispatch Agent.
|
||||
|
||||
Prerequisites:
|
||||
- agents/plan.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/)
|
||||
- Developer must be initialized
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from common.cli_adapter import get_cli_adapter
|
||||
from common.paths import get_repo_root
|
||||
from common.developer import ensure_developer
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Colors
|
||||
# =============================================================================
|
||||
|
||||
class Colors:
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m"
|
||||
|
||||
|
||||
def log_info(msg: str) -> None:
|
||||
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str) -> None:
|
||||
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_error(msg: str) -> None:
|
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_PLATFORM = "claude"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Multi-Agent Pipeline: Plan Agent Launcher"
|
||||
)
|
||||
parser.add_argument("--name", "-n", required=True, help="Task name (e.g., user-auth)")
|
||||
parser.add_argument("--type", "-t", required=True, help="Dev type: backend|frontend|fullstack")
|
||||
parser.add_argument("--requirement", "-r", required=True, help="Requirement description")
|
||||
parser.add_argument(
|
||||
"--platform", "-p",
|
||||
choices=["claude", "cursor", "iflow", "opencode", "qoder"],
|
||||
default=DEFAULT_PLATFORM,
|
||||
help="Platform to use (default: claude)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
task_name = args.name
|
||||
dev_type = args.type
|
||||
requirement = args.requirement
|
||||
platform = args.platform
|
||||
|
||||
# Initialize CLI adapter
|
||||
adapter = get_cli_adapter(platform)
|
||||
|
||||
# Validate dev type
|
||||
if dev_type not in ("backend", "frontend", "fullstack"):
|
||||
log_error(f"Invalid dev type: {dev_type} (must be: backend, frontend, fullstack)")
|
||||
return 1
|
||||
|
||||
project_root = get_repo_root()
|
||||
|
||||
# Check plan agent exists (path varies by platform)
|
||||
plan_md = adapter.get_agent_path("plan", project_root)
|
||||
if not plan_md.is_file():
|
||||
log_error(f"plan agent not found at {plan_md}")
|
||||
log_info(f"Platform: {platform}")
|
||||
return 1
|
||||
|
||||
ensure_developer(project_root)
|
||||
|
||||
# =============================================================================
|
||||
# Step 1: Create Task Directory
|
||||
# =============================================================================
|
||||
print()
|
||||
print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Plan ==={Colors.NC}")
|
||||
log_info(f"Task: {task_name}")
|
||||
log_info(f"Type: {dev_type}")
|
||||
log_info(f"Requirement: {requirement}")
|
||||
print()
|
||||
|
||||
log_info("Step 1: Creating task directory...")
|
||||
|
||||
# Import task module to create task
|
||||
from task import cmd_create
|
||||
import argparse as ap
|
||||
|
||||
# Create task using task.py's create command
|
||||
create_args = ap.Namespace(
|
||||
title=requirement,
|
||||
slug=task_name,
|
||||
assignee=None,
|
||||
priority="P2",
|
||||
description=""
|
||||
)
|
||||
|
||||
# Capture stdout to get task dir
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
stdout_capture = io.StringIO()
|
||||
with redirect_stdout(stdout_capture):
|
||||
ret = cmd_create(create_args)
|
||||
|
||||
if ret != 0:
|
||||
log_error("Failed to create task directory")
|
||||
return 1
|
||||
|
||||
task_dir = stdout_capture.getvalue().strip().split("\n")[-1]
|
||||
task_dir_abs = project_root / task_dir
|
||||
|
||||
log_success(f"Task directory: {task_dir}")
|
||||
|
||||
# =============================================================================
|
||||
# Step 2: Prepare and Start Plan Agent
|
||||
# =============================================================================
|
||||
log_info("Step 2: Starting Plan Agent in background...")
|
||||
|
||||
log_file = task_dir_abs / ".plan-log"
|
||||
log_file.touch()
|
||||
|
||||
# Get proxy environment variables
|
||||
https_proxy = os.environ.get("https_proxy", "")
|
||||
http_proxy = os.environ.get("http_proxy", "")
|
||||
all_proxy = os.environ.get("all_proxy", "")
|
||||
|
||||
# Start agent in background (cross-platform, no shell script needed)
|
||||
env = os.environ.copy()
|
||||
env["PLAN_TASK_NAME"] = task_name
|
||||
env["PLAN_DEV_TYPE"] = dev_type
|
||||
env["PLAN_TASK_DIR"] = task_dir
|
||||
env["PLAN_REQUIREMENT"] = requirement
|
||||
env["https_proxy"] = https_proxy
|
||||
env["http_proxy"] = http_proxy
|
||||
env["all_proxy"] = all_proxy
|
||||
|
||||
# Clear nested-session detection so the new CLI process can start
|
||||
env.pop("CLAUDECODE", None)
|
||||
|
||||
# Set non-interactive env var based on platform
|
||||
env.update(adapter.get_non_interactive_env())
|
||||
|
||||
# Build CLI command using adapter
|
||||
cli_cmd = adapter.build_run_command(
|
||||
agent="plan", # Will be mapped to "trellis-plan" for OpenCode
|
||||
prompt=f"Start planning for task: {task_name}",
|
||||
skip_permissions=True,
|
||||
verbose=True,
|
||||
json_output=True,
|
||||
)
|
||||
|
||||
with log_file.open("w") as log_f:
|
||||
# Use shell=False for cross-platform compatibility
|
||||
# creationflags for Windows, start_new_session for Unix
|
||||
popen_kwargs = {
|
||||
"stdout": log_f,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"cwd": str(project_root),
|
||||
"env": env,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
process = subprocess.Popen(cli_cmd, **popen_kwargs)
|
||||
agent_pid = process.pid
|
||||
|
||||
log_success(f"Plan Agent started (PID: {agent_pid})")
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
print()
|
||||
print(f"{Colors.GREEN}=== Plan Agent Running ==={Colors.NC}")
|
||||
print()
|
||||
print(f" Task: {task_name}")
|
||||
print(f" Type: {dev_type}")
|
||||
print(f" Dir: {task_dir}")
|
||||
print(f" Log: {log_file}")
|
||||
print(f" PID: {agent_pid}")
|
||||
print()
|
||||
print(f"{Colors.YELLOW}To monitor:{Colors.NC}")
|
||||
print(f" tail -f {log_file}")
|
||||
print()
|
||||
print(f"{Colors.YELLOW}To check status:{Colors.NC}")
|
||||
print(f" ps -p {agent_pid}")
|
||||
print(f" ls -la {task_dir}")
|
||||
print()
|
||||
print(f"{Colors.YELLOW}After completion, run:{Colors.NC}")
|
||||
print(f" python3 ./.trellis/scripts/multi_agent/start.py {task_dir}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+465
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Pipeline: Start Worktree Agent.
|
||||
|
||||
Usage: python3 start.py <task-dir>
|
||||
Example: python3 start.py .trellis/tasks/01-21-my-task
|
||||
|
||||
This script:
|
||||
1. Creates worktree (if not exists) with dependency install
|
||||
2. Copies environment files (from worktree.yaml config)
|
||||
3. Sets .current-task in worktree
|
||||
4. Starts claude agent in background
|
||||
5. Registers agent to registry.json
|
||||
|
||||
Prerequisites:
|
||||
- task.json must exist with 'branch' field
|
||||
- agents/dispatch.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/)
|
||||
|
||||
Configuration: .trellis/worktree.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from common.cli_adapter import CLIAdapter, get_cli_adapter
|
||||
from common.git_context import _run_git_command
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
FILE_CURRENT_TASK,
|
||||
FILE_TASK_JSON,
|
||||
get_repo_root,
|
||||
)
|
||||
from common.registry import (
|
||||
registry_add_agent,
|
||||
registry_get_file,
|
||||
)
|
||||
from common.worktree import (
|
||||
get_worktree_base_dir,
|
||||
get_worktree_config,
|
||||
get_worktree_copy_files,
|
||||
get_worktree_post_create_hooks,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Colors
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Colors:
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m"
|
||||
|
||||
|
||||
def log_info(msg: str) -> None:
|
||||
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str) -> None:
|
||||
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_warn(msg: str) -> None:
|
||||
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_error(msg: str) -> None:
|
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> dict | None:
|
||||
"""Read and parse a JSON file."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_json_file(path: Path, data: dict) -> bool:
|
||||
"""Write dict to JSON file."""
|
||||
try:
|
||||
path.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_PLATFORM = "claude"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Start Worktree Agent")
|
||||
parser.add_argument("task_dir", help="Task directory path")
|
||||
parser.add_argument(
|
||||
"--platform", "-p",
|
||||
choices=["claude", "cursor", "iflow", "opencode", "qoder"],
|
||||
default=DEFAULT_PLATFORM,
|
||||
help="Platform to use (default: claude)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
task_dir_arg = args.task_dir
|
||||
platform = args.platform
|
||||
|
||||
# Initialize CLI adapter
|
||||
adapter = get_cli_adapter(platform)
|
||||
|
||||
project_root = get_repo_root()
|
||||
|
||||
# Normalize paths
|
||||
if task_dir_arg.startswith("/"):
|
||||
task_dir_relative = task_dir_arg[len(str(project_root)) + 1 :]
|
||||
task_dir_abs = Path(task_dir_arg)
|
||||
else:
|
||||
task_dir_relative = task_dir_arg
|
||||
task_dir_abs = project_root / task_dir_arg
|
||||
|
||||
task_json_path = task_dir_abs / FILE_TASK_JSON
|
||||
|
||||
# =============================================================================
|
||||
# Validation
|
||||
# =============================================================================
|
||||
if not task_json_path.is_file():
|
||||
log_error(f"task.json not found at {task_json_path}")
|
||||
return 1
|
||||
|
||||
dispatch_md = adapter.get_agent_path("dispatch", project_root)
|
||||
if not dispatch_md.is_file():
|
||||
log_error(f"dispatch.md not found at {dispatch_md}")
|
||||
log_info(f"Platform: {platform}")
|
||||
return 1
|
||||
|
||||
config_file = get_worktree_config(project_root)
|
||||
if not config_file.is_file():
|
||||
log_error(f"worktree.yaml not found at {config_file}")
|
||||
return 1
|
||||
|
||||
# =============================================================================
|
||||
# Read Task Config
|
||||
# =============================================================================
|
||||
print()
|
||||
print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Start ==={Colors.NC}")
|
||||
log_info(f"Task: {task_dir_abs}")
|
||||
|
||||
task_data = _read_json_file(task_json_path)
|
||||
if not task_data:
|
||||
log_error("Failed to read task.json")
|
||||
return 1
|
||||
|
||||
branch = task_data.get("branch")
|
||||
task_name = task_data.get("name")
|
||||
task_status = task_data.get("status")
|
||||
worktree_path = task_data.get("worktree_path")
|
||||
|
||||
# Check if task was rejected
|
||||
if task_status == "rejected":
|
||||
log_error("Task was rejected by Plan Agent")
|
||||
rejected_file = task_dir_abs / "REJECTED.md"
|
||||
if rejected_file.is_file():
|
||||
print()
|
||||
print(f"{Colors.YELLOW}Rejection reason:{Colors.NC}")
|
||||
print(rejected_file.read_text(encoding="utf-8"))
|
||||
print()
|
||||
log_info(
|
||||
"To retry, delete this directory and run plan.py again with revised requirements"
|
||||
)
|
||||
return 1
|
||||
|
||||
# Check if prd.md exists (plan completed successfully)
|
||||
prd_file = task_dir_abs / "prd.md"
|
||||
if not prd_file.is_file():
|
||||
log_error("prd.md not found - Plan Agent may not have completed")
|
||||
log_info(f"Check plan log: {task_dir_abs}/.plan-log")
|
||||
return 1
|
||||
|
||||
if not branch:
|
||||
log_error("branch field not set in task.json")
|
||||
log_info("Please set branch field first, e.g.:")
|
||||
log_info(
|
||||
" jq '.branch = \"task/my-task\"' task.json > tmp && mv tmp task.json"
|
||||
)
|
||||
return 1
|
||||
|
||||
log_info(f"Branch: {branch}")
|
||||
log_info(f"Name: {task_name}")
|
||||
|
||||
# =============================================================================
|
||||
# Step 1: Create Worktree (if not exists)
|
||||
# =============================================================================
|
||||
if not worktree_path or not Path(worktree_path).is_dir():
|
||||
log_info("Step 1: Creating worktree...")
|
||||
|
||||
# Record current branch as base_branch (PR target)
|
||||
_, base_branch_out, _ = _run_git_command(
|
||||
["branch", "--show-current"], cwd=project_root
|
||||
)
|
||||
base_branch = base_branch_out.strip()
|
||||
log_info(f"Base branch (PR target): {base_branch}")
|
||||
|
||||
# Calculate worktree path
|
||||
worktree_base = get_worktree_base_dir(project_root)
|
||||
worktree_base.mkdir(parents=True, exist_ok=True)
|
||||
worktree_base = worktree_base.resolve()
|
||||
worktree_path_obj = worktree_base / branch
|
||||
worktree_path = str(worktree_path_obj)
|
||||
|
||||
# Create parent directory
|
||||
worktree_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create branch if not exists
|
||||
ret, _, _ = _run_git_command(
|
||||
["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
||||
cwd=project_root,
|
||||
)
|
||||
if ret == 0:
|
||||
log_info("Branch exists, checking out...")
|
||||
ret, _, err = _run_git_command(
|
||||
["worktree", "add", worktree_path, branch], cwd=project_root
|
||||
)
|
||||
else:
|
||||
log_info(f"Creating new branch: {branch}")
|
||||
ret, _, err = _run_git_command(
|
||||
["worktree", "add", "-b", branch, worktree_path], cwd=project_root
|
||||
)
|
||||
|
||||
if ret != 0:
|
||||
log_error(f"Failed to create worktree: {err}")
|
||||
return 1
|
||||
|
||||
log_success(f"Worktree created: {worktree_path}")
|
||||
|
||||
# Update task.json with worktree_path and base_branch
|
||||
task_data["worktree_path"] = worktree_path
|
||||
task_data["base_branch"] = base_branch
|
||||
_write_json_file(task_json_path, task_data)
|
||||
|
||||
# ----- Copy environment files -----
|
||||
log_info("Copying environment files...")
|
||||
copy_list = get_worktree_copy_files(project_root)
|
||||
copy_count = 0
|
||||
|
||||
for item in copy_list:
|
||||
if not item:
|
||||
continue
|
||||
|
||||
source = project_root / item
|
||||
target = Path(worktree_path) / item
|
||||
|
||||
if source.is_file():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(source), str(target))
|
||||
copy_count += 1
|
||||
|
||||
if copy_count > 0:
|
||||
log_success(f"Copied {copy_count} file(s)")
|
||||
|
||||
# ----- Copy task directory (may not be committed yet) -----
|
||||
log_info("Copying task directory...")
|
||||
task_target_dir = Path(worktree_path) / task_dir_relative
|
||||
task_target_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
if task_target_dir.exists():
|
||||
shutil.rmtree(str(task_target_dir))
|
||||
shutil.copytree(str(task_dir_abs), str(task_target_dir))
|
||||
log_success("Task directory copied to worktree")
|
||||
|
||||
# ----- Run post_create hooks -----
|
||||
log_info("Running post_create hooks...")
|
||||
post_create = get_worktree_post_create_hooks(project_root)
|
||||
hook_count = 0
|
||||
|
||||
for cmd in post_create:
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
log_info(f" Running: {cmd}")
|
||||
ret = subprocess.run(cmd, shell=True, cwd=worktree_path)
|
||||
if ret.returncode != 0:
|
||||
log_error(f"Hook failed: {cmd}")
|
||||
return 1
|
||||
hook_count += 1
|
||||
|
||||
if hook_count > 0:
|
||||
log_success(f"Ran {hook_count} hook(s)")
|
||||
else:
|
||||
log_info(f"Step 1: Using existing worktree: {worktree_path}")
|
||||
|
||||
# =============================================================================
|
||||
# Step 2: Set .current-task in Worktree
|
||||
# =============================================================================
|
||||
log_info("Step 2: Setting current task in worktree...")
|
||||
|
||||
worktree_workflow_dir = Path(worktree_path) / DIR_WORKFLOW
|
||||
worktree_workflow_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
current_task_file = worktree_workflow_dir / FILE_CURRENT_TASK
|
||||
current_task_file.write_text(task_dir_relative, encoding="utf-8")
|
||||
log_success(f"Current task set: {task_dir_relative}")
|
||||
|
||||
# =============================================================================
|
||||
# Step 3: Prepare and Start Claude Agent
|
||||
# =============================================================================
|
||||
log_info(f"Step 3: Starting {adapter.cli_name} agent...")
|
||||
|
||||
# Update task status
|
||||
task_data["status"] = "in_progress"
|
||||
_write_json_file(task_json_path, task_data)
|
||||
|
||||
log_file = Path(worktree_path) / ".agent-log"
|
||||
session_id_file = Path(worktree_path) / ".session-id"
|
||||
|
||||
log_file.touch()
|
||||
|
||||
# Generate session ID for resume support (Claude Code only)
|
||||
# OpenCode generates its own session ID, we'll extract it from logs later
|
||||
if adapter.supports_session_id_on_create:
|
||||
session_id = str(uuid.uuid4()).lower()
|
||||
session_id_file.write_text(session_id, encoding="utf-8")
|
||||
log_info(f"Session ID: {session_id}")
|
||||
else:
|
||||
session_id = None # Will be extracted from logs after startup
|
||||
log_info("Session ID will be extracted from logs after startup")
|
||||
|
||||
# Get proxy environment variables
|
||||
https_proxy = os.environ.get("https_proxy", "")
|
||||
http_proxy = os.environ.get("http_proxy", "")
|
||||
all_proxy = os.environ.get("all_proxy", "")
|
||||
|
||||
# Start agent in background (cross-platform, no shell script needed)
|
||||
env = os.environ.copy()
|
||||
env["https_proxy"] = https_proxy
|
||||
env["http_proxy"] = http_proxy
|
||||
env["all_proxy"] = all_proxy
|
||||
|
||||
# Clear nested-session detection so the new CLI process can start
|
||||
# (when this script runs inside a Claude Code session, CLAUDECODE=1 is inherited)
|
||||
env.pop("CLAUDECODE", None)
|
||||
|
||||
# Set non-interactive env var based on platform
|
||||
env.update(adapter.get_non_interactive_env())
|
||||
|
||||
# Build CLI command using adapter
|
||||
# Note: Use explicit prompt to avoid confusion with CI/CD pipelines
|
||||
# Also remind the model to follow its agent definition for better cross-model compatibility
|
||||
cli_cmd = adapter.build_run_command(
|
||||
agent="dispatch",
|
||||
prompt="Follow your agent instructions to execute the task workflow. Start by reading .trellis/.current-task to get the task directory, then execute each action in task.json next_action array in order.",
|
||||
session_id=session_id if adapter.supports_session_id_on_create else None,
|
||||
skip_permissions=True,
|
||||
verbose=True,
|
||||
json_output=True,
|
||||
)
|
||||
|
||||
with log_file.open("w") as log_f:
|
||||
# Use shell=False for cross-platform compatibility
|
||||
# creationflags for Windows, start_new_session for Unix
|
||||
popen_kwargs = {
|
||||
"stdout": log_f,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"cwd": worktree_path,
|
||||
"env": env,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
process = subprocess.Popen(cli_cmd, **popen_kwargs)
|
||||
agent_pid = process.pid
|
||||
|
||||
log_success(f"Agent started with PID: {agent_pid}")
|
||||
|
||||
# For OpenCode: extract session ID from logs after startup
|
||||
if not adapter.supports_session_id_on_create:
|
||||
import time
|
||||
log_info("Waiting for session ID from logs...")
|
||||
# Wait a bit for the log to have session ID
|
||||
for _ in range(10): # Try for up to 5 seconds
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
log_content = log_file.read_text(encoding="utf-8", errors="replace")
|
||||
session_id = adapter.extract_session_id_from_log(log_content)
|
||||
if session_id:
|
||||
session_id_file.write_text(session_id, encoding="utf-8")
|
||||
log_success(f"Session ID extracted: {session_id}")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
log_warn("Could not extract session ID from logs")
|
||||
session_id = "unknown"
|
||||
|
||||
# =============================================================================
|
||||
# Step 4: Register to Registry (in main repo, not worktree)
|
||||
# =============================================================================
|
||||
log_info("Step 4: Registering agent to registry...")
|
||||
|
||||
# Generate agent ID
|
||||
task_id = task_data.get("id")
|
||||
if not task_id:
|
||||
task_id = branch.replace("/", "-")
|
||||
|
||||
registry_add_agent(
|
||||
task_id, worktree_path, agent_pid, task_dir_relative, project_root, platform
|
||||
)
|
||||
|
||||
log_success(f"Agent registered: {task_id}")
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
print()
|
||||
print(f"{Colors.GREEN}=== Agent Started ==={Colors.NC}")
|
||||
print()
|
||||
print(f" ID: {task_id}")
|
||||
print(f" PID: {agent_pid}")
|
||||
print(f" Session: {session_id}")
|
||||
print(f" Worktree: {worktree_path}")
|
||||
print(f" Task: {task_dir_relative}")
|
||||
print(f" Log: {log_file}")
|
||||
print(f" Registry: {registry_get_file(project_root)}")
|
||||
print()
|
||||
print(f"{Colors.YELLOW}To monitor:{Colors.NC} tail -f {log_file}")
|
||||
print(f"{Colors.YELLOW}To stop:{Colors.NC} kill {agent_pid}")
|
||||
if session_id and session_id != "unknown":
|
||||
resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree_path)
|
||||
print(f"{Colors.YELLOW}To resume:{Colors.NC} {resume_cmd}")
|
||||
else:
|
||||
print(f"{Colors.YELLOW}To resume:{Colors.NC} (session ID not available)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+817
@@ -0,0 +1,817 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Pipeline: Status Monitor.
|
||||
|
||||
Usage:
|
||||
python3 status.py Show summary of all tasks (default)
|
||||
python3 status.py -a <assignee> Filter tasks by assignee
|
||||
python3 status.py --list List all worktrees and agents
|
||||
python3 status.py --detail <task> Detailed task status
|
||||
python3 status.py --watch <task> Watch agent log in real-time
|
||||
python3 status.py --log <task> Show recent log entries
|
||||
python3 status.py --registry Show agent registry
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from common.cli_adapter import get_cli_adapter
|
||||
from common.developer import ensure_developer
|
||||
from common.paths import (
|
||||
FILE_TASK_JSON,
|
||||
get_repo_root,
|
||||
get_tasks_dir,
|
||||
)
|
||||
from common.phase import get_phase_info
|
||||
from common.task_queue import format_task_stats, get_task_stats
|
||||
from common.worktree import get_agents_dir
|
||||
|
||||
# =============================================================================
|
||||
# Colors
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Colors:
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
CYAN = "\033[0;36m"
|
||||
DIM = "\033[2m"
|
||||
NC = "\033[0m"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> dict | None:
|
||||
"""Read and parse a JSON file."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def is_running(pid: int | str | None) -> bool:
|
||||
"""Check if PID is running."""
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
pid_int = int(pid)
|
||||
os.kill(pid_int, 0)
|
||||
return True
|
||||
except (ProcessLookupError, ValueError, PermissionError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def status_color(status: str) -> str:
|
||||
"""Get status color."""
|
||||
colors = {
|
||||
"completed": Colors.GREEN,
|
||||
"in_progress": Colors.BLUE,
|
||||
"planning": Colors.YELLOW,
|
||||
}
|
||||
return colors.get(status, Colors.DIM)
|
||||
|
||||
|
||||
def get_registry_file(repo_root: Path) -> Path | None:
|
||||
"""Get registry file path."""
|
||||
agents_dir = get_agents_dir(repo_root)
|
||||
if agents_dir:
|
||||
return agents_dir / "registry.json"
|
||||
return None
|
||||
|
||||
|
||||
def find_agent(search: str, repo_root: Path) -> dict | None:
|
||||
"""Find agent by task name or ID."""
|
||||
registry_file = get_registry_file(repo_root)
|
||||
if not registry_file or not registry_file.is_file():
|
||||
return None
|
||||
|
||||
data = _read_json_file(registry_file)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
for agent in data.get("agents", []):
|
||||
# Exact ID match
|
||||
if agent.get("id") == search:
|
||||
return agent
|
||||
# Partial match on task_dir
|
||||
task_dir = agent.get("task_dir", "")
|
||||
if search in task_dir:
|
||||
return agent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def calc_elapsed(started: str | None) -> str:
|
||||
"""Calculate elapsed time from ISO timestamp."""
|
||||
if not started:
|
||||
return "N/A"
|
||||
|
||||
try:
|
||||
# Parse ISO format
|
||||
if "+" in started:
|
||||
started = started.split("+")[0]
|
||||
if "T" in started:
|
||||
start_dt = datetime.fromisoformat(started)
|
||||
else:
|
||||
return "N/A"
|
||||
|
||||
now = datetime.now()
|
||||
elapsed = (now - start_dt).total_seconds()
|
||||
|
||||
if elapsed < 60:
|
||||
return f"{int(elapsed)}s"
|
||||
elif elapsed < 3600:
|
||||
mins = int(elapsed // 60)
|
||||
secs = int(elapsed % 60)
|
||||
return f"{mins}m {secs}s"
|
||||
else:
|
||||
hours = int(elapsed // 3600)
|
||||
mins = int((elapsed % 3600) // 60)
|
||||
return f"{hours}h {mins}m"
|
||||
except (ValueError, TypeError):
|
||||
return "N/A"
|
||||
|
||||
|
||||
def count_modified_files(worktree: str) -> int:
|
||||
"""Count modified files in worktree."""
|
||||
if not Path(worktree).is_dir():
|
||||
return 0
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--short"],
|
||||
cwd=worktree,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return len([line for line in result.stdout.splitlines() if line.strip()])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def tail_follow(file_path: Path) -> None:
|
||||
"""Follow a file like 'tail -f', cross-platform compatible."""
|
||||
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
# Seek to end of file
|
||||
f.seek(0, 2)
|
||||
|
||||
while True:
|
||||
line = f.readline()
|
||||
if line:
|
||||
print(line, end="", flush=True)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def get_last_tool(log_file: Path, platform: str = "claude") -> str | None:
|
||||
"""Get the last tool call from agent log.
|
||||
|
||||
Supports both Claude Code and OpenCode log formats.
|
||||
|
||||
Claude Code format:
|
||||
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}}
|
||||
|
||||
OpenCode format:
|
||||
{"type": "tool_use", "tool": "bash", "state": {"status": "completed"}}
|
||||
"""
|
||||
if not log_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
lines = log_file.read_text(encoding="utf-8").splitlines()
|
||||
for line in reversed(lines[-100:]):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
|
||||
if platform == "opencode":
|
||||
# OpenCode format: {"type": "tool_use", "tool": "bash", ...}
|
||||
if data.get("type") == "tool_use":
|
||||
return data.get("tool")
|
||||
else:
|
||||
# Claude Code format: {"type": "assistant", "message": {"content": [...]}}
|
||||
if data.get("type") == "assistant":
|
||||
content = data.get("message", {}).get("content", [])
|
||||
for item in content:
|
||||
if item.get("type") == "tool_use":
|
||||
return item.get("name")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None:
|
||||
"""Get the last assistant text from agent log.
|
||||
|
||||
Supports both Claude Code and OpenCode log formats.
|
||||
|
||||
Claude Code format:
|
||||
{"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}}
|
||||
|
||||
OpenCode format:
|
||||
{"type": "text", "text": "..."}
|
||||
"""
|
||||
if not log_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
lines = log_file.read_text(encoding="utf-8").splitlines()
|
||||
for line in reversed(lines[-100:]):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
|
||||
if platform == "opencode":
|
||||
# OpenCode format: {"type": "text", "text": "..."}
|
||||
if data.get("type") == "text":
|
||||
text = data.get("text", "")
|
||||
if text:
|
||||
return text[:max_len]
|
||||
else:
|
||||
# Claude Code format: {"type": "assistant", "message": {"content": [...]}}
|
||||
if data.get("type") == "assistant":
|
||||
content = data.get("message", {}).get("content", [])
|
||||
for item in content:
|
||||
if item.get("type") == "text":
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
return text[:max_len]
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Commands
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def cmd_help() -> int:
|
||||
"""Show help."""
|
||||
print("""Multi-Agent Pipeline: Status Monitor
|
||||
|
||||
Usage:
|
||||
python3 status.py Show summary of all tasks
|
||||
python3 status.py -a <assignee> Filter tasks by assignee
|
||||
python3 status.py --list List all worktrees and agents
|
||||
python3 status.py --detail <task> Detailed task status
|
||||
python3 status.py --progress <task> Quick progress view with recent activity
|
||||
python3 status.py --watch <task> Watch agent log in real-time
|
||||
python3 status.py --log <task> Show recent log entries
|
||||
python3 status.py --registry Show agent registry
|
||||
|
||||
Examples:
|
||||
python3 status.py -a taosu
|
||||
python3 status.py --detail my-task
|
||||
python3 status.py --progress my-task
|
||||
python3 status.py --watch 01-16-worktree-support
|
||||
python3 status.py --log worktree-support
|
||||
""")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(repo_root: Path) -> int:
|
||||
"""List worktrees and agents."""
|
||||
print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}")
|
||||
print()
|
||||
|
||||
subprocess.run(["git", "worktree", "list"], cwd=repo_root)
|
||||
print()
|
||||
|
||||
print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}")
|
||||
print()
|
||||
|
||||
registry_file = get_registry_file(repo_root)
|
||||
if not registry_file or not registry_file.is_file():
|
||||
print(" (no registry found)")
|
||||
return 0
|
||||
|
||||
data = _read_json_file(registry_file)
|
||||
if not data or not data.get("agents"):
|
||||
print(" (no agents registered)")
|
||||
return 0
|
||||
|
||||
for agent in data["agents"]:
|
||||
agent_id = agent.get("id", "?")
|
||||
pid = agent.get("pid")
|
||||
wt = agent.get("worktree_path", "?")
|
||||
started = agent.get("started_at", "?")
|
||||
|
||||
if is_running(pid):
|
||||
status_icon = f"{Colors.GREEN}●{Colors.NC}"
|
||||
else:
|
||||
status_icon = f"{Colors.RED}○{Colors.NC}"
|
||||
|
||||
print(f" {status_icon} {agent_id} (PID: {pid})")
|
||||
print(f" {Colors.DIM}Worktree: {wt}{Colors.NC}")
|
||||
print(f" {Colors.DIM}Started: {started}{Colors.NC}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int:
|
||||
"""Show summary of all tasks."""
|
||||
ensure_developer(repo_root)
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
if not tasks_dir.is_dir():
|
||||
print("No tasks directory found")
|
||||
return 0
|
||||
|
||||
registry_file = get_registry_file(repo_root)
|
||||
|
||||
# Count running agents
|
||||
running_count = 0
|
||||
total_agents = 0
|
||||
|
||||
if registry_file and registry_file.is_file():
|
||||
data = _read_json_file(registry_file)
|
||||
if data:
|
||||
agents = data.get("agents", [])
|
||||
total_agents = len(agents)
|
||||
for agent in agents:
|
||||
if is_running(agent.get("pid")):
|
||||
running_count += 1
|
||||
|
||||
# Task queue stats
|
||||
task_stats = get_task_stats(repo_root)
|
||||
|
||||
print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}")
|
||||
print(
|
||||
f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered"
|
||||
)
|
||||
print(f" Tasks: {format_task_stats(task_stats)}")
|
||||
print()
|
||||
|
||||
# Process tasks
|
||||
running_tasks = []
|
||||
stopped_tasks = []
|
||||
regular_tasks = []
|
||||
|
||||
registry_data = (
|
||||
_read_json_file(registry_file)
|
||||
if registry_file and registry_file.is_file()
|
||||
else None
|
||||
)
|
||||
|
||||
for d in sorted(tasks_dir.iterdir()):
|
||||
if not d.is_dir() or d.name == "archive":
|
||||
continue
|
||||
|
||||
name = d.name
|
||||
task_json = d / FILE_TASK_JSON
|
||||
status = "unknown"
|
||||
assignee = "unassigned"
|
||||
priority = "P2"
|
||||
|
||||
if task_json.is_file():
|
||||
data = _read_json_file(task_json)
|
||||
if data:
|
||||
status = data.get("status", "unknown")
|
||||
assignee = data.get("assignee", "unassigned")
|
||||
priority = data.get("priority", "P2")
|
||||
|
||||
# Filter by assignee
|
||||
if filter_assignee and assignee != filter_assignee:
|
||||
continue
|
||||
|
||||
# Check agent status
|
||||
agent_info = None
|
||||
if registry_data:
|
||||
for agent in registry_data.get("agents", []):
|
||||
if name in agent.get("task_dir", ""):
|
||||
agent_info = agent
|
||||
break
|
||||
|
||||
if agent_info:
|
||||
pid = agent_info.get("pid")
|
||||
worktree = agent_info.get("worktree_path", "")
|
||||
started = agent_info.get("started_at")
|
||||
agent_platform = agent_info.get("platform", "claude")
|
||||
|
||||
if is_running(pid):
|
||||
# Running agent
|
||||
task_dir_rel = agent_info.get("task_dir", "")
|
||||
worktree_task_json = Path(worktree) / task_dir_rel / "task.json"
|
||||
phase_source = task_json
|
||||
if worktree_task_json.is_file():
|
||||
phase_source = worktree_task_json
|
||||
|
||||
phase_info_str = get_phase_info(phase_source)
|
||||
elapsed = calc_elapsed(started)
|
||||
modified = count_modified_files(worktree)
|
||||
|
||||
worktree_data = _read_json_file(phase_source)
|
||||
branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A"
|
||||
|
||||
log_file = Path(worktree) / ".agent-log"
|
||||
last_tool = get_last_tool(log_file, platform=agent_platform)
|
||||
|
||||
running_tasks.append(
|
||||
{
|
||||
"name": name,
|
||||
"priority": priority,
|
||||
"assignee": assignee,
|
||||
"phase_info": phase_info_str,
|
||||
"elapsed": elapsed,
|
||||
"branch": branch,
|
||||
"modified": modified,
|
||||
"last_tool": last_tool,
|
||||
"pid": pid,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Stopped agent
|
||||
task_dir_rel = agent_info.get("task_dir", "")
|
||||
worktree_task_json = Path(worktree) / task_dir_rel / "task.json"
|
||||
worktree_status = "unknown"
|
||||
|
||||
if worktree_task_json.is_file():
|
||||
wt_data = _read_json_file(worktree_task_json)
|
||||
if wt_data:
|
||||
worktree_status = wt_data.get("status", "unknown")
|
||||
|
||||
session_id_file = Path(worktree) / ".session-id"
|
||||
log_file = Path(worktree) / ".agent-log"
|
||||
|
||||
stopped_tasks.append(
|
||||
{
|
||||
"name": name,
|
||||
"worktree": worktree,
|
||||
"status": worktree_status,
|
||||
"session_id_file": session_id_file,
|
||||
"log_file": log_file,
|
||||
"platform": agent_info.get("platform", "claude"),
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Regular task
|
||||
regular_tasks.append(
|
||||
{
|
||||
"name": name,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"assignee": assignee,
|
||||
}
|
||||
)
|
||||
|
||||
# Output running agents
|
||||
if running_tasks:
|
||||
print(f"{Colors.CYAN}Running Agents:{Colors.NC}")
|
||||
for t in running_tasks:
|
||||
priority_color = (
|
||||
Colors.RED
|
||||
if t["priority"] == "P0"
|
||||
else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE)
|
||||
)
|
||||
print(
|
||||
f"{Colors.GREEN}▶{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}"
|
||||
)
|
||||
print(f" Phase: {t['phase_info']}")
|
||||
print(f" Elapsed: {t['elapsed']}")
|
||||
print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}")
|
||||
print(f" Modified: {t['modified']} file(s)")
|
||||
if t["last_tool"]:
|
||||
print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}")
|
||||
print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}")
|
||||
print()
|
||||
|
||||
# Output stopped agents
|
||||
if stopped_tasks:
|
||||
print(f"{Colors.RED}Stopped Agents:{Colors.NC}")
|
||||
for t in stopped_tasks:
|
||||
if t["status"] == "completed":
|
||||
print(
|
||||
f"{Colors.GREEN}✓{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}"
|
||||
)
|
||||
else:
|
||||
if t["session_id_file"].is_file():
|
||||
session_id = (
|
||||
t["session_id_file"].read_text(encoding="utf-8").strip()
|
||||
)
|
||||
last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude"))
|
||||
print(
|
||||
f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}"
|
||||
)
|
||||
if last_msg:
|
||||
print(f'{Colors.DIM}"{last_msg}"{Colors.NC}')
|
||||
# Use CLI adapter for platform-specific resume command
|
||||
adapter = get_cli_adapter(t.get("platform", "claude"))
|
||||
resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"])
|
||||
print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}")
|
||||
else:
|
||||
print(
|
||||
f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}"
|
||||
)
|
||||
print()
|
||||
|
||||
# Separator
|
||||
if (running_tasks or stopped_tasks) and regular_tasks:
|
||||
print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}")
|
||||
print()
|
||||
|
||||
# Output regular tasks grouped by assignee
|
||||
if regular_tasks:
|
||||
# Sort by assignee, priority, status
|
||||
regular_tasks.sort(
|
||||
key=lambda x: (
|
||||
x["assignee"],
|
||||
{"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2),
|
||||
{"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1),
|
||||
)
|
||||
)
|
||||
|
||||
current_assignee = None
|
||||
for t in regular_tasks:
|
||||
if t["assignee"] != current_assignee:
|
||||
if current_assignee is not None:
|
||||
print()
|
||||
print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}")
|
||||
current_assignee = t["assignee"]
|
||||
|
||||
color = status_color(t["status"])
|
||||
priority_color = (
|
||||
Colors.RED
|
||||
if t["priority"] == "P0"
|
||||
else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE)
|
||||
)
|
||||
print(
|
||||
f" {color}●{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}"
|
||||
)
|
||||
|
||||
if running_tasks:
|
||||
print()
|
||||
print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}")
|
||||
print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}")
|
||||
print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}")
|
||||
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_detail(target: str, repo_root: Path) -> int:
|
||||
"""Show detailed task status."""
|
||||
agent = find_agent(target, repo_root)
|
||||
if not agent:
|
||||
print(f"Agent not found: {target}")
|
||||
return 1
|
||||
|
||||
agent_id = agent.get("id", "?")
|
||||
pid = agent.get("pid")
|
||||
worktree = agent.get("worktree_path", "?")
|
||||
task_dir = agent.get("task_dir", "?")
|
||||
started = agent.get("started_at", "?")
|
||||
platform = agent.get("platform", "claude")
|
||||
|
||||
# Check for session-id
|
||||
session_id = ""
|
||||
session_id_file = Path(worktree) / ".session-id"
|
||||
if session_id_file.is_file():
|
||||
session_id = session_id_file.read_text(encoding="utf-8").strip()
|
||||
|
||||
print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}")
|
||||
print()
|
||||
print(f" ID: {agent_id}")
|
||||
print(f" PID: {pid}")
|
||||
print(f" Session: {session_id or 'N/A'}")
|
||||
print(f" Worktree: {worktree}")
|
||||
print(f" Task Dir: {task_dir}")
|
||||
print(f" Started: {started}")
|
||||
print()
|
||||
|
||||
# Status
|
||||
if is_running(pid):
|
||||
print(f" Status: {Colors.GREEN}Running{Colors.NC}")
|
||||
else:
|
||||
print(f" Status: {Colors.RED}Stopped{Colors.NC}")
|
||||
if session_id:
|
||||
print()
|
||||
# Use CLI adapter for platform-specific resume command
|
||||
adapter = get_cli_adapter(platform)
|
||||
resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree)
|
||||
print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}")
|
||||
|
||||
# Task info
|
||||
task_json = repo_root / task_dir / "task.json"
|
||||
if task_json.is_file():
|
||||
print()
|
||||
print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}")
|
||||
print()
|
||||
data = _read_json_file(task_json)
|
||||
if data:
|
||||
print(f" Status: {data.get('status', 'unknown')}")
|
||||
print(f" Branch: {data.get('branch', 'N/A')}")
|
||||
print(f" Base Branch: {data.get('base_branch', 'N/A')}")
|
||||
|
||||
# Git changes
|
||||
if Path(worktree).is_dir():
|
||||
print()
|
||||
print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}")
|
||||
print()
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "status", "--short"],
|
||||
cwd=worktree,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
changes = result.stdout.strip()
|
||||
if changes:
|
||||
for line in changes.splitlines()[:10]:
|
||||
print(f" {line}")
|
||||
total = len(changes.splitlines())
|
||||
if total > 10:
|
||||
print(f" ... and {total - 10} more")
|
||||
else:
|
||||
print(" (no changes)")
|
||||
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_watch(target: str, repo_root: Path) -> int:
|
||||
"""Watch agent log in real-time."""
|
||||
agent = find_agent(target, repo_root)
|
||||
if not agent:
|
||||
print(f"Agent not found: {target}")
|
||||
return 1
|
||||
|
||||
worktree = agent.get("worktree_path", "")
|
||||
log_file = Path(worktree) / ".agent-log"
|
||||
|
||||
if not log_file.is_file():
|
||||
print(f"Log file not found: {log_file}")
|
||||
return 1
|
||||
|
||||
print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}")
|
||||
print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}")
|
||||
print()
|
||||
|
||||
try:
|
||||
tail_follow(log_file)
|
||||
except KeyboardInterrupt:
|
||||
print() # Clean newline after Ctrl+C
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_log(target: str, repo_root: Path) -> int:
|
||||
"""Show recent log entries."""
|
||||
agent = find_agent(target, repo_root)
|
||||
if not agent:
|
||||
print(f"Agent not found: {target}")
|
||||
return 1
|
||||
|
||||
worktree = agent.get("worktree_path", "")
|
||||
platform = agent.get("platform", "claude")
|
||||
log_file = Path(worktree) / ".agent-log"
|
||||
|
||||
if not log_file.is_file():
|
||||
print(f"Log file not found: {log_file}")
|
||||
return 1
|
||||
|
||||
print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}")
|
||||
print(f"{Colors.DIM}Platform: {platform}{Colors.NC}")
|
||||
print()
|
||||
|
||||
lines = log_file.read_text(encoding="utf-8").splitlines()
|
||||
for line in lines[-50:]:
|
||||
try:
|
||||
data = json.loads(line)
|
||||
msg_type = data.get("type", "")
|
||||
|
||||
if platform == "opencode":
|
||||
# OpenCode format
|
||||
if msg_type == "text":
|
||||
text = data.get("text", "")
|
||||
if text:
|
||||
display = text[:300]
|
||||
if len(text) > 300:
|
||||
display += "..."
|
||||
print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}")
|
||||
elif msg_type == "tool_use":
|
||||
tool_name = data.get("tool", "unknown")
|
||||
status = data.get("state", {}).get("status", "")
|
||||
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})")
|
||||
elif msg_type == "step_start":
|
||||
print(f"{Colors.CYAN}[STEP]{Colors.NC} Start")
|
||||
elif msg_type == "step_finish":
|
||||
reason = data.get("reason", "")
|
||||
print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})")
|
||||
elif msg_type == "error":
|
||||
error_msg = data.get("message", "")
|
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}")
|
||||
else:
|
||||
# Claude Code format
|
||||
if msg_type == "system":
|
||||
subtype = data.get("subtype", "")
|
||||
print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}")
|
||||
elif msg_type == "user":
|
||||
content = data.get("message", {}).get("content", "")
|
||||
if content:
|
||||
print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}")
|
||||
elif msg_type == "assistant":
|
||||
content = data.get("message", {}).get("content", [])
|
||||
if content:
|
||||
item = content[0]
|
||||
text = item.get("text")
|
||||
tool = item.get("name")
|
||||
if text:
|
||||
display = text[:300]
|
||||
if len(text) > 300:
|
||||
display += "..."
|
||||
print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}")
|
||||
elif tool:
|
||||
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}")
|
||||
elif msg_type == "result":
|
||||
tool_name = data.get("tool", "unknown")
|
||||
print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_registry(repo_root: Path) -> int:
|
||||
"""Show agent registry."""
|
||||
registry_file = get_registry_file(repo_root)
|
||||
|
||||
print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}")
|
||||
print()
|
||||
print(f"File: {registry_file}")
|
||||
print()
|
||||
|
||||
if registry_file and registry_file.is_file():
|
||||
data = _read_json_file(registry_file)
|
||||
if data:
|
||||
print(json.dumps(data, indent=2))
|
||||
else:
|
||||
print("(registry not found)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Status Monitor")
|
||||
parser.add_argument("-a", "--assignee", help="Filter by assignee")
|
||||
parser.add_argument(
|
||||
"--list", action="store_true", help="List all worktrees and agents"
|
||||
)
|
||||
parser.add_argument("--detail", metavar="TASK", help="Detailed task status")
|
||||
parser.add_argument("--progress", metavar="TASK", help="Quick progress view")
|
||||
parser.add_argument("--watch", metavar="TASK", help="Watch agent log")
|
||||
parser.add_argument("--log", metavar="TASK", help="Show recent log entries")
|
||||
parser.add_argument("--registry", action="store_true", help="Show agent registry")
|
||||
parser.add_argument("target", nargs="?", help="Target task")
|
||||
|
||||
args = parser.parse_args()
|
||||
repo_root = get_repo_root()
|
||||
|
||||
if args.list:
|
||||
return cmd_list(repo_root)
|
||||
elif args.detail:
|
||||
return cmd_detail(args.detail, repo_root)
|
||||
elif args.progress:
|
||||
return cmd_detail(args.progress, repo_root) # Similar to detail
|
||||
elif args.watch:
|
||||
return cmd_watch(args.watch, repo_root)
|
||||
elif args.log:
|
||||
return cmd_log(args.log, repo_root)
|
||||
elif args.registry:
|
||||
return cmd_registry(repo_root)
|
||||
elif args.target:
|
||||
return cmd_detail(args.target, repo_root)
|
||||
else:
|
||||
return cmd_summary(repo_root, args.assignee)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user