docs: 更新 HTTP 错误码、用户积分、占卜运行及用户资料协议文档

This commit is contained in:
qzl
2026-04-10 16:45:45 +08:00
parent 1bc8bc6a27
commit 17ef460391
78 changed files with 18680 additions and 25 deletions
+5
View File
@@ -0,0 +1,5 @@
"""
Multi-Agent Pipeline Scripts.
This module provides orchestration for multi-agent workflows.
"""
+403
View File
@@ -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())
+329
View File
@@ -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())
+236
View File
@@ -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())
+465
View File
@@ -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())
+817
View File
@@ -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())