task.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Task Management Script.
  5. Usage:
  6. python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>]
  7. python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
  8. python3 task.py validate <dir> # Validate jsonl files
  9. python3 task.py list-context <dir> # List jsonl entries
  10. python3 task.py start <dir> # Set active task
  11. python3 task.py current [--source] # Show active task
  12. python3 task.py finish # Clear active task
  13. python3 task.py set-branch <dir> <branch> # Set git branch
  14. python3 task.py set-base-branch <dir> <branch> # Set PR target branch
  15. python3 task.py set-scope <dir> <scope> # Set scope for PR title
  16. python3 task.py archive <task-dir> # Archive completed task
  17. python3 task.py list # List active tasks
  18. python3 task.py list-archive [month] # List archived tasks
  19. python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
  20. python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
  21. """
  22. from __future__ import annotations
  23. import argparse
  24. import sys
  25. from common.log import Colors, colored
  26. from common.paths import (
  27. DIR_WORKFLOW,
  28. DIR_TASKS,
  29. FILE_TASK_JSON,
  30. get_repo_root,
  31. get_developer,
  32. get_tasks_dir,
  33. get_current_task,
  34. )
  35. from common.active_task import (
  36. clear_active_task,
  37. resolve_active_task,
  38. resolve_context_key,
  39. set_active_task,
  40. )
  41. from common.io import read_json, write_json
  42. from common.task_utils import resolve_task_dir, run_task_hooks
  43. from common.tasks import iter_active_tasks, children_progress
  44. # Import command handlers from split modules (also re-exports for plan.py compatibility)
  45. from common.task_store import (
  46. cmd_create,
  47. cmd_archive,
  48. cmd_set_branch,
  49. cmd_set_base_branch,
  50. cmd_set_scope,
  51. cmd_add_subtask,
  52. cmd_remove_subtask,
  53. )
  54. from common.task_context import (
  55. cmd_add_context,
  56. cmd_validate,
  57. cmd_list_context,
  58. )
  59. # =============================================================================
  60. # Command: start / finish
  61. # =============================================================================
  62. def cmd_start(args: argparse.Namespace) -> int:
  63. """Set active task."""
  64. repo_root = get_repo_root()
  65. task_input = args.dir
  66. if not task_input:
  67. print(colored("Error: task directory or name required", Colors.RED))
  68. return 1
  69. # Resolve task directory (supports task name, relative path, or absolute path)
  70. full_path = resolve_task_dir(task_input, repo_root)
  71. if not full_path.is_dir():
  72. print(colored(f"Error: Task not found: {task_input}", Colors.RED))
  73. print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
  74. return 1
  75. # Convert to relative path for storage
  76. try:
  77. task_dir = full_path.relative_to(repo_root).as_posix()
  78. except ValueError:
  79. task_dir = str(full_path)
  80. task_json_path = full_path / FILE_TASK_JSON
  81. if not resolve_context_key():
  82. # Degraded mode: no session identity available.
  83. # Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code,
  84. # --continue resume path, fork distribution, hooks disabled, etc.). Skip
  85. # per-session pointer write; AI continues based on conversation context.
  86. print(colored(
  87. "ℹ Session identity not available; active-task pointer not persisted "
  88. "this session (degraded mode). AI continues based on conversation context.",
  89. Colors.YELLOW,
  90. ))
  91. print(colored(
  92. "Hint: run inside an AI IDE/session that exposes session identity, "
  93. "or set TRELLIS_CONTEXT_ID before running task.py start.",
  94. Colors.YELLOW,
  95. ))
  96. # Still flip task.json status: planning → in_progress so downstream phases proceed.
  97. if task_json_path.is_file():
  98. data = read_json(task_json_path)
  99. if data and data.get("status") == "planning":
  100. data["status"] = "in_progress"
  101. if write_json(task_json_path, data):
  102. print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN))
  103. run_task_hooks("after_start", task_json_path, repo_root)
  104. return 0
  105. active = set_active_task(task_dir, repo_root)
  106. if active:
  107. print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
  108. print(f"Source: {active.source}")
  109. if task_json_path.is_file():
  110. data = read_json(task_json_path)
  111. if data and data.get("status") == "planning":
  112. data["status"] = "in_progress"
  113. if write_json(task_json_path, data):
  114. print(colored("✓ Status: planning → in_progress", Colors.GREEN))
  115. print()
  116. print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
  117. run_task_hooks("after_start", task_json_path, repo_root)
  118. return 0
  119. else:
  120. print(colored("Error: Failed to set current task", Colors.RED))
  121. return 1
  122. def cmd_finish(args: argparse.Namespace) -> int:
  123. """Clear active task."""
  124. repo_root = get_repo_root()
  125. active = clear_active_task(repo_root)
  126. current = active.task_path
  127. if not current:
  128. print(colored("No current task set", Colors.YELLOW))
  129. return 0
  130. # Resolve task.json path before clearing
  131. task_json_path = repo_root / current / FILE_TASK_JSON
  132. print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
  133. print(f"Source: {active.source}")
  134. if task_json_path.is_file():
  135. run_task_hooks("after_finish", task_json_path, repo_root)
  136. return 0
  137. def cmd_current(args: argparse.Namespace) -> int:
  138. """Show active task."""
  139. repo_root = get_repo_root()
  140. active = resolve_active_task(repo_root)
  141. if args.source:
  142. print(f"Current task: {active.task_path or '(none)'}")
  143. print(f"Source: {active.source}")
  144. if active.stale:
  145. print("State: stale")
  146. return 0 if active.task_path else 1
  147. if active.task_path:
  148. print(active.task_path)
  149. return 0
  150. return 1
  151. # =============================================================================
  152. # Command: list
  153. # =============================================================================
  154. def cmd_list(args: argparse.Namespace) -> int:
  155. """List active tasks."""
  156. repo_root = get_repo_root()
  157. tasks_dir = get_tasks_dir(repo_root)
  158. current_task = get_current_task(repo_root)
  159. developer = get_developer(repo_root)
  160. filter_mine = args.mine
  161. filter_status = args.status
  162. if filter_mine:
  163. if not developer:
  164. print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
  165. return 1
  166. print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
  167. else:
  168. print(colored("All active tasks:", Colors.BLUE))
  169. print()
  170. # Single pass: collect all tasks via shared iterator
  171. all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
  172. all_statuses = {name: t.status for name, t in all_tasks.items()}
  173. # Display tasks hierarchically
  174. count = 0
  175. def _print_task(dir_name: str, indent: int = 0) -> None:
  176. nonlocal count
  177. t = all_tasks[dir_name]
  178. # Apply --mine filter
  179. if filter_mine and (t.assignee or "-") != developer:
  180. return
  181. # Apply --status filter
  182. if filter_status and t.status != filter_status:
  183. return
  184. relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
  185. marker = ""
  186. if relative_path == current_task:
  187. marker = f" {colored('<- current', Colors.GREEN)}"
  188. # Children progress
  189. progress = children_progress(t.children, all_statuses)
  190. # Package tag
  191. pkg_tag = f" @{t.package}" if t.package else ""
  192. prefix = " " * indent + " - "
  193. if filter_mine:
  194. print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}")
  195. else:
  196. print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
  197. count += 1
  198. # Print children indented
  199. for child_name in t.children:
  200. if child_name in all_tasks:
  201. _print_task(child_name, indent + 1)
  202. # Display only top-level tasks (those without a parent)
  203. for dir_name in sorted(all_tasks.keys()):
  204. if not all_tasks[dir_name].parent:
  205. _print_task(dir_name)
  206. if count == 0:
  207. if filter_mine:
  208. print(" (no tasks assigned to you)")
  209. else:
  210. print(" (no active tasks)")
  211. print()
  212. print(f"Total: {count} task(s)")
  213. return 0
  214. # =============================================================================
  215. # Command: list-archive
  216. # =============================================================================
  217. def cmd_list_archive(args: argparse.Namespace) -> int:
  218. """List archived tasks."""
  219. repo_root = get_repo_root()
  220. tasks_dir = get_tasks_dir(repo_root)
  221. archive_dir = tasks_dir / "archive"
  222. month = args.month
  223. print(colored("Archived tasks:", Colors.BLUE))
  224. print()
  225. if month:
  226. month_dir = archive_dir / month
  227. if month_dir.is_dir():
  228. print(f"[{month}]")
  229. for d in sorted(month_dir.iterdir()):
  230. if d.is_dir():
  231. print(f" - {d.name}/")
  232. else:
  233. print(f" No archives for {month}")
  234. else:
  235. if archive_dir.is_dir():
  236. for month_dir in sorted(archive_dir.iterdir()):
  237. if month_dir.is_dir():
  238. month_name = month_dir.name
  239. count = sum(1 for d in month_dir.iterdir() if d.is_dir())
  240. print(f"[{month_name}] - {count} task(s)")
  241. return 0
  242. # =============================================================================
  243. # Help
  244. # =============================================================================
  245. def show_usage() -> None:
  246. """Show usage help."""
  247. print("""Task Management Script
  248. Usage:
  249. python3 task.py create <title> Create new task directory
  250. python3 task.py create <title> --package <pkg> Create task for a specific package
  251. python3 task.py create <title> --parent <dir> Create task as child of parent
  252. python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
  253. python3 task.py validate <dir> Validate jsonl files
  254. python3 task.py list-context <dir> List jsonl entries
  255. python3 task.py start <dir> Set active task
  256. python3 task.py current [--source] Show active task
  257. python3 task.py finish Clear active task
  258. python3 task.py set-branch <dir> <branch> Set git branch
  259. python3 task.py set-base-branch <dir> <branch> Set PR target branch
  260. python3 task.py set-scope <dir> <scope> Set scope for PR title
  261. python3 task.py archive <task-dir> Archive completed task
  262. python3 task.py add-subtask <parent> <child> Link child task to parent
  263. python3 task.py remove-subtask <parent> <child> Unlink child from parent
  264. python3 task.py list [--mine] [--status <status>] List tasks
  265. python3 task.py list-archive [YYYY-MM] List archived tasks
  266. Monorepo options:
  267. --package <pkg> Package name (validated against config.yaml packages)
  268. List options:
  269. --mine, -m Show only tasks assigned to current developer
  270. --status, -s <s> Filter by status (planning, in_progress, review, completed)
  271. Examples:
  272. python3 task.py create "Add login feature" --slug add-login
  273. python3 task.py create "Add login feature" --slug add-login --package cli
  274. python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent
  275. python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
  276. python3 task.py set-branch <dir> task/add-login
  277. python3 task.py start .trellis/tasks/01-21-add-login
  278. python3 task.py current --source
  279. python3 task.py finish
  280. python3 task.py archive add-login
  281. python3 task.py add-subtask parent-task child-task # Link existing tasks
  282. python3 task.py remove-subtask parent-task child-task
  283. python3 task.py list # List all active tasks
  284. python3 task.py list --mine # List my tasks only
  285. python3 task.py list --mine --status in_progress # List my in-progress tasks
  286. """)
  287. # =============================================================================
  288. # Main Entry
  289. # =============================================================================
  290. def main() -> int:
  291. """CLI entry point."""
  292. # Deprecation guard: `init-context` was removed in v0.5.0-beta.12.
  293. # Detect early so argparse doesn't mask the real reason with a generic
  294. # "invalid choice" error.
  295. if len(sys.argv) >= 2 and sys.argv[1] == "init-context":
  296. print(
  297. colored(
  298. "Error: `task.py init-context` was removed in v0.5.0-beta.12.",
  299. Colors.RED,
  300. ),
  301. file=sys.stderr,
  302. )
  303. print(
  304. "implement.jsonl / check.jsonl are now seeded on `task.py create` for",
  305. file=sys.stderr,
  306. )
  307. print(
  308. "sub-agent-capable platforms and curated by the AI during Phase 1.3.",
  309. file=sys.stderr,
  310. )
  311. print("See .trellis/workflow.md Phase 1.3 or run:", file=sys.stderr)
  312. print(
  313. " python3 ./.trellis/scripts/get_context.py --mode phase --step 1.3",
  314. file=sys.stderr,
  315. )
  316. print(
  317. "Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.",
  318. file=sys.stderr,
  319. )
  320. return 2
  321. parser = argparse.ArgumentParser(
  322. description="Task Management Script",
  323. formatter_class=argparse.RawDescriptionHelpFormatter,
  324. )
  325. subparsers = parser.add_subparsers(dest="command", help="Commands")
  326. # create
  327. p_create = subparsers.add_parser("create", help="Create new task")
  328. p_create.add_argument("title", help="Task title")
  329. p_create.add_argument("--slug", "-s", help="Task slug")
  330. p_create.add_argument("--assignee", "-a", help="Assignee developer")
  331. p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
  332. p_create.add_argument("--description", "-d", help="Task description")
  333. p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
  334. p_create.add_argument("--package", help="Package name for monorepo projects")
  335. # add-context
  336. p_add = subparsers.add_parser("add-context", help="Add context entry")
  337. p_add.add_argument("dir", help="Task directory")
  338. p_add.add_argument("file", help="JSONL file (implement|check)")
  339. p_add.add_argument("path", help="File path to add")
  340. p_add.add_argument("reason", nargs="?", help="Reason for adding")
  341. # validate
  342. p_validate = subparsers.add_parser("validate", help="Validate context files")
  343. p_validate.add_argument("dir", help="Task directory")
  344. # list-context
  345. p_listctx = subparsers.add_parser("list-context", help="List context entries")
  346. p_listctx.add_argument("dir", help="Task directory")
  347. # start
  348. p_start = subparsers.add_parser("start", help="Set active task")
  349. p_start.add_argument("dir", help="Task directory")
  350. # current
  351. p_current = subparsers.add_parser("current", help="Show active task")
  352. p_current.add_argument("--source", action="store_true",
  353. help="Show active task source")
  354. # finish
  355. subparsers.add_parser("finish", help="Clear active task")
  356. # set-branch
  357. p_branch = subparsers.add_parser("set-branch", help="Set git branch")
  358. p_branch.add_argument("dir", help="Task directory")
  359. p_branch.add_argument("branch", help="Branch name")
  360. # set-base-branch
  361. p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch")
  362. p_base.add_argument("dir", help="Task directory")
  363. p_base.add_argument("base_branch", help="Base branch name (PR target)")
  364. # set-scope
  365. p_scope = subparsers.add_parser("set-scope", help="Set scope")
  366. p_scope.add_argument("dir", help="Task directory")
  367. p_scope.add_argument("scope", help="Scope name")
  368. # archive
  369. p_archive = subparsers.add_parser("archive", help="Archive task")
  370. p_archive.add_argument("name", help="Task directory or name")
  371. p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
  372. # list
  373. p_list = subparsers.add_parser("list", help="List tasks")
  374. p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
  375. p_list.add_argument("--status", "-s", help="Filter by status")
  376. # add-subtask
  377. p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
  378. p_addsub.add_argument("parent_dir", help="Parent task directory")
  379. p_addsub.add_argument("child_dir", help="Child task directory")
  380. # remove-subtask
  381. p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
  382. p_rmsub.add_argument("parent_dir", help="Parent task directory")
  383. p_rmsub.add_argument("child_dir", help="Child task directory")
  384. # list-archive
  385. p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
  386. p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
  387. args = parser.parse_args()
  388. if not args.command:
  389. show_usage()
  390. return 1
  391. commands = {
  392. "create": cmd_create,
  393. "add-context": cmd_add_context,
  394. "validate": cmd_validate,
  395. "list-context": cmd_list_context,
  396. "start": cmd_start,
  397. "current": cmd_current,
  398. "finish": cmd_finish,
  399. "set-branch": cmd_set_branch,
  400. "set-base-branch": cmd_set_base_branch,
  401. "set-scope": cmd_set_scope,
  402. "archive": cmd_archive,
  403. "add-subtask": cmd_add_subtask,
  404. "remove-subtask": cmd_remove_subtask,
  405. "list": cmd_list,
  406. "list-archive": cmd_list_archive,
  407. }
  408. if args.command in commands:
  409. return commands[args.command](args)
  410. else:
  411. show_usage()
  412. return 1
  413. if __name__ == "__main__":
  414. sys.exit(main())