#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Trellis 任务管理脚本。 用法: python3 task.py create "" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] python3 task.py add-context <dir> <file> <path> [reason] # 添加 JSONL 条目 python3 task.py validate <dir> # 校验 JSONL 文件 python3 task.py list-context <dir> # 列出 JSONL 条目 python3 task.py start <dir> # 设置活动任务 python3 task.py current [--source] [--json] # 显示活动任务 python3 task.py finish # 清除活动任务 python3 task.py set-branch <dir> <branch> # 设置 Git 分支 python3 task.py set-base-branch <dir> <branch> # 设置 PR 目标分支 python3 task.py set-scope <dir> <scope> # 设置 PR 标题范围 python3 task.py set-meta <dir> <key> <value> # 设置任务 metadata python3 task.py archive <task-dir> # 归档已完成任务 python3 task.py list # 列出活动任务 python3 task.py list-archive [month] # 列出已归档任务 python3 task.py add-subtask <parent-dir> <child-dir> # 关联子任务与父任务 python3 task.py remove-subtask <parent-dir> <child-dir> # 解除子任务与父任务关联 """ from __future__ import annotations import argparse import json import sys from common.log import Colors, colored from common.paths import ( DIR_WORKFLOW, DIR_TASKS, FILE_TASK_JSON, get_repo_root, get_developer, get_tasks_dir, get_current_task, ) from common.active_task import ( clear_active_task, resolve_active_task, resolve_context_key, set_active_task, ) from common.io import read_json, write_json from common.task_utils import resolve_task_dir, run_task_hooks from common.tasks import iter_active_tasks, children_progress # 从拆分模块导入命令处理器(同时为 plan.py 提供兼容性 re-export)。 from common.task_store import ( cmd_create, cmd_archive, cmd_set_branch, cmd_set_base_branch, cmd_set_scope, cmd_set_meta, cmd_add_subtask, cmd_remove_subtask, ) from common.task_context import ( cmd_add_context, cmd_validate, cmd_list_context, ) # ============================================================================= # 命令:start / finish # ============================================================================= def cmd_start(args: argparse.Namespace) -> int: """设置活动任务。""" repo_root = get_repo_root() task_input = args.dir if not task_input: print(colored("错误:必须提供任务目录或名称。", Colors.RED)) return 1 # 解析任务目录(支持任务名称、相对路径或绝对路径)。 full_path = resolve_task_dir(task_input, repo_root) if not full_path.is_dir(): print(colored(f"错误:找不到任务:{task_input}。", Colors.RED)) print("提示:可使用任务名称(例如 'my-task')或完整路径(例如 '.trellis/tasks/01-31-my-task')。") return 1 # 转换为相对路径后存储。 try: task_dir = full_path.relative_to(repo_root).as_posix() except ValueError: task_dir = str(full_path) task_json_path = full_path / FILE_TASK_JSON if not resolve_context_key(): # 降级模式:没有可用的 session identity。 # Hook 没有注入 TRELLIS_CONTEXT_ID(Windows + Claude Code、--continue 恢复路径、 # fork 分发、关闭 hooks 等情况都可能发生)。跳过按 session 写入指针,AI 依据对话 # 上下文继续工作。 print(colored( "ℹ 当前没有可用的 session identity;本 session 不会持久化活动任务指针(降级模式)。" "AI 将依据当前对话上下文继续。", Colors.YELLOW, )) print(colored( "提示:请在能提供 session identity 的 AI IDE/session 中运行," "或在执行 task.py start 前设置 TRELLIS_CONTEXT_ID。", Colors.YELLOW, )) # 仍然切换 task.json status:planning → in_progress,使后续阶段可以继续。 if task_json_path.is_file(): data = read_json(task_json_path) if data and data.get("status") == "planning": data["status"] = "in_progress" if write_json(task_json_path, data): print(colored("✓ 状态:planning → in_progress(降级模式)", Colors.GREEN)) run_task_hooks("after_start", task_json_path, repo_root) return 0 active = set_active_task(task_dir, repo_root) if active: print(colored(f"✓ 已设置活动任务:{task_dir}", Colors.GREEN)) print(f"来源:{active.source}") if task_json_path.is_file(): data = read_json(task_json_path) if data and data.get("status") == "planning": data["status"] = "in_progress" if write_json(task_json_path, data): print(colored("✓ 状态:planning → in_progress", Colors.GREEN)) print() print(colored("Hook 现在会从该任务的 JSONL 文件注入上下文。", Colors.BLUE)) run_task_hooks("after_start", task_json_path, repo_root) return 0 else: print(colored("错误:设置活动任务失败。", Colors.RED)) return 1 def cmd_finish(args: argparse.Namespace) -> int: """清除活动任务。""" repo_root = get_repo_root() active = clear_active_task(repo_root) current = active.task_path if not current: print(colored("尚未设置活动任务。", Colors.YELLOW)) return 0 # 清除前先解析 task.json 路径。 task_json_path = repo_root / current / FILE_TASK_JSON print(colored(f"✓ 已清除活动任务(原任务:{current})", Colors.GREEN)) print(f"来源:{active.source}") if task_json_path.is_file(): run_task_hooks("after_finish", task_json_path, repo_root) return 0 def cmd_current(args: argparse.Namespace) -> int: """显示活动任务。""" repo_root = get_repo_root() active = resolve_active_task(repo_root) if getattr(args, "json", False): task_obj = None if active.task_path: data = read_json(repo_root / active.task_path / FILE_TASK_JSON) or {} task_obj = { "dir": active.task_path, "id": data.get("id") or data.get("name"), "title": data.get("title"), "status": data.get("status"), "parent": data.get("parent"), "children": data.get("children", []), "branch": data.get("branch"), "base_branch": data.get("base_branch"), } print(json.dumps({ "current_task": task_obj, "source": active.source, "stale": active.stale, }, ensure_ascii=False)) return 0 if active.task_path else 1 if args.source: print(f"活动任务:{active.task_path or '(无)'}") print(f"来源:{active.source}") if active.stale: print("状态:stale") return 0 if active.task_path else 1 if active.task_path: print(active.task_path) return 0 return 1 # ============================================================================= # 命令:list # ============================================================================= def _display_status(t, all_statuses: dict) -> str: """返回 `list` 输出中显示的任务状态标签。 父任务的存储状态会一直保持为 "planning",直到有人直接对父任务运行 `task.py start`;即使子任务正在执行,列表中也容易造成误解(#399 item 3)。 当至少一个子任务已离开 planning 时显示 "active";不修改 task.json 中的存储状态。 """ if t.status == "planning" and t.children: child_in_flight = any( all_statuses.get(c) not in (None, "planning") for c in t.children ) if child_in_flight: return "active" return t.status def cmd_list(args: argparse.Namespace) -> int: """列出活动任务。""" 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 as_json = getattr(args, "json", False) # 单次遍历:通过共享 iterator 收集所有任务。 all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} all_statuses = {name: t.status for name, t in all_tasks.items()} if as_json: if filter_mine and not developer: print(json.dumps({"error": "未设置开发者身份"}, ensure_ascii=False), file=sys.stderr) return 1 items = [] for dir_name in sorted(all_tasks.keys()): t = all_tasks[dir_name] if filter_mine and (t.assignee or "-") != developer: continue if filter_status and t.status != filter_status: continue items.append({ "dir": f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}", "id": t.raw.get("id") or dir_name, "title": t.title, "status": t.status, "display_status": _display_status(t, all_statuses), "priority": t.priority, "assignee": t.assignee or None, "parent": t.parent, "children": list(t.children), "package": t.package, }) print(json.dumps({"tasks": items}, ensure_ascii=False)) return 0 if filter_mine: if not developer: print(colored("错误:尚未设置开发者身份。请先运行 init_developer.py。", Colors.RED), file=sys.stderr) return 1 print(colored(f"我的任务(assignee:{developer}):", Colors.BLUE)) else: print(colored("全部活动任务:", Colors.BLUE)) print() # 按层级展示任务。 count = 0 def _print_task(dir_name: str, indent: int = 0) -> None: nonlocal count t = all_tasks[dir_name] # 应用 --mine 过滤。 if filter_mine and (t.assignee or "-") != developer: return # 应用 --status 过滤。 if filter_status and t.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)}" # 子任务进度。 progress = children_progress(t.children, all_statuses) status_label = _display_status(t, all_statuses) # package 标签。 pkg_tag = f" @{t.package}" if t.package else "" prefix = " " * indent + " - " if filter_mine: print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress}{marker}") else: print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") count += 1 # 缩进输出子任务。 for child_name in t.children: if child_name in all_tasks: _print_task(child_name, indent + 1) # 只展示顶层任务:没有 parent 的任务,以及记录的 parent 不在(或已不在)活动集合中 # 的孤儿任务。悬空 parent ref 仍应以扁平形式渲染,不能直接消失。 for dir_name in sorted(all_tasks.keys()): parent = all_tasks[dir_name].parent if not parent or parent not in all_tasks: _print_task(dir_name) if count == 0: if filter_mine: print(" (没有分配给你的任务)") else: print(" (没有活动任务)") print() print(f"总计:{count} 个任务") return 0 # ============================================================================= # 命令:list-archive # ============================================================================= def cmd_list_archive(args: argparse.Namespace) -> int: """列出已归档任务。""" repo_root = get_repo_root() tasks_dir = get_tasks_dir(repo_root) archive_dir = tasks_dir / "archive" month = args.month print(colored("已归档任务:", 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" {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} 个任务") return 0 # ============================================================================= # 帮助 # ============================================================================= def show_usage() -> None: """显示用法帮助。""" print("""Trellis 任务管理脚本 用法: python3 task.py create <title> 创建任务目录 python3 task.py create <title> --package <pkg> 为指定 package 创建任务 python3 task.py create <title> --parent <dir> 创建父任务的子任务 python3 task.py create <title> --no-start 创建任务但不在本 session 激活 python3 task.py add-context <dir> <jsonl> <path> [reason] 添加 JSONL 条目 python3 task.py validate <dir> 校验 JSONL 文件 python3 task.py list-context <dir> 列出 JSONL 条目 python3 task.py start <dir> 设置活动任务 python3 task.py current [--source] 显示活动任务 python3 task.py finish 清除活动任务 python3 task.py set-branch <dir> <branch> 设置 Git 分支 python3 task.py set-base-branch <dir> <branch> 设置 PR 目标分支 python3 task.py set-scope <dir> <scope> 设置 PR 标题范围 python3 task.py set-meta <dir> <key> <value> 设置/覆盖任务 metadata python3 task.py archive <task-dir> 归档已完成任务 python3 task.py add-subtask <parent> <child> 关联子任务与父任务 python3 task.py remove-subtask <parent> <child> 解除子任务与父任务关联 python3 task.py list [--mine] [--status <status>] [--json] 列出任务 python3 task.py list-archive [YYYY-MM] 列出已归档任务 Monorepo 选项(多包项目): --package <pkg> package 名称(依据 config.yaml 的 packages 校验) 列表选项: --mine, -m 只显示分配给当前开发者的任务 --status, -s <s> 按 status 筛选(planning、in_progress、review、completed) --json 输出机器可读 JSON(`current` 也支持) 示例: python3 task.py create "添加登录功能" --slug add-login python3 task.py create "添加登录功能" --slug add-login --package cli python3 task.py create "添加登录功能" --meta linear=ENG-123 --meta epic=auth python3 task.py create "子任务" --slug child --parent .trellis/tasks/01-21-parent python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "认证规范" python3 task.py set-branch <dir> task/add-login python3 task.py start .trellis/tasks/01-21-add-login python3 task.py current --source python3 task.py finish python3 task.py archive add-login python3 task.py add-subtask parent-task child-task # 关联已有任务 python3 task.py remove-subtask parent-task child-task python3 task.py list # 列出全部活动任务 python3 task.py list --mine # 只列出我的任务 python3 task.py list --mine --status in_progress # 列出我的进行中任务 """) # ============================================================================= # 主入口 # ============================================================================= def main() -> int: """CLI 入口。""" # 兼容性提示:`init-context` 已在 v0.5.0-beta.12 移除。 # 提前检测,避免 argparse 用笼统的 "invalid choice" 错误掩盖真实原因。 if len(sys.argv) >= 2 and sys.argv[1] == "init-context": print( colored( "错误:`task.py init-context` 已在 v0.5.0-beta.12 移除。", Colors.RED, ), file=sys.stderr, ) print( "现在,支持 sub-agent 的平台会在 `task.py create` 时生成 implement.jsonl / check.jsonl;", file=sys.stderr, ) print( "需要时由 AI 在规划阶段整理真实条目。", file=sys.stderr, ) print("请查阅 .trellis/workflow.md 的规划产物说明,或运行:", file=sys.stderr) print( " python3 ./.trellis/scripts/get_context.py --mode phase --step 1", file=sys.stderr, ) print( "使用 `task.py add-context <dir> implement|check <path> <reason>` 追加条目。", file=sys.stderr, ) return 2 parser = argparse.ArgumentParser( description="Trellis 任务管理脚本", formatter_class=argparse.RawDescriptionHelpFormatter, ) subparsers = parser.add_subparsers(dest="command", help="命令") # create p_create = subparsers.add_parser("create", help="创建新任务") p_create.add_argument("title", help="任务标题") p_create.add_argument("--slug", "-s", help="不含 MM-DD 日期前缀的任务 slug") p_create.add_argument("--assignee", "-a", help="负责开发者") p_create.add_argument("--priority", "-p", default="P2", help="优先级(P0-P3)") p_create.add_argument("--description", "-d", help="任务描述") p_create.add_argument("--parent", help="父任务目录(建立子任务关联)") p_create.add_argument("--package", help="monorepo 项目的 package 名称") p_create.add_argument( "--base-branch", help="PR 目标分支(覆盖 origin/HEAD 探测和当前检出分支回退)", ) p_create.add_argument( "--meta", action="append", help="任务 metadata key=value(可重复)", ) p_create.add_argument( "--no-start", action="store_true", help="创建任务但不在本 session 激活", ) # add-context p_add = subparsers.add_parser("add-context", help="添加上下文条目") p_add.add_argument("dir", help="任务目录") p_add.add_argument("file", help="JSONL 文件(implement|check)") p_add.add_argument("path", help="要添加的文件路径") p_add.add_argument("reason", nargs="?", help="添加原因") # validate p_validate = subparsers.add_parser("validate", help="校验上下文文件") p_validate.add_argument("dir", help="任务目录") # list-context p_listctx = subparsers.add_parser("list-context", help="列出上下文条目") p_listctx.add_argument("dir", help="任务目录") # start p_start = subparsers.add_parser("start", help="设置活动任务") p_start.add_argument("dir", help="任务目录") # current p_current = subparsers.add_parser("current", help="显示活动任务") p_current.add_argument("--source", action="store_true", help="显示活动任务来源") p_current.add_argument("--json", action="store_true", help="输出机器可读 JSON") # finish subparsers.add_parser("finish", help="清除活动任务") # set-branch p_branch = subparsers.add_parser("set-branch", help="设置 Git 分支") p_branch.add_argument("dir", help="任务目录") p_branch.add_argument("branch", help="分支名称") # set-base-branch p_base = subparsers.add_parser("set-base-branch", help="设置 PR 目标分支") p_base.add_argument("dir", help="任务目录") p_base.add_argument("base_branch", help="基础分支名称(PR 目标)") # set-scope p_scope = subparsers.add_parser("set-scope", help="设置范围") p_scope.add_argument("dir", help="任务目录") p_scope.add_argument("scope", help="范围名称") # set-meta p_setmeta = subparsers.add_parser("set-meta", help="设置/覆盖任务 metadata") p_setmeta.add_argument("dir", help="任务目录") p_setmeta.add_argument("key", help="metadata 键") p_setmeta.add_argument("value", help="metadata 值") # archive p_archive = subparsers.add_parser("archive", help="归档任务") p_archive.add_argument("name", help="任务目录或名称") p_archive.add_argument("--no-commit", action="store_true", help="归档后跳过 Git 自动提交") # list p_list = subparsers.add_parser("list", help="列出任务") p_list.add_argument("--mine", "-m", action="store_true", help="只显示我的任务") p_list.add_argument("--status", "-s", help="按 status 筛选") p_list.add_argument("--json", action="store_true", help="输出机器可读 JSON") # add-subtask p_addsub = subparsers.add_parser("add-subtask", help="关联子任务与父任务") p_addsub.add_argument("parent_dir", help="父任务目录") p_addsub.add_argument("child_dir", help="子任务目录") # remove-subtask p_rmsub = subparsers.add_parser("remove-subtask", help="解除子任务与父任务关联") p_rmsub.add_argument("parent_dir", help="父任务目录") p_rmsub.add_argument("child_dir", help="子任务目录") # list-archive p_listarch = subparsers.add_parser("list-archive", help="列出已归档任务") p_listarch.add_argument("month", nargs="?", help="月份(YYYY-MM)") args = parser.parse_args() if not args.command: show_usage() return 1 commands = { "create": cmd_create, "add-context": cmd_add_context, "validate": cmd_validate, "list-context": cmd_list_context, "start": cmd_start, "current": cmd_current, "finish": cmd_finish, "set-branch": cmd_set_branch, "set-base-branch": cmd_set_base_branch, "set-scope": cmd_set_scope, "set-meta": cmd_set_meta, "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())