#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Trellis 任务管理脚本。
用法:
python3 task.py create "
" [--slug ] [--assignee ] [--priority P0|P1|P2|P3] [--parent ] [--package ] [--no-start]
python3 task.py add-context [reason] # 添加 JSONL 条目
python3 task.py validate # 校验 JSONL 文件
python3 task.py list-context # 列出 JSONL 条目
python3 task.py start # 设置活动任务
python3 task.py current [--source] [--json] # 显示活动任务
python3 task.py finish # 清除活动任务
python3 task.py set-branch # 设置 Git 分支
python3 task.py set-base-branch # 设置 PR 目标分支
python3 task.py set-scope # 设置 PR 标题范围
python3 task.py set-meta # 设置任务 metadata
python3 task.py archive # 归档已完成任务
python3 task.py list # 列出活动任务
python3 task.py list-archive [month] # 列出已归档任务
python3 task.py add-subtask # 关联子任务与父任务
python3 task.py remove-subtask # 解除子任务与父任务关联
"""
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 创建任务目录
python3 task.py create --package 为指定 package 创建任务
python3 task.py create --parent 创建父任务的子任务
python3 task.py create --no-start 创建任务但不在本 session 激活
python3 task.py add-context [reason] 添加 JSONL 条目
python3 task.py validate 校验 JSONL 文件
python3 task.py list-context 列出 JSONL 条目
python3 task.py start 设置活动任务
python3 task.py current [--source] 显示活动任务
python3 task.py finish 清除活动任务
python3 task.py set-branch 设置 Git 分支
python3 task.py set-base-branch 设置 PR 目标分支
python3 task.py set-scope 设置 PR 标题范围
python3 task.py set-meta 设置/覆盖任务 metadata
python3 task.py archive 归档已完成任务
python3 task.py add-subtask 关联子任务与父任务
python3 task.py remove-subtask 解除子任务与父任务关联
python3 task.py list [--mine] [--status ] [--json] 列出任务
python3 task.py list-archive [YYYY-MM] 列出已归档任务
Monorepo 选项(多包项目):
--package package 名称(依据 config.yaml 的 packages 校验)
列表选项:
--mine, -m 只显示分配给当前开发者的任务
--status, -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 implement .trellis/spec/cli/backend/auth.md "认证规范"
python3 task.py set-branch 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 implement|check ` 追加条目。",
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())