1371 lines
48 KiB
Python
1371 lines
48 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
Task Management Script for Multi-Agent Pipeline.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>]
|
||
|
|
python3 task.py init-context <dir> <type> # Initialize jsonl files
|
||
|
|
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
|
||
|
|
python3 task.py validate <dir> # Validate jsonl files
|
||
|
|
python3 task.py list-context <dir> # List jsonl entries
|
||
|
|
python3 task.py start <dir> # Set as current task
|
||
|
|
python3 task.py finish # Clear current task
|
||
|
|
python3 task.py set-branch <dir> <branch> # Set git branch
|
||
|
|
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
|
||
|
|
python3 task.py set-scope <dir> <scope> # Set scope for PR title
|
||
|
|
python3 task.py create-pr [dir] [--dry-run] # Create PR from task
|
||
|
|
python3 task.py archive <task-name> # Archive completed task
|
||
|
|
python3 task.py list # List active tasks
|
||
|
|
python3 task.py list-archive [month] # List archived tasks
|
||
|
|
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
|
||
|
|
python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import sys
|
||
|
|
|
||
|
|
# IMPORTANT: Force stdout to use UTF-8 on Windows
|
||
|
|
# This fixes UnicodeEncodeError when outputting non-ASCII characters
|
||
|
|
if sys.platform == "win32":
|
||
|
|
import io as _io
|
||
|
|
if hasattr(sys.stdout, "reconfigure"):
|
||
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||
|
|
elif hasattr(sys.stdout, "detach"):
|
||
|
|
sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
from datetime import datetime
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from common.cli_adapter import get_cli_adapter_auto
|
||
|
|
from common.git_context import _run_git_command
|
||
|
|
from common.paths import (
|
||
|
|
DIR_WORKFLOW,
|
||
|
|
DIR_TASKS,
|
||
|
|
DIR_SPEC,
|
||
|
|
DIR_ARCHIVE,
|
||
|
|
FILE_TASK_JSON,
|
||
|
|
get_repo_root,
|
||
|
|
get_developer,
|
||
|
|
get_tasks_dir,
|
||
|
|
get_current_task,
|
||
|
|
set_current_task,
|
||
|
|
clear_current_task,
|
||
|
|
generate_task_date_prefix,
|
||
|
|
)
|
||
|
|
from common.task_utils import (
|
||
|
|
find_task_by_name,
|
||
|
|
archive_task_complete,
|
||
|
|
)
|
||
|
|
from common.config import get_hooks
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Colors
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
class Colors:
|
||
|
|
RED = "\033[0;31m"
|
||
|
|
GREEN = "\033[0;32m"
|
||
|
|
YELLOW = "\033[1;33m"
|
||
|
|
BLUE = "\033[0;34m"
|
||
|
|
CYAN = "\033[0;36m"
|
||
|
|
NC = "\033[0m"
|
||
|
|
|
||
|
|
|
||
|
|
def colored(text: str, color: str) -> str:
|
||
|
|
"""Apply color to text."""
|
||
|
|
return f"{color}{text}{Colors.NC}"
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Lifecycle Hooks
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def _run_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
||
|
|
"""Run lifecycle hooks for an event.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
event: Event name (e.g. "after_create").
|
||
|
|
task_json_path: Absolute path to the task's task.json.
|
||
|
|
repo_root: Repository root for cwd and config lookup.
|
||
|
|
"""
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
|
||
|
|
commands = get_hooks(event, repo_root)
|
||
|
|
if not commands:
|
||
|
|
return
|
||
|
|
|
||
|
|
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
||
|
|
|
||
|
|
for cmd in commands:
|
||
|
|
try:
|
||
|
|
result = subprocess.run(
|
||
|
|
cmd,
|
||
|
|
shell=True,
|
||
|
|
cwd=repo_root,
|
||
|
|
env=env,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
encoding="utf-8",
|
||
|
|
errors="replace",
|
||
|
|
)
|
||
|
|
if result.returncode != 0:
|
||
|
|
print(
|
||
|
|
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
|
||
|
|
file=sys.stderr,
|
||
|
|
)
|
||
|
|
if result.stderr.strip():
|
||
|
|
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||
|
|
except Exception as e:
|
||
|
|
print(
|
||
|
|
colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW),
|
||
|
|
file=sys.stderr,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 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
|
||
|
|
|
||
|
|
|
||
|
|
def _slugify(title: str) -> str:
|
||
|
|
"""Convert title to slug (only works with ASCII)."""
|
||
|
|
result = title.lower()
|
||
|
|
result = re.sub(r"[^a-z0-9]", "-", result)
|
||
|
|
result = re.sub(r"-+", "-", result)
|
||
|
|
result = result.strip("-")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def _resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
|
||
|
|
"""Resolve task directory to absolute path.
|
||
|
|
|
||
|
|
Supports:
|
||
|
|
- Absolute path: /path/to/task
|
||
|
|
- Relative path: .trellis/tasks/01-31-my-task
|
||
|
|
- Task name: my-task (uses find_task_by_name for lookup)
|
||
|
|
"""
|
||
|
|
if not target_dir:
|
||
|
|
return Path()
|
||
|
|
|
||
|
|
# Absolute path
|
||
|
|
if target_dir.startswith("/"):
|
||
|
|
return Path(target_dir)
|
||
|
|
|
||
|
|
# Relative path (contains path separator or starts with .trellis)
|
||
|
|
if "/" in target_dir or target_dir.startswith(".trellis"):
|
||
|
|
return repo_root / target_dir
|
||
|
|
|
||
|
|
# Task name - try to find in tasks directory
|
||
|
|
tasks_dir = get_tasks_dir(repo_root)
|
||
|
|
found = find_task_by_name(target_dir, tasks_dir)
|
||
|
|
if found:
|
||
|
|
return found
|
||
|
|
|
||
|
|
# Fallback to treating as relative path
|
||
|
|
return repo_root / target_dir
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# JSONL Default Content Generators
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def get_implement_base() -> list[dict]:
|
||
|
|
"""Get base implement context entries."""
|
||
|
|
return [
|
||
|
|
{"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def get_implement_backend() -> list[dict]:
|
||
|
|
"""Get backend implement context entries."""
|
||
|
|
return [
|
||
|
|
{"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md", "reason": "Backend development guide"},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def get_implement_frontend() -> list[dict]:
|
||
|
|
"""Get frontend implement context entries."""
|
||
|
|
return [
|
||
|
|
{"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md", "reason": "Frontend development guide"},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def get_check_context(dev_type: str, repo_root: Path) -> list[dict]:
|
||
|
|
"""Get check context entries."""
|
||
|
|
adapter = get_cli_adapter_auto(repo_root)
|
||
|
|
|
||
|
|
entries = [
|
||
|
|
{"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"},
|
||
|
|
]
|
||
|
|
|
||
|
|
if dev_type in ("backend", "fullstack"):
|
||
|
|
entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"})
|
||
|
|
if dev_type in ("frontend", "fullstack"):
|
||
|
|
entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"})
|
||
|
|
|
||
|
|
return entries
|
||
|
|
|
||
|
|
|
||
|
|
def get_debug_context(dev_type: str, repo_root: Path) -> list[dict]:
|
||
|
|
"""Get debug context entries."""
|
||
|
|
adapter = get_cli_adapter_auto(repo_root)
|
||
|
|
|
||
|
|
entries: list[dict] = []
|
||
|
|
|
||
|
|
if dev_type in ("backend", "fullstack"):
|
||
|
|
entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"})
|
||
|
|
if dev_type in ("frontend", "fullstack"):
|
||
|
|
entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"})
|
||
|
|
|
||
|
|
return entries
|
||
|
|
|
||
|
|
|
||
|
|
def _write_jsonl(path: Path, entries: list[dict]) -> None:
|
||
|
|
"""Write entries to JSONL file."""
|
||
|
|
lines = [json.dumps(entry, ensure_ascii=False) for entry in entries]
|
||
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Task Operations
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def ensure_tasks_dir(repo_root: Path) -> Path:
|
||
|
|
"""Ensure tasks directory exists."""
|
||
|
|
tasks_dir = get_tasks_dir(repo_root)
|
||
|
|
archive_dir = tasks_dir / "archive"
|
||
|
|
|
||
|
|
if not tasks_dir.exists():
|
||
|
|
tasks_dir.mkdir(parents=True)
|
||
|
|
print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
|
||
|
|
|
||
|
|
if not archive_dir.exists():
|
||
|
|
archive_dir.mkdir(parents=True)
|
||
|
|
|
||
|
|
return tasks_dir
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: create
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_create(args: argparse.Namespace) -> int:
|
||
|
|
"""Create a new task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
|
||
|
|
if not args.title:
|
||
|
|
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Default assignee to current developer
|
||
|
|
assignee = args.assignee
|
||
|
|
if not assignee:
|
||
|
|
assignee = get_developer(repo_root)
|
||
|
|
if not assignee:
|
||
|
|
print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
ensure_tasks_dir(repo_root)
|
||
|
|
|
||
|
|
# Get current developer as creator
|
||
|
|
creator = get_developer(repo_root) or assignee
|
||
|
|
|
||
|
|
# Generate slug if not provided
|
||
|
|
slug = args.slug or _slugify(args.title)
|
||
|
|
if not slug:
|
||
|
|
print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Create task directory with MM-DD-slug format
|
||
|
|
tasks_dir = get_tasks_dir(repo_root)
|
||
|
|
date_prefix = generate_task_date_prefix()
|
||
|
|
dir_name = f"{date_prefix}-{slug}"
|
||
|
|
task_dir = tasks_dir / dir_name
|
||
|
|
task_json_path = task_dir / FILE_TASK_JSON
|
||
|
|
|
||
|
|
if task_dir.exists():
|
||
|
|
print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr)
|
||
|
|
else:
|
||
|
|
task_dir.mkdir(parents=True)
|
||
|
|
|
||
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
||
|
|
|
||
|
|
# Record current branch as base_branch (PR target)
|
||
|
|
_, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root)
|
||
|
|
current_branch = branch_out.strip() or "main"
|
||
|
|
|
||
|
|
task_data = {
|
||
|
|
"id": slug,
|
||
|
|
"name": slug,
|
||
|
|
"title": args.title,
|
||
|
|
"description": args.description or "",
|
||
|
|
"status": "planning",
|
||
|
|
"dev_type": None,
|
||
|
|
"scope": None,
|
||
|
|
"priority": args.priority,
|
||
|
|
"creator": creator,
|
||
|
|
"assignee": assignee,
|
||
|
|
"createdAt": today,
|
||
|
|
"completedAt": None,
|
||
|
|
"branch": None,
|
||
|
|
"base_branch": current_branch,
|
||
|
|
"worktree_path": None,
|
||
|
|
"current_phase": 0,
|
||
|
|
"next_action": [
|
||
|
|
{"phase": 1, "action": "implement"},
|
||
|
|
{"phase": 2, "action": "check"},
|
||
|
|
{"phase": 3, "action": "finish"},
|
||
|
|
{"phase": 4, "action": "create-pr"},
|
||
|
|
],
|
||
|
|
"commit": None,
|
||
|
|
"pr_url": None,
|
||
|
|
"subtasks": [],
|
||
|
|
"children": [],
|
||
|
|
"parent": None,
|
||
|
|
"relatedFiles": [],
|
||
|
|
"notes": "",
|
||
|
|
"meta": {},
|
||
|
|
}
|
||
|
|
|
||
|
|
_write_json_file(task_json_path, task_data)
|
||
|
|
|
||
|
|
# Handle --parent: establish bidirectional link
|
||
|
|
if args.parent:
|
||
|
|
parent_dir = _resolve_task_dir(args.parent, repo_root)
|
||
|
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||
|
|
if not parent_json_path.is_file():
|
||
|
|
print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
|
||
|
|
else:
|
||
|
|
parent_data = _read_json_file(parent_json_path)
|
||
|
|
if parent_data:
|
||
|
|
# Add child to parent's children list
|
||
|
|
parent_children = parent_data.get("children", [])
|
||
|
|
if dir_name not in parent_children:
|
||
|
|
parent_children.append(dir_name)
|
||
|
|
parent_data["children"] = parent_children
|
||
|
|
_write_json_file(parent_json_path, parent_data)
|
||
|
|
|
||
|
|
# Set parent in child's task.json
|
||
|
|
task_data["parent"] = parent_dir.name
|
||
|
|
_write_json_file(task_json_path, task_data)
|
||
|
|
|
||
|
|
print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||
|
|
|
||
|
|
print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
|
||
|
|
print("", file=sys.stderr)
|
||
|
|
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
||
|
|
print(" 1. Create prd.md with requirements", file=sys.stderr)
|
||
|
|
print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr)
|
||
|
|
print(" 3. Run: python3 task.py start <dir>", file=sys.stderr)
|
||
|
|
print("", file=sys.stderr)
|
||
|
|
|
||
|
|
# Output relative path for script chaining
|
||
|
|
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
|
||
|
|
|
||
|
|
_run_hooks("after_create", task_json_path, repo_root)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: init-context
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_init_context(args: argparse.Namespace) -> int:
|
||
|
|
"""Initialize JSONL context files for a task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
target_dir = _resolve_task_dir(args.dir, repo_root)
|
||
|
|
dev_type = args.type
|
||
|
|
|
||
|
|
if not dev_type:
|
||
|
|
print(colored("Error: Missing arguments", Colors.RED))
|
||
|
|
print("Usage: python3 task.py init-context <task-dir> <dev_type>")
|
||
|
|
print(" dev_type: backend | frontend | fullstack | test | docs")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
if not target_dir.is_dir():
|
||
|
|
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
print(colored("=== Initializing Agent Context Files ===", Colors.BLUE))
|
||
|
|
print(f"Target dir: {target_dir}")
|
||
|
|
print(f"Dev type: {dev_type}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
# implement.jsonl
|
||
|
|
print(colored("Creating implement.jsonl...", Colors.CYAN))
|
||
|
|
implement_entries = get_implement_base()
|
||
|
|
if dev_type in ("backend", "test"):
|
||
|
|
implement_entries.extend(get_implement_backend())
|
||
|
|
elif dev_type == "frontend":
|
||
|
|
implement_entries.extend(get_implement_frontend())
|
||
|
|
elif dev_type == "fullstack":
|
||
|
|
implement_entries.extend(get_implement_backend())
|
||
|
|
implement_entries.extend(get_implement_frontend())
|
||
|
|
|
||
|
|
implement_file = target_dir / "implement.jsonl"
|
||
|
|
_write_jsonl(implement_file, implement_entries)
|
||
|
|
print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries")
|
||
|
|
|
||
|
|
# check.jsonl
|
||
|
|
print(colored("Creating check.jsonl...", Colors.CYAN))
|
||
|
|
check_entries = get_check_context(dev_type, repo_root)
|
||
|
|
check_file = target_dir / "check.jsonl"
|
||
|
|
_write_jsonl(check_file, check_entries)
|
||
|
|
print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries")
|
||
|
|
|
||
|
|
# debug.jsonl
|
||
|
|
print(colored("Creating debug.jsonl...", Colors.CYAN))
|
||
|
|
debug_entries = get_debug_context(dev_type, repo_root)
|
||
|
|
debug_file = target_dir / "debug.jsonl"
|
||
|
|
_write_jsonl(debug_file, debug_entries)
|
||
|
|
print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries")
|
||
|
|
|
||
|
|
print()
|
||
|
|
print(colored("✓ All context files created", Colors.GREEN))
|
||
|
|
print()
|
||
|
|
print(colored("Next steps:", Colors.BLUE))
|
||
|
|
print(" 1. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>")
|
||
|
|
print(" 2. Set as current: python3 task.py start <dir>")
|
||
|
|
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: add-context
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_add_context(args: argparse.Namespace) -> int:
|
||
|
|
"""Add entry to JSONL context file."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
target_dir = _resolve_task_dir(args.dir, repo_root)
|
||
|
|
|
||
|
|
jsonl_name = args.file
|
||
|
|
path = args.path
|
||
|
|
reason = args.reason or "Added manually"
|
||
|
|
|
||
|
|
if not target_dir.is_dir():
|
||
|
|
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Support shorthand
|
||
|
|
if not jsonl_name.endswith(".jsonl"):
|
||
|
|
jsonl_name = f"{jsonl_name}.jsonl"
|
||
|
|
|
||
|
|
jsonl_file = target_dir / jsonl_name
|
||
|
|
full_path = repo_root / path
|
||
|
|
|
||
|
|
entry_type = "file"
|
||
|
|
if full_path.is_dir():
|
||
|
|
entry_type = "directory"
|
||
|
|
if not path.endswith("/"):
|
||
|
|
path = f"{path}/"
|
||
|
|
elif not full_path.is_file():
|
||
|
|
print(colored(f"Error: Path not found: {path}", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Check if already exists
|
||
|
|
if jsonl_file.is_file():
|
||
|
|
content = jsonl_file.read_text(encoding="utf-8")
|
||
|
|
if f'"{path}"' in content:
|
||
|
|
print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
# Add entry
|
||
|
|
entry: dict
|
||
|
|
if entry_type == "directory":
|
||
|
|
entry = {"file": path, "type": "directory", "reason": reason}
|
||
|
|
else:
|
||
|
|
entry = {"file": path, "reason": reason}
|
||
|
|
|
||
|
|
with jsonl_file.open("a", encoding="utf-8") as f:
|
||
|
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||
|
|
|
||
|
|
print(colored(f"Added {entry_type}: {path}", Colors.GREEN))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: validate
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_validate(args: argparse.Namespace) -> int:
|
||
|
|
"""Validate JSONL context files."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
target_dir = _resolve_task_dir(args.dir, repo_root)
|
||
|
|
|
||
|
|
if not target_dir.is_dir():
|
||
|
|
print(colored("Error: task directory required", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
print(colored("=== Validating Context Files ===", Colors.BLUE))
|
||
|
|
print(f"Target dir: {target_dir}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
total_errors = 0
|
||
|
|
for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]:
|
||
|
|
jsonl_file = target_dir / jsonl_name
|
||
|
|
errors = _validate_jsonl(jsonl_file, repo_root)
|
||
|
|
total_errors += errors
|
||
|
|
|
||
|
|
print()
|
||
|
|
if total_errors == 0:
|
||
|
|
print(colored("✓ All validations passed", Colors.GREEN))
|
||
|
|
return 0
|
||
|
|
else:
|
||
|
|
print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int:
|
||
|
|
"""Validate a single JSONL file."""
|
||
|
|
file_name = jsonl_file.name
|
||
|
|
errors = 0
|
||
|
|
|
||
|
|
if not jsonl_file.is_file():
|
||
|
|
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
line_num = 0
|
||
|
|
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||
|
|
line_num += 1
|
||
|
|
if not line.strip():
|
||
|
|
continue
|
||
|
|
|
||
|
|
try:
|
||
|
|
data = json.loads(line)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}")
|
||
|
|
errors += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
file_path = data.get("file")
|
||
|
|
entry_type = data.get("type", "file")
|
||
|
|
|
||
|
|
if not file_path:
|
||
|
|
print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}")
|
||
|
|
errors += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
full_path = repo_root / file_path
|
||
|
|
if entry_type == "directory":
|
||
|
|
if not full_path.is_dir():
|
||
|
|
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||
|
|
errors += 1
|
||
|
|
else:
|
||
|
|
if not full_path.is_file():
|
||
|
|
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||
|
|
errors += 1
|
||
|
|
|
||
|
|
if errors == 0:
|
||
|
|
print(f" {colored(f'{file_name}: ✓ ({line_num} entries)', Colors.GREEN)}")
|
||
|
|
else:
|
||
|
|
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}")
|
||
|
|
|
||
|
|
return errors
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: list-context
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_list_context(args: argparse.Namespace) -> int:
|
||
|
|
"""List JSONL context entries."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
target_dir = _resolve_task_dir(args.dir, repo_root)
|
||
|
|
|
||
|
|
if not target_dir.is_dir():
|
||
|
|
print(colored("Error: task directory required", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
print(colored("=== Context Files ===", Colors.BLUE))
|
||
|
|
print()
|
||
|
|
|
||
|
|
for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]:
|
||
|
|
jsonl_file = target_dir / jsonl_name
|
||
|
|
if not jsonl_file.is_file():
|
||
|
|
continue
|
||
|
|
|
||
|
|
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
||
|
|
|
||
|
|
count = 0
|
||
|
|
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||
|
|
if not line.strip():
|
||
|
|
continue
|
||
|
|
|
||
|
|
try:
|
||
|
|
data = json.loads(line)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
continue
|
||
|
|
|
||
|
|
count += 1
|
||
|
|
file_path = data.get("file", "?")
|
||
|
|
entry_type = data.get("type", "file")
|
||
|
|
reason = data.get("reason", "-")
|
||
|
|
|
||
|
|
if entry_type == "directory":
|
||
|
|
print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}")
|
||
|
|
else:
|
||
|
|
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
|
||
|
|
print(f" {colored('→', Colors.YELLOW)} {reason}")
|
||
|
|
|
||
|
|
print()
|
||
|
|
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: start / finish
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_start(args: argparse.Namespace) -> int:
|
||
|
|
"""Set current task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
task_input = args.dir
|
||
|
|
|
||
|
|
if not task_input:
|
||
|
|
print(colored("Error: task directory or name required", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Resolve task directory (supports task name, relative path, or absolute path)
|
||
|
|
full_path = _resolve_task_dir(task_input, repo_root)
|
||
|
|
|
||
|
|
if not full_path.is_dir():
|
||
|
|
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
|
||
|
|
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Convert to relative path for storage
|
||
|
|
try:
|
||
|
|
task_dir = str(full_path.relative_to(repo_root))
|
||
|
|
except ValueError:
|
||
|
|
task_dir = str(full_path)
|
||
|
|
|
||
|
|
if set_current_task(task_dir, repo_root):
|
||
|
|
print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
|
||
|
|
print()
|
||
|
|
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
|
||
|
|
|
||
|
|
task_json_path = full_path / FILE_TASK_JSON
|
||
|
|
_run_hooks("after_start", task_json_path, repo_root)
|
||
|
|
return 0
|
||
|
|
else:
|
||
|
|
print(colored("Error: Failed to set current task", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_finish(args: argparse.Namespace) -> int:
|
||
|
|
"""Clear current task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
current = get_current_task(repo_root)
|
||
|
|
|
||
|
|
if not current:
|
||
|
|
print(colored("No current task set", Colors.YELLOW))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
# Resolve task.json path before clearing
|
||
|
|
task_json_path = repo_root / current / FILE_TASK_JSON
|
||
|
|
|
||
|
|
clear_current_task(repo_root)
|
||
|
|
print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
|
||
|
|
|
||
|
|
if task_json_path.is_file():
|
||
|
|
_run_hooks("after_finish", task_json_path, repo_root)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: archive
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_archive(args: argparse.Namespace) -> int:
|
||
|
|
"""Archive completed task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
task_name = args.name
|
||
|
|
|
||
|
|
if not task_name:
|
||
|
|
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
tasks_dir = get_tasks_dir(repo_root)
|
||
|
|
|
||
|
|
# Find task directory
|
||
|
|
task_dir = find_task_by_name(task_name, tasks_dir)
|
||
|
|
|
||
|
|
if not task_dir or not task_dir.is_dir():
|
||
|
|
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
||
|
|
print("Active tasks:", file=sys.stderr)
|
||
|
|
cmd_list(argparse.Namespace(mine=False, status=None))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
dir_name = task_dir.name
|
||
|
|
task_json_path = task_dir / FILE_TASK_JSON
|
||
|
|
|
||
|
|
# Update status before archiving
|
||
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
||
|
|
if task_json_path.is_file():
|
||
|
|
data = _read_json_file(task_json_path)
|
||
|
|
if data:
|
||
|
|
data["status"] = "completed"
|
||
|
|
data["completedAt"] = today
|
||
|
|
_write_json_file(task_json_path, data)
|
||
|
|
|
||
|
|
# Handle subtask relationships on archive
|
||
|
|
task_parent = data.get("parent")
|
||
|
|
task_children = data.get("children", [])
|
||
|
|
|
||
|
|
# If this is a child, remove from parent's children list
|
||
|
|
if task_parent:
|
||
|
|
parent_dir = find_task_by_name(task_parent, tasks_dir)
|
||
|
|
if parent_dir:
|
||
|
|
parent_json = parent_dir / FILE_TASK_JSON
|
||
|
|
if parent_json.is_file():
|
||
|
|
parent_data = _read_json_file(parent_json)
|
||
|
|
if parent_data:
|
||
|
|
parent_children = parent_data.get("children", [])
|
||
|
|
if dir_name in parent_children:
|
||
|
|
parent_children.remove(dir_name)
|
||
|
|
parent_data["children"] = parent_children
|
||
|
|
_write_json_file(parent_json, parent_data)
|
||
|
|
|
||
|
|
# If this is a parent, clear parent field in all children
|
||
|
|
if task_children:
|
||
|
|
for child_name in task_children:
|
||
|
|
child_dir_path = find_task_by_name(child_name, tasks_dir)
|
||
|
|
if child_dir_path:
|
||
|
|
child_json = child_dir_path / FILE_TASK_JSON
|
||
|
|
if child_json.is_file():
|
||
|
|
child_data = _read_json_file(child_json)
|
||
|
|
if child_data:
|
||
|
|
child_data["parent"] = None
|
||
|
|
_write_json_file(child_json, child_data)
|
||
|
|
|
||
|
|
# Clear if current task
|
||
|
|
current = get_current_task(repo_root)
|
||
|
|
if current and dir_name in current:
|
||
|
|
clear_current_task(repo_root)
|
||
|
|
|
||
|
|
# Archive
|
||
|
|
result = archive_task_complete(task_dir, repo_root)
|
||
|
|
if "archived_to" in result:
|
||
|
|
archive_dest = Path(result["archived_to"])
|
||
|
|
year_month = archive_dest.parent.name
|
||
|
|
print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
|
||
|
|
|
||
|
|
# Auto-commit unless --no-commit
|
||
|
|
if not getattr(args, "no_commit", False):
|
||
|
|
_auto_commit_archive(dir_name, repo_root)
|
||
|
|
|
||
|
|
# Return the archive path
|
||
|
|
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
|
||
|
|
|
||
|
|
# Run hooks with the archived path
|
||
|
|
archived_json = archive_dest / FILE_TASK_JSON
|
||
|
|
_run_hooks("after_archive", archived_json, repo_root)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
|
||
|
|
"""Stage .trellis/tasks/ changes and commit after archive."""
|
||
|
|
tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}"
|
||
|
|
_run_git_command(["add", "-A", tasks_rel], cwd=repo_root)
|
||
|
|
|
||
|
|
# Check if there are staged changes
|
||
|
|
rc, _, _ = _run_git_command(
|
||
|
|
["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root
|
||
|
|
)
|
||
|
|
if rc == 0:
|
||
|
|
print("[OK] No task changes to commit.", file=sys.stderr)
|
||
|
|
return
|
||
|
|
|
||
|
|
commit_msg = f"chore(task): archive {task_name}"
|
||
|
|
rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root)
|
||
|
|
if rc == 0:
|
||
|
|
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||
|
|
else:
|
||
|
|
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: add-subtask
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_add_subtask(args: argparse.Namespace) -> int:
|
||
|
|
"""Link a child task to a parent task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
|
||
|
|
parent_dir = _resolve_task_dir(args.parent_dir, repo_root)
|
||
|
|
child_dir = _resolve_task_dir(args.child_dir, repo_root)
|
||
|
|
|
||
|
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||
|
|
child_json_path = child_dir / FILE_TASK_JSON
|
||
|
|
|
||
|
|
if not parent_json_path.is_file():
|
||
|
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
if not child_json_path.is_file():
|
||
|
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
parent_data = _read_json_file(parent_json_path)
|
||
|
|
child_data = _read_json_file(child_json_path)
|
||
|
|
|
||
|
|
if not parent_data or not child_data:
|
||
|
|
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Check if child already has a parent
|
||
|
|
existing_parent = child_data.get("parent")
|
||
|
|
if existing_parent:
|
||
|
|
print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Add child to parent's children list
|
||
|
|
parent_children = parent_data.get("children", [])
|
||
|
|
child_dir_name = child_dir.name
|
||
|
|
if child_dir_name not in parent_children:
|
||
|
|
parent_children.append(child_dir_name)
|
||
|
|
parent_data["children"] = parent_children
|
||
|
|
|
||
|
|
# Set parent in child's task.json
|
||
|
|
child_data["parent"] = parent_dir.name
|
||
|
|
|
||
|
|
# Write both
|
||
|
|
_write_json_file(parent_json_path, parent_data)
|
||
|
|
_write_json_file(child_json_path, child_data)
|
||
|
|
|
||
|
|
print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: remove-subtask
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
||
|
|
"""Unlink a child task from a parent task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
|
||
|
|
parent_dir = _resolve_task_dir(args.parent_dir, repo_root)
|
||
|
|
child_dir = _resolve_task_dir(args.child_dir, repo_root)
|
||
|
|
|
||
|
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
||
|
|
child_json_path = child_dir / FILE_TASK_JSON
|
||
|
|
|
||
|
|
if not parent_json_path.is_file():
|
||
|
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
if not child_json_path.is_file():
|
||
|
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
parent_data = _read_json_file(parent_json_path)
|
||
|
|
child_data = _read_json_file(child_json_path)
|
||
|
|
|
||
|
|
if not parent_data or not child_data:
|
||
|
|
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Remove child from parent's children list
|
||
|
|
parent_children = parent_data.get("children", [])
|
||
|
|
child_dir_name = child_dir.name
|
||
|
|
if child_dir_name in parent_children:
|
||
|
|
parent_children.remove(child_dir_name)
|
||
|
|
parent_data["children"] = parent_children
|
||
|
|
|
||
|
|
# Clear parent in child's task.json
|
||
|
|
child_data["parent"] = None
|
||
|
|
|
||
|
|
# Write both
|
||
|
|
_write_json_file(parent_json_path, parent_data)
|
||
|
|
_write_json_file(child_json_path, child_data)
|
||
|
|
|
||
|
|
print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: list
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def _get_children_progress(children: list[str], tasks_dir: Path) -> str:
|
||
|
|
"""Get children progress summary like '[2/3 done]'."""
|
||
|
|
if not children:
|
||
|
|
return ""
|
||
|
|
done_count = 0
|
||
|
|
total = len(children)
|
||
|
|
for child_name in children:
|
||
|
|
child_dir = tasks_dir / child_name
|
||
|
|
child_json = child_dir / FILE_TASK_JSON
|
||
|
|
if child_json.is_file():
|
||
|
|
data = _read_json_file(child_json)
|
||
|
|
if data:
|
||
|
|
status = data.get("status", "")
|
||
|
|
if status in ("completed", "done"):
|
||
|
|
done_count += 1
|
||
|
|
return f" [{done_count}/{total} done]"
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_list(args: argparse.Namespace) -> int:
|
||
|
|
"""List active tasks."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
tasks_dir = get_tasks_dir(repo_root)
|
||
|
|
current_task = get_current_task(repo_root)
|
||
|
|
developer = get_developer(repo_root)
|
||
|
|
filter_mine = args.mine
|
||
|
|
filter_status = args.status
|
||
|
|
|
||
|
|
if filter_mine:
|
||
|
|
if not developer:
|
||
|
|
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
|
||
|
|
else:
|
||
|
|
print(colored("All active tasks:", Colors.BLUE))
|
||
|
|
print()
|
||
|
|
|
||
|
|
# First pass: collect all task data and identify parent/child relationships
|
||
|
|
all_tasks: dict[str, dict] = {}
|
||
|
|
if tasks_dir.is_dir():
|
||
|
|
for d in sorted(tasks_dir.iterdir()):
|
||
|
|
if not d.is_dir() or d.name == "archive":
|
||
|
|
continue
|
||
|
|
|
||
|
|
dir_name = d.name
|
||
|
|
task_json = d / FILE_TASK_JSON
|
||
|
|
status = "unknown"
|
||
|
|
assignee = "-"
|
||
|
|
children: list[str] = []
|
||
|
|
parent: str | None = None
|
||
|
|
|
||
|
|
if task_json.is_file():
|
||
|
|
data = _read_json_file(task_json)
|
||
|
|
if data:
|
||
|
|
status = data.get("status", "unknown")
|
||
|
|
assignee = data.get("assignee", "-")
|
||
|
|
children = data.get("children", [])
|
||
|
|
parent = data.get("parent")
|
||
|
|
|
||
|
|
all_tasks[dir_name] = {
|
||
|
|
"status": status,
|
||
|
|
"assignee": assignee,
|
||
|
|
"children": children,
|
||
|
|
"parent": parent,
|
||
|
|
}
|
||
|
|
|
||
|
|
# Second pass: display tasks hierarchically
|
||
|
|
count = 0
|
||
|
|
|
||
|
|
def _print_task(dir_name: str, indent: int = 0) -> None:
|
||
|
|
nonlocal count
|
||
|
|
info = all_tasks[dir_name]
|
||
|
|
status = info["status"]
|
||
|
|
assignee = info["assignee"]
|
||
|
|
children = info["children"]
|
||
|
|
|
||
|
|
# Apply --mine filter
|
||
|
|
if filter_mine and assignee != developer:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Apply --status filter
|
||
|
|
if filter_status and status != filter_status:
|
||
|
|
return
|
||
|
|
|
||
|
|
relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
|
||
|
|
marker = ""
|
||
|
|
if relative_path == current_task:
|
||
|
|
marker = f" {colored('<- current', Colors.GREEN)}"
|
||
|
|
|
||
|
|
# Children progress
|
||
|
|
progress = _get_children_progress(children, tasks_dir) if children else ""
|
||
|
|
|
||
|
|
prefix = " " * indent + " - "
|
||
|
|
|
||
|
|
if filter_mine:
|
||
|
|
print(f"{prefix}{dir_name}/ ({status}){progress}{marker}")
|
||
|
|
else:
|
||
|
|
print(f"{prefix}{dir_name}/ ({status}){progress} [{colored(assignee, Colors.CYAN)}]{marker}")
|
||
|
|
count += 1
|
||
|
|
|
||
|
|
# Print children indented
|
||
|
|
for child_name in children:
|
||
|
|
if child_name in all_tasks:
|
||
|
|
_print_task(child_name, indent + 1)
|
||
|
|
|
||
|
|
# Display only top-level tasks (those without a parent)
|
||
|
|
for dir_name in sorted(all_tasks.keys()):
|
||
|
|
info = all_tasks[dir_name]
|
||
|
|
if not info["parent"]:
|
||
|
|
_print_task(dir_name)
|
||
|
|
|
||
|
|
if count == 0:
|
||
|
|
if filter_mine:
|
||
|
|
print(" (no tasks assigned to you)")
|
||
|
|
else:
|
||
|
|
print(" (no active tasks)")
|
||
|
|
|
||
|
|
print()
|
||
|
|
print(f"Total: {count} task(s)")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: list-archive
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_list_archive(args: argparse.Namespace) -> int:
|
||
|
|
"""List archived tasks."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
tasks_dir = get_tasks_dir(repo_root)
|
||
|
|
archive_dir = tasks_dir / "archive"
|
||
|
|
month = args.month
|
||
|
|
|
||
|
|
print(colored("Archived tasks:", Colors.BLUE))
|
||
|
|
print()
|
||
|
|
|
||
|
|
if month:
|
||
|
|
month_dir = archive_dir / month
|
||
|
|
if month_dir.is_dir():
|
||
|
|
print(f"[{month}]")
|
||
|
|
for d in sorted(month_dir.iterdir()):
|
||
|
|
if d.is_dir():
|
||
|
|
print(f" - {d.name}/")
|
||
|
|
else:
|
||
|
|
print(f" No archives for {month}")
|
||
|
|
else:
|
||
|
|
if archive_dir.is_dir():
|
||
|
|
for month_dir in sorted(archive_dir.iterdir()):
|
||
|
|
if month_dir.is_dir():
|
||
|
|
month_name = month_dir.name
|
||
|
|
count = sum(1 for d in month_dir.iterdir() if d.is_dir())
|
||
|
|
print(f"[{month_name}] - {count} task(s)")
|
||
|
|
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: set-branch
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_set_branch(args: argparse.Namespace) -> int:
|
||
|
|
"""Set git branch for task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
target_dir = _resolve_task_dir(args.dir, repo_root)
|
||
|
|
branch = args.branch
|
||
|
|
|
||
|
|
if not branch:
|
||
|
|
print(colored("Error: Missing arguments", Colors.RED))
|
||
|
|
print("Usage: python3 task.py set-branch <task-dir> <branch-name>")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
task_json = target_dir / FILE_TASK_JSON
|
||
|
|
if not task_json.is_file():
|
||
|
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
data = _read_json_file(task_json)
|
||
|
|
if not data:
|
||
|
|
return 1
|
||
|
|
|
||
|
|
data["branch"] = branch
|
||
|
|
_write_json_file(task_json, data)
|
||
|
|
|
||
|
|
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
|
||
|
|
print()
|
||
|
|
print(colored("Now you can start the multi-agent pipeline:", Colors.BLUE))
|
||
|
|
print(f" python3 ./.trellis/scripts/multi_agent/start.py {args.dir}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: set-base-branch
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_set_base_branch(args: argparse.Namespace) -> int:
|
||
|
|
"""Set the base branch (PR target) for task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
target_dir = _resolve_task_dir(args.dir, repo_root)
|
||
|
|
base_branch = args.base_branch
|
||
|
|
|
||
|
|
if not base_branch:
|
||
|
|
print(colored("Error: Missing arguments", Colors.RED))
|
||
|
|
print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>")
|
||
|
|
print("Example: python3 task.py set-base-branch <dir> develop")
|
||
|
|
print()
|
||
|
|
print("This sets the target branch for PR (the branch your feature will merge into).")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
task_json = target_dir / FILE_TASK_JSON
|
||
|
|
if not task_json.is_file():
|
||
|
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
data = _read_json_file(task_json)
|
||
|
|
if not data:
|
||
|
|
return 1
|
||
|
|
|
||
|
|
data["base_branch"] = base_branch
|
||
|
|
_write_json_file(task_json, data)
|
||
|
|
|
||
|
|
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
|
||
|
|
print(f" PR will target: {base_branch}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: set-scope
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_set_scope(args: argparse.Namespace) -> int:
|
||
|
|
"""Set scope for PR title."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
target_dir = _resolve_task_dir(args.dir, repo_root)
|
||
|
|
scope = args.scope
|
||
|
|
|
||
|
|
if not scope:
|
||
|
|
print(colored("Error: Missing arguments", Colors.RED))
|
||
|
|
print("Usage: python3 task.py set-scope <task-dir> <scope>")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
task_json = target_dir / FILE_TASK_JSON
|
||
|
|
if not task_json.is_file():
|
||
|
|
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
data = _read_json_file(task_json)
|
||
|
|
if not data:
|
||
|
|
return 1
|
||
|
|
|
||
|
|
data["scope"] = scope
|
||
|
|
_write_json_file(task_json, data)
|
||
|
|
|
||
|
|
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: create-pr (delegates to multi-agent script)
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_create_pr(args: argparse.Namespace) -> int:
|
||
|
|
"""Create PR from task - delegates to multi_agent/create_pr.py."""
|
||
|
|
import subprocess
|
||
|
|
script_dir = Path(__file__).parent
|
||
|
|
create_pr_script = script_dir / "multi_agent" / "create_pr.py"
|
||
|
|
|
||
|
|
cmd = [sys.executable, str(create_pr_script)]
|
||
|
|
if args.dir:
|
||
|
|
cmd.append(args.dir)
|
||
|
|
if args.dry_run:
|
||
|
|
cmd.append("--dry-run")
|
||
|
|
|
||
|
|
result = subprocess.run(cmd)
|
||
|
|
return result.returncode
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Help
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def show_usage() -> None:
|
||
|
|
"""Show usage help."""
|
||
|
|
print("""Task Management Script for Multi-Agent Pipeline
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python3 task.py create <title> Create new task directory
|
||
|
|
python3 task.py create <title> --parent <dir> Create task as child of parent
|
||
|
|
python3 task.py init-context <dir> <dev_type> Initialize jsonl files
|
||
|
|
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
||
|
|
python3 task.py validate <dir> Validate jsonl files
|
||
|
|
python3 task.py list-context <dir> List jsonl entries
|
||
|
|
python3 task.py start <dir> Set as current task
|
||
|
|
python3 task.py finish Clear current task
|
||
|
|
python3 task.py set-branch <dir> <branch> Set git branch for multi-agent
|
||
|
|
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
||
|
|
python3 task.py create-pr [dir] [--dry-run] Create PR from task
|
||
|
|
python3 task.py archive <task-name> Archive completed task
|
||
|
|
python3 task.py add-subtask <parent> <child> Link child task to parent
|
||
|
|
python3 task.py remove-subtask <parent> <child> Unlink child from parent
|
||
|
|
python3 task.py list [--mine] [--status <status>] List tasks
|
||
|
|
python3 task.py list-archive [YYYY-MM] List archived tasks
|
||
|
|
|
||
|
|
Arguments:
|
||
|
|
dev_type: backend | frontend | fullstack | test | docs
|
||
|
|
|
||
|
|
List options:
|
||
|
|
--mine, -m Show only tasks assigned to current developer
|
||
|
|
--status, -s <s> Filter by status (planning, in_progress, review, completed)
|
||
|
|
|
||
|
|
Examples:
|
||
|
|
python3 task.py create "Add login feature" --slug add-login
|
||
|
|
python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent
|
||
|
|
python3 task.py init-context .trellis/tasks/01-21-add-login backend
|
||
|
|
python3 task.py add-context <dir> implement .trellis/spec/backend/auth.md "Auth guidelines"
|
||
|
|
python3 task.py set-branch <dir> task/add-login
|
||
|
|
python3 task.py start .trellis/tasks/01-21-add-login
|
||
|
|
python3 task.py create-pr # Uses current task
|
||
|
|
python3 task.py create-pr <dir> --dry-run # Preview without changes
|
||
|
|
python3 task.py finish
|
||
|
|
python3 task.py archive add-login
|
||
|
|
python3 task.py add-subtask parent-task child-task # Link existing tasks
|
||
|
|
python3 task.py remove-subtask parent-task child-task
|
||
|
|
python3 task.py list # List all active tasks
|
||
|
|
python3 task.py list --mine # List my tasks only
|
||
|
|
python3 task.py list --mine --status in_progress # List my in-progress tasks
|
||
|
|
""")
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Main Entry
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
"""CLI entry point."""
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description="Task Management Script for Multi-Agent Pipeline",
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
|
|
)
|
||
|
|
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||
|
|
|
||
|
|
# create
|
||
|
|
p_create = subparsers.add_parser("create", help="Create new task")
|
||
|
|
p_create.add_argument("title", help="Task title")
|
||
|
|
p_create.add_argument("--slug", "-s", help="Task slug")
|
||
|
|
p_create.add_argument("--assignee", "-a", help="Assignee developer")
|
||
|
|
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
|
||
|
|
p_create.add_argument("--description", "-d", help="Task description")
|
||
|
|
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
|
||
|
|
|
||
|
|
# init-context
|
||
|
|
p_init = subparsers.add_parser("init-context", help="Initialize context files")
|
||
|
|
p_init.add_argument("dir", help="Task directory")
|
||
|
|
p_init.add_argument("type", help="Dev type: backend|frontend|fullstack|test|docs")
|
||
|
|
|
||
|
|
# add-context
|
||
|
|
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||
|
|
p_add.add_argument("dir", help="Task directory")
|
||
|
|
p_add.add_argument("file", help="JSONL file (implement|check|debug)")
|
||
|
|
p_add.add_argument("path", help="File path to add")
|
||
|
|
p_add.add_argument("reason", nargs="?", help="Reason for adding")
|
||
|
|
|
||
|
|
# validate
|
||
|
|
p_validate = subparsers.add_parser("validate", help="Validate context files")
|
||
|
|
p_validate.add_argument("dir", help="Task directory")
|
||
|
|
|
||
|
|
# list-context
|
||
|
|
p_listctx = subparsers.add_parser("list-context", help="List context entries")
|
||
|
|
p_listctx.add_argument("dir", help="Task directory")
|
||
|
|
|
||
|
|
# start
|
||
|
|
p_start = subparsers.add_parser("start", help="Set current task")
|
||
|
|
p_start.add_argument("dir", help="Task directory")
|
||
|
|
|
||
|
|
# finish
|
||
|
|
subparsers.add_parser("finish", help="Clear current task")
|
||
|
|
|
||
|
|
# set-branch
|
||
|
|
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
||
|
|
p_branch.add_argument("dir", help="Task directory")
|
||
|
|
p_branch.add_argument("branch", help="Branch name")
|
||
|
|
|
||
|
|
# set-base-branch
|
||
|
|
p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch")
|
||
|
|
p_base.add_argument("dir", help="Task directory")
|
||
|
|
p_base.add_argument("base_branch", help="Base branch name (PR target)")
|
||
|
|
|
||
|
|
# set-scope
|
||
|
|
p_scope = subparsers.add_parser("set-scope", help="Set scope")
|
||
|
|
p_scope.add_argument("dir", help="Task directory")
|
||
|
|
p_scope.add_argument("scope", help="Scope name")
|
||
|
|
|
||
|
|
# create-pr
|
||
|
|
p_pr = subparsers.add_parser("create-pr", help="Create PR")
|
||
|
|
p_pr.add_argument("dir", nargs="?", help="Task directory")
|
||
|
|
p_pr.add_argument("--dry-run", action="store_true", help="Dry run mode")
|
||
|
|
|
||
|
|
# archive
|
||
|
|
p_archive = subparsers.add_parser("archive", help="Archive task")
|
||
|
|
p_archive.add_argument("name", help="Task name")
|
||
|
|
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
|
||
|
|
|
||
|
|
# list
|
||
|
|
p_list = subparsers.add_parser("list", help="List tasks")
|
||
|
|
p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
|
||
|
|
p_list.add_argument("--status", "-s", help="Filter by status")
|
||
|
|
|
||
|
|
# add-subtask
|
||
|
|
p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
|
||
|
|
p_addsub.add_argument("parent_dir", help="Parent task directory")
|
||
|
|
p_addsub.add_argument("child_dir", help="Child task directory")
|
||
|
|
|
||
|
|
# remove-subtask
|
||
|
|
p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
|
||
|
|
p_rmsub.add_argument("parent_dir", help="Parent task directory")
|
||
|
|
p_rmsub.add_argument("child_dir", help="Child task directory")
|
||
|
|
|
||
|
|
# list-archive
|
||
|
|
p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
|
||
|
|
p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
|
||
|
|
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
if not args.command:
|
||
|
|
show_usage()
|
||
|
|
return 1
|
||
|
|
|
||
|
|
commands = {
|
||
|
|
"create": cmd_create,
|
||
|
|
"init-context": cmd_init_context,
|
||
|
|
"add-context": cmd_add_context,
|
||
|
|
"validate": cmd_validate,
|
||
|
|
"list-context": cmd_list_context,
|
||
|
|
"start": cmd_start,
|
||
|
|
"finish": cmd_finish,
|
||
|
|
"set-branch": cmd_set_branch,
|
||
|
|
"set-base-branch": cmd_set_base_branch,
|
||
|
|
"set-scope": cmd_set_scope,
|
||
|
|
"create-pr": cmd_create_pr,
|
||
|
|
"archive": cmd_archive,
|
||
|
|
"add-subtask": cmd_add_subtask,
|
||
|
|
"remove-subtask": cmd_remove_subtask,
|
||
|
|
"list": cmd_list,
|
||
|
|
"list-archive": cmd_list_archive,
|
||
|
|
}
|
||
|
|
|
||
|
|
if args.command in commands:
|
||
|
|
return commands[args.command](args)
|
||
|
|
else:
|
||
|
|
show_usage()
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|