first commit
This commit is contained in:
Executable
+5
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Multi-Agent Pipeline Scripts.
|
||||
|
||||
This module provides orchestration for multi-agent workflows.
|
||||
"""
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
"""Bootstrap path setup for multi_agent scripts.
|
||||
|
||||
Import this module before importing from common/:
|
||||
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
This adds the parent scripts/ directory to sys.path so that
|
||||
`from common.xxx import yyy` works when running scripts directly
|
||||
via `python3 .trellis/scripts/multi_agent/some_script.py`.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_scripts_dir = str(Path(__file__).resolve().parent.parent)
|
||||
if _scripts_dir not in sys.path:
|
||||
sys.path.insert(0, _scripts_dir)
|
||||
Executable
+398
@@ -0,0 +1,398 @@
|
||||
#!/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 json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
|
||||
|
||||
from common.git import run_git
|
||||
from common.log import Colors, log_info, log_success, log_warn, log_error
|
||||
from common.paths import FILE_TASK_JSON, 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, log_info, log_success, log_warn, log_error
|
||||
# are now imported from common.log above.
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _warn_submodule_prs(task_dir_abs: Path) -> None:
|
||||
"""Print reminders for any open submodule PRs found in task.json."""
|
||||
task_json = task_dir_abs / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
task_data = json.loads(task_json.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return
|
||||
|
||||
submodule_prs = task_data.get("submodule_prs")
|
||||
if not isinstance(submodule_prs, dict) or not submodule_prs:
|
||||
return
|
||||
|
||||
for name, url in submodule_prs.items():
|
||||
log_warn(f"Submodule PR still open: {name} -> {url}")
|
||||
log_info("Remember to close/merge submodule PRs before cleanup")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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()
|
||||
|
||||
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
|
||||
|
||||
# Check for submodule PRs and archive task directory
|
||||
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():
|
||||
_warn_submodule_prs(task_dir_abs)
|
||||
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(
|
||||
["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 (and check for submodule PRs)
|
||||
task_dir = registry_get_task_dir(worktree_path, repo_root)
|
||||
if task_dir and is_safe_task_path(task_dir, repo_root):
|
||||
task_dir_abs_for_warn = repo_root / task_dir
|
||||
if task_dir_abs_for_warn.is_dir():
|
||||
_warn_submodule_prs(task_dir_abs_for_warn)
|
||||
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(
|
||||
["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(["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(
|
||||
["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(
|
||||
["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(["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(
|
||||
["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(["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
+620
@@ -0,0 +1,620 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Pipeline: Create PR.
|
||||
|
||||
Usage:
|
||||
python3 create_pr.py [task-dir] [--dry-run]
|
||||
|
||||
This script:
|
||||
1. Handles submodule changes (commit, push, PR) if any submodules are configured
|
||||
2. Stages and commits all main-repo changes (excluding workspace/)
|
||||
3. Pushes to origin
|
||||
4. Creates a Draft PR using `gh pr create`
|
||||
5. Updates task.json with status="completed", pr_url, submodule_prs, 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 subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
|
||||
|
||||
from common.config import get_submodule_packages
|
||||
from common.git import run_git
|
||||
from common.io import read_json, write_json
|
||||
from common.log import Colors
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
FILE_TASK_JSON,
|
||||
get_current_task,
|
||||
get_repo_root,
|
||||
)
|
||||
from common.phase import get_phase_for_action
|
||||
|
||||
# Colors, read_json, write_json
|
||||
# are now imported from common.log and common.io above.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Submodule PR Helpers
|
||||
# =============================================================================
|
||||
|
||||
# Warning message prepended to main PR body when submodule PRs exist
|
||||
_SUBMODULE_SQUASH_WARNING_MARKER = (
|
||||
"Merge submodule PR(s) first. If squash-merged, update submodule ref after merge."
|
||||
)
|
||||
|
||||
|
||||
def _get_submodule_default_branch(submodule_abs: Path) -> str:
|
||||
"""Get the default branch of a submodule repository.
|
||||
|
||||
Uses `git symbolic-ref refs/remotes/origin/HEAD` for portability
|
||||
(no grep, no English-dependent output).
|
||||
|
||||
Returns:
|
||||
Default branch name (e.g. "main"), falls back to "main" on failure.
|
||||
"""
|
||||
ret, out, _ = run_git(
|
||||
["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=submodule_abs
|
||||
)
|
||||
if ret == 0 and out.strip():
|
||||
# Output: "refs/remotes/origin/main" -> "main"
|
||||
ref = out.strip()
|
||||
prefix = "refs/remotes/origin/"
|
||||
if ref.startswith(prefix):
|
||||
return ref[len(prefix):]
|
||||
return "main"
|
||||
|
||||
|
||||
def _process_submodule_changes(
|
||||
repo_root: Path,
|
||||
current_branch: str,
|
||||
commit_prefix: str,
|
||||
scope: str,
|
||||
task_name: str,
|
||||
task_data: dict,
|
||||
task_json: Path,
|
||||
dry_run: bool,
|
||||
) -> tuple[dict[str, str], list[str], bool]:
|
||||
"""Process submodule changes: commit, push, create PRs.
|
||||
|
||||
Returns:
|
||||
Tuple of (submodule_prs dict, changed_submodule_paths list, success bool).
|
||||
On failure, submodule_prs contains URLs persisted so far.
|
||||
"""
|
||||
submodule_packages = get_submodule_packages(repo_root)
|
||||
if not submodule_packages:
|
||||
return {}, [], True
|
||||
|
||||
# Load existing submodule_prs for incremental merge
|
||||
raw_prs = task_data.get("submodule_prs")
|
||||
submodule_prs: dict[str, str] = dict(raw_prs) if isinstance(raw_prs, dict) else {}
|
||||
|
||||
# Detect which submodules have changes
|
||||
changed: list[tuple[str, str]] = [] # (name, path)
|
||||
for pkg_name, pkg_path in submodule_packages.items():
|
||||
sub_abs = repo_root / pkg_path
|
||||
if not sub_abs.is_dir():
|
||||
continue
|
||||
|
||||
ret, status_out, _ = run_git(
|
||||
["status", "--porcelain"], cwd=sub_abs
|
||||
)
|
||||
if ret != 0:
|
||||
continue
|
||||
if status_out.strip():
|
||||
changed.append((pkg_name, pkg_path))
|
||||
|
||||
if not changed:
|
||||
return submodule_prs, [], True
|
||||
|
||||
# Determine submodule branch name: <repo-dir-name>/<main-branch>
|
||||
repo_dir_name = repo_root.name
|
||||
sub_branch = f"{repo_dir_name}/{current_branch}"
|
||||
|
||||
print(f"\n{Colors.BLUE}=== Submodule Changes Detected ==={Colors.NC}")
|
||||
for pkg_name, pkg_path in changed:
|
||||
print(f" - {pkg_name} ({pkg_path})")
|
||||
print()
|
||||
|
||||
changed_paths: list[str] = []
|
||||
|
||||
for pkg_name, pkg_path in changed:
|
||||
sub_abs = repo_root / pkg_path
|
||||
sub_base = _get_submodule_default_branch(sub_abs)
|
||||
sub_commit_msg = f"{commit_prefix}({scope}): {task_name}"
|
||||
|
||||
print(f"{Colors.YELLOW}Processing submodule: {pkg_name} ({pkg_path}){Colors.NC}")
|
||||
print(f" Submodule base branch: {sub_base}")
|
||||
print(f" Submodule branch: {sub_branch}")
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY-RUN] Would checkout branch: {sub_branch}")
|
||||
print(f" [DRY-RUN] Would commit: {sub_commit_msg}")
|
||||
print(f" [DRY-RUN] Would push to: origin/{sub_branch}")
|
||||
print(f" [DRY-RUN] Would create PR: {sub_branch} -> {sub_base}")
|
||||
submodule_prs[pkg_name] = "https://github.com/example/repo/pull/DRY-RUN"
|
||||
changed_paths.append(pkg_path)
|
||||
continue
|
||||
|
||||
# --- Checkout or create branch in submodule ---
|
||||
ret, _, _ = run_git(
|
||||
["show-ref", "--verify", "--quiet", f"refs/heads/{sub_branch}"],
|
||||
cwd=sub_abs,
|
||||
)
|
||||
if ret == 0:
|
||||
# Branch exists, checkout
|
||||
ret, _, err = run_git(
|
||||
["checkout", sub_branch], cwd=sub_abs
|
||||
)
|
||||
if ret != 0:
|
||||
print(f"{Colors.RED}Failed to checkout branch in {pkg_name}: {err}{Colors.NC}")
|
||||
return submodule_prs, changed_paths, False
|
||||
|
||||
# Check for divergence (reuse risk)
|
||||
ret_anc, _, _ = run_git(
|
||||
["merge-base", "--is-ancestor", sub_base, sub_branch],
|
||||
cwd=sub_abs,
|
||||
)
|
||||
if ret_anc != 0:
|
||||
print(
|
||||
f" {Colors.YELLOW}[WARN] submodule branch has diverged history, "
|
||||
f"consider recreating{Colors.NC}"
|
||||
)
|
||||
else:
|
||||
# Create new branch
|
||||
ret, _, err = run_git(
|
||||
["checkout", "-b", sub_branch], cwd=sub_abs
|
||||
)
|
||||
if ret != 0:
|
||||
print(f"{Colors.RED}Failed to create branch in {pkg_name}: {err}{Colors.NC}")
|
||||
return submodule_prs, changed_paths, False
|
||||
|
||||
# --- Stage and commit ---
|
||||
run_git(["add", "-A"], cwd=sub_abs)
|
||||
|
||||
ret, _, _ = run_git(["diff", "--cached", "--quiet"], cwd=sub_abs)
|
||||
if ret != 0:
|
||||
# Has staged changes
|
||||
ret, _, err = run_git(
|
||||
["commit", "-m", sub_commit_msg], cwd=sub_abs
|
||||
)
|
||||
if ret != 0:
|
||||
print(f"{Colors.RED}Failed to commit in {pkg_name}: {err}{Colors.NC}")
|
||||
return submodule_prs, changed_paths, False
|
||||
print(f" {Colors.GREEN}Committed in {pkg_name}{Colors.NC}")
|
||||
else:
|
||||
print(f" No staged changes in {pkg_name}, skipping commit")
|
||||
|
||||
# --- Push ---
|
||||
ret, _, err = run_git(
|
||||
["push", "-u", "origin", sub_branch], cwd=sub_abs
|
||||
)
|
||||
if ret != 0:
|
||||
print(f"{Colors.RED}Failed to push {pkg_name}: {err}{Colors.NC}")
|
||||
return submodule_prs, changed_paths, False
|
||||
print(f" {Colors.GREEN}Pushed {pkg_name} to origin/{sub_branch}{Colors.NC}")
|
||||
|
||||
# --- Create or reuse PR ---
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh", "pr", "list",
|
||||
"--head", sub_branch,
|
||||
"--base", sub_base,
|
||||
"--json", "url",
|
||||
"--jq", ".[0].url",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=str(sub_abs),
|
||||
)
|
||||
existing_sub_pr = result.stdout.strip()
|
||||
|
||||
if existing_sub_pr:
|
||||
print(f" {Colors.YELLOW}PR already exists: {existing_sub_pr}{Colors.NC}")
|
||||
sub_pr_url = existing_sub_pr
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh", "pr", "create",
|
||||
"--draft",
|
||||
"--base", sub_base,
|
||||
"--title", f"{commit_prefix}({scope}): {task_name} [{pkg_name}]",
|
||||
"--body", f"Submodule changes for {task_name}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=str(sub_abs),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
f"{Colors.RED}Failed to create PR for {pkg_name}: "
|
||||
f"{result.stderr}{Colors.NC}"
|
||||
)
|
||||
return submodule_prs, changed_paths, False
|
||||
|
||||
sub_pr_url = result.stdout.strip()
|
||||
print(f" {Colors.GREEN}PR created for {pkg_name}: {sub_pr_url}{Colors.NC}")
|
||||
|
||||
# Persist immediately (incremental, supports re-entry)
|
||||
submodule_prs[pkg_name] = sub_pr_url
|
||||
task_data["submodule_prs"] = submodule_prs
|
||||
write_json(task_json, task_data)
|
||||
|
||||
changed_paths.append(pkg_path)
|
||||
|
||||
return submodule_prs, changed_paths, True
|
||||
|
||||
|
||||
def _build_submodule_warning(submodule_prs: dict[str, str]) -> str:
|
||||
"""Build the squash-merge warning block for the main PR body."""
|
||||
pr_lines = "\n".join(f"> - {name}: {url}" for name, url in submodule_prs.items())
|
||||
return (
|
||||
f"> {_SUBMODULE_SQUASH_WARNING_MARKER}\n"
|
||||
f">\n"
|
||||
f"> Submodule PRs:\n"
|
||||
f"{pr_lines}\n"
|
||||
f"\n---\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_submodule_warning_on_existing_pr(
|
||||
submodule_prs: dict[str, str],
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Read-modify-write: add squash warning to existing PR if missing."""
|
||||
if dry_run:
|
||||
print("[DRY-RUN] Would check/add submodule warning to existing PR")
|
||||
return
|
||||
|
||||
# Read current PR body
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh", "pr", "view",
|
||||
"--json", "body",
|
||||
"--jq", ".body",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
|
||||
current_body = result.stdout.strip()
|
||||
if _SUBMODULE_SQUASH_WARNING_MARKER in current_body:
|
||||
return # Warning already present
|
||||
|
||||
# Prepend warning to existing body
|
||||
warning = _build_submodule_warning(submodule_prs)
|
||||
new_body = warning + current_body
|
||||
|
||||
subprocess.run(
|
||||
["gh", "pr", "edit", "--body", new_body],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
print(f" {Colors.GREEN}Added submodule merge warning to existing PR{Colors.NC}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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(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(["branch", "--show-current"])
|
||||
current_branch = branch_out.strip()
|
||||
print(f"Current branch: {current_branch}")
|
||||
|
||||
# =============================================================================
|
||||
# Submodule PR Flow (runs BEFORE main repo staging)
|
||||
# =============================================================================
|
||||
submodule_prs, changed_submodule_paths, sub_success = _process_submodule_changes(
|
||||
repo_root=repo_root,
|
||||
current_branch=current_branch,
|
||||
commit_prefix=commit_prefix,
|
||||
scope=scope,
|
||||
task_name=task_name,
|
||||
task_data=task_data,
|
||||
task_json=task_json,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
if not sub_success:
|
||||
print(
|
||||
f"\n{Colors.RED}Submodule PR flow failed. "
|
||||
f"Skipping main repo commit/PR.{Colors.NC}"
|
||||
)
|
||||
print("Already-created submodule PRs have been saved to task.json.")
|
||||
return 1
|
||||
|
||||
# =============================================================================
|
||||
# Main Repo: Stage, Commit, Push, PR
|
||||
# =============================================================================
|
||||
|
||||
# Check for changes
|
||||
print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}")
|
||||
|
||||
# Stage changes
|
||||
run_git(["add", "-A"])
|
||||
|
||||
# Exclude workspace and temp files
|
||||
run_git(["reset", f"{DIR_WORKFLOW}/workspace/"])
|
||||
run_git(["reset", ".agent-log", ".session-id"])
|
||||
|
||||
# If submodules changed, ensure their ref updates are staged
|
||||
for sub_path in changed_submodule_paths:
|
||||
run_git(["add", sub_path])
|
||||
|
||||
# Check if there are staged changes
|
||||
ret, _, _ = run_git(["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(
|
||||
["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(["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(["diff", "--cached", "--name-only"])
|
||||
for line in staged_out.splitlines():
|
||||
print(f" - {line}")
|
||||
else:
|
||||
run_git(["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(["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 = ""
|
||||
|
||||
# Build PR body with optional submodule warning
|
||||
has_submodule_prs = bool(submodule_prs)
|
||||
|
||||
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)")
|
||||
if has_submodule_prs:
|
||||
print(" Body includes submodule merge warning")
|
||||
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
|
||||
|
||||
# Read-modify-write: add submodule warning if missing
|
||||
if has_submodule_prs:
|
||||
_ensure_submodule_warning_on_existing_pr(
|
||||
submodule_prs, args.dry_run
|
||||
)
|
||||
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")
|
||||
|
||||
# Prepend submodule warning if applicable
|
||||
if has_submodule_prs:
|
||||
pr_body = _build_submodule_warning(submodule_prs) + pr_body
|
||||
|
||||
# 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}")
|
||||
if has_submodule_prs:
|
||||
print(f" submodule_prs: {submodule_prs}")
|
||||
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
|
||||
if has_submodule_prs:
|
||||
task_data["submodule_prs"] = submodule_prs
|
||||
|
||||
write_json(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(["reset", "HEAD"])
|
||||
|
||||
print()
|
||||
print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}")
|
||||
print(f"PR URL: {pr_url}")
|
||||
if has_submodule_prs:
|
||||
print("Submodule PRs:")
|
||||
for name, url in submodule_prs.items():
|
||||
print(f" - {name}: {url}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+213
@@ -0,0 +1,213 @@
|
||||
#!/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
|
||||
|
||||
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
|
||||
|
||||
from common.cli_adapter import get_cli_adapter
|
||||
from common.log import Colors, log_info, log_success, log_error
|
||||
from common.paths import get_repo_root
|
||||
from common.developer import ensure_developer
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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", "codex", "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)
|
||||
if adapter.requires_agent_definition_file:
|
||||
plan_md = adapter.get_agent_path("plan", project_root)
|
||||
if not plan_md.is_file():
|
||||
log_error(f"Agent definition 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
+539
@@ -0,0 +1,539 @@
|
||||
#!/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 os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
|
||||
|
||||
from common.cli_adapter import get_cli_adapter
|
||||
from common.git import run_git
|
||||
from common.io import read_json, write_json
|
||||
from common.log import Colors, log_info, log_success, log_warn, log_error
|
||||
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.config import (
|
||||
get_default_package,
|
||||
get_packages,
|
||||
get_submodule_packages,
|
||||
validate_package,
|
||||
)
|
||||
from common.worktree import (
|
||||
get_worktree_base_dir,
|
||||
get_worktree_config,
|
||||
get_worktree_copy_files,
|
||||
get_worktree_post_create_hooks,
|
||||
)
|
||||
|
||||
# Colors, log_info, log_success, log_warn, log_error, read_json, write_json
|
||||
# are now imported from common.log and common.io above.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_PLATFORM = "claude"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Submodule Init
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _init_submodules_for_task(
|
||||
task_data: dict, worktree_path: str, project_root: Path
|
||||
) -> None:
|
||||
"""Initialize submodules in worktree based on task's target package.
|
||||
|
||||
Resolves the target package from task_data.package -> default_package -> None.
|
||||
Only initializes submodule-type packages. Idempotent: skips already-initialized
|
||||
submodules to avoid detaching HEAD on in-progress work.
|
||||
"""
|
||||
# Skip if not a monorepo (no packages configured)
|
||||
if get_packages(project_root) is None:
|
||||
return
|
||||
|
||||
# Resolve package: task.package -> default_package -> None
|
||||
task_package = task_data.get("package")
|
||||
package = None
|
||||
|
||||
if task_package and isinstance(task_package, str):
|
||||
if validate_package(task_package, project_root):
|
||||
package = task_package
|
||||
else:
|
||||
log_warn(
|
||||
f"package '{task_package}' not found in config.yaml, "
|
||||
"skipping submodule init"
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Fallback to default_package
|
||||
default_pkg = get_default_package(project_root)
|
||||
if default_pkg:
|
||||
if validate_package(default_pkg, project_root):
|
||||
package = default_pkg
|
||||
else:
|
||||
log_warn(
|
||||
f"package '{default_pkg}' not found in config.yaml, "
|
||||
"skipping submodule init"
|
||||
)
|
||||
return
|
||||
|
||||
if not package:
|
||||
log_warn("no package specified, skipping submodule init")
|
||||
return
|
||||
|
||||
# Check if this package is a submodule
|
||||
submodule_packages = get_submodule_packages(project_root)
|
||||
if package not in submodule_packages:
|
||||
log_info(f"Package '{package}' is not a submodule, skipping submodule init")
|
||||
return
|
||||
|
||||
submodule_path = submodule_packages[package]
|
||||
log_info(f"Checking submodule status for '{package}' ({submodule_path})...")
|
||||
|
||||
# Run git submodule status in worktree directory
|
||||
ret, status_out, status_err = run_git(
|
||||
["submodule", "status", submodule_path], cwd=Path(worktree_path)
|
||||
)
|
||||
|
||||
if ret != 0:
|
||||
log_warn(
|
||||
f"git submodule status failed for '{submodule_path}': {status_err.strip()}, "
|
||||
"skipping submodule init"
|
||||
)
|
||||
return
|
||||
|
||||
# Parse the prefix character from submodule status output
|
||||
# Format: "<prefix><sha1> <path> (<describe>)"
|
||||
# Prefix: '-' (uninitialized), ' ' (normal), '+' (commit mismatch), 'U' (conflict)
|
||||
status_line = status_out.rstrip("\n\r")
|
||||
if not status_line:
|
||||
log_warn(f"Empty submodule status for '{submodule_path}', skipping")
|
||||
return
|
||||
|
||||
prefix = status_line[0]
|
||||
|
||||
if prefix == "-":
|
||||
# Uninitialized: run git submodule update --init
|
||||
log_info(f"Initializing submodule '{submodule_path}'...")
|
||||
ret, _, err = run_git(
|
||||
["submodule", "update", "--init", submodule_path],
|
||||
cwd=Path(worktree_path),
|
||||
)
|
||||
if ret != 0:
|
||||
log_warn(f"Failed to initialize submodule '{submodule_path}': {err.strip()}")
|
||||
else:
|
||||
log_success(f"Submodule '{submodule_path}' initialized")
|
||||
elif prefix == " ":
|
||||
log_info(f"Submodule '{submodule_path}' already initialized, skipping")
|
||||
elif prefix == "+":
|
||||
log_warn(
|
||||
f"submodule {submodule_path} has local changes, skipping update"
|
||||
)
|
||||
elif prefix == "U":
|
||||
log_warn(
|
||||
f"submodule {submodule_path} has conflicts, skipping"
|
||||
)
|
||||
else:
|
||||
log_warn(
|
||||
f"Unknown submodule status prefix '{prefix}' for '{submodule_path}', skipping"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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", "codex", "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
|
||||
task_dir_path = Path(task_dir_arg)
|
||||
if task_dir_path.is_absolute():
|
||||
task_dir_abs = task_dir_path
|
||||
else:
|
||||
task_dir_abs = project_root / task_dir_path
|
||||
|
||||
try:
|
||||
task_dir_relative = task_dir_abs.relative_to(project_root).as_posix()
|
||||
except ValueError:
|
||||
task_dir_relative = str(task_dir_abs)
|
||||
|
||||
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
|
||||
|
||||
if adapter.requires_agent_definition_file:
|
||||
dispatch_md = adapter.get_agent_path("dispatch", project_root)
|
||||
if not dispatch_md.is_file():
|
||||
log_error(f"Agent definition 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(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(
|
||||
["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(
|
||||
["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
||||
cwd=project_root,
|
||||
)
|
||||
if ret == 0:
|
||||
log_info("Branch exists, checking out...")
|
||||
ret, _, err = run_git(
|
||||
["worktree", "add", worktree_path, branch], cwd=project_root
|
||||
)
|
||||
else:
|
||||
log_info(f"Creating new branch: {branch}")
|
||||
ret, _, err = run_git(
|
||||
["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(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")
|
||||
|
||||
# ----- Initialize submodules (before hooks, so hooks can use submodule content) -----
|
||||
_init_submodules_for_task(task_data, worktree_path, project_root)
|
||||
|
||||
# ----- 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}")
|
||||
|
||||
# ----- Initialize submodules (idempotent, for reused worktrees) -----
|
||||
_init_submodules_for_task(task_data, worktree_path, project_root)
|
||||
|
||||
# =============================================================================
|
||||
# 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(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
+76
@@ -0,0 +1,76 @@
|
||||
#!/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
|
||||
|
||||
Entry shim — delegates to status_display and status_monitor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
|
||||
|
||||
from common.paths import get_repo_root
|
||||
|
||||
from .status_display import (
|
||||
cmd_detail,
|
||||
cmd_help,
|
||||
cmd_list,
|
||||
cmd_registry,
|
||||
cmd_summary,
|
||||
)
|
||||
from .status_monitor import cmd_log, cmd_watch
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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())
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Pipeline: Status display and formatting.
|
||||
|
||||
Provides:
|
||||
cmd_help - Show help text
|
||||
cmd_list - List worktrees and agents
|
||||
cmd_summary - Summary of all tasks with agent status
|
||||
cmd_detail - Detailed single-agent status
|
||||
cmd_registry - Dump agent registry
|
||||
|
||||
Also exports shared utilities used by status_monitor:
|
||||
is_running, find_agent, get_registry_file, calc_elapsed, count_modified_files
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from common.cli_adapter import get_cli_adapter
|
||||
from common.io import read_json
|
||||
from common.log import Colors
|
||||
from common.developer import ensure_developer
|
||||
from common.paths import (
|
||||
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.tasks import iter_active_tasks
|
||||
from common.worktree import get_agents_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared Utilities
|
||||
# =============================================================================
|
||||
|
||||
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(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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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(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."""
|
||||
# Import lazily to avoid circular import at module level
|
||||
from .status_monitor import get_last_tool, get_last_message
|
||||
|
||||
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(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(registry_file)
|
||||
if registry_file and registry_file.is_file()
|
||||
else None
|
||||
)
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
name = t.dir_name
|
||||
status = t.status
|
||||
assignee = t.assignee or "unassigned"
|
||||
priority = t.priority
|
||||
|
||||
# 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 = t.directory / "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(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(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(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_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(registry_file)
|
||||
if data:
|
||||
print(json.dumps(data, indent=2))
|
||||
else:
|
||||
print("(registry not found)")
|
||||
|
||||
return 0
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Pipeline: Process monitoring and log parsing.
|
||||
|
||||
Provides:
|
||||
tail_follow - Follow a file like 'tail -f'
|
||||
get_last_tool - Get last tool call from agent log
|
||||
get_last_message - Get last assistant text from agent log
|
||||
cmd_watch - Watch agent log in real-time
|
||||
cmd_log - Show recent log entries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from common.log import Colors
|
||||
|
||||
from .status_display import find_agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Log Parsing
|
||||
# =============================================================================
|
||||
|
||||
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_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
|
||||
Reference in New Issue
Block a user