1
0

task_store.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  1. #!/usr/bin/env python3
  2. """
  3. Task CRUD operations.
  4. Provides:
  5. ensure_tasks_dir - Ensure tasks directory exists
  6. cmd_create - Create a new task
  7. cmd_archive - Archive completed task
  8. cmd_set_branch - Set git branch for task
  9. cmd_set_base_branch - Set PR target branch
  10. cmd_set_scope - Set scope for PR title
  11. cmd_add_subtask - Link child task to parent
  12. cmd_remove_subtask - Unlink child task from parent
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import json
  17. import re
  18. import sys
  19. from datetime import datetime
  20. from pathlib import Path
  21. from .config import (
  22. get_packages,
  23. get_session_auto_commit,
  24. is_monorepo,
  25. resolve_package,
  26. validate_package,
  27. )
  28. from .git import run_git
  29. from .io import read_json, write_json
  30. from .log import Colors, colored
  31. from .paths import (
  32. DIR_ARCHIVE,
  33. DIR_TASKS,
  34. DIR_WORKFLOW,
  35. FILE_TASK_JSON,
  36. generate_task_date_prefix,
  37. get_developer,
  38. get_repo_root,
  39. get_tasks_dir,
  40. )
  41. from .safe_commit import (
  42. print_gitignore_warning,
  43. safe_archive_paths_to_add,
  44. safe_git_add,
  45. )
  46. from .task_utils import (
  47. archive_task_complete,
  48. find_task_by_name,
  49. resolve_task_dir,
  50. run_task_hooks,
  51. )
  52. # =============================================================================
  53. # Helper Functions
  54. # =============================================================================
  55. def _slugify(title: str) -> str:
  56. """Convert title to slug (only works with ASCII)."""
  57. result = title.lower()
  58. result = re.sub(r"[^a-z0-9]", "-", result)
  59. result = re.sub(r"-+", "-", result)
  60. result = result.strip("-")
  61. return result
  62. def ensure_tasks_dir(repo_root: Path) -> Path:
  63. """Ensure tasks directory exists."""
  64. tasks_dir = get_tasks_dir(repo_root)
  65. archive_dir = tasks_dir / "archive"
  66. if not tasks_dir.exists():
  67. tasks_dir.mkdir(parents=True)
  68. print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
  69. if not archive_dir.exists():
  70. archive_dir.mkdir(parents=True)
  71. return tasks_dir
  72. def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
  73. """Find an archived task directory with the exact active-task dir name."""
  74. archive_dir = tasks_dir / DIR_ARCHIVE
  75. if not archive_dir.is_dir():
  76. return None
  77. for month_dir in sorted(archive_dir.iterdir()):
  78. if not month_dir.is_dir():
  79. continue
  80. candidate = month_dir / dir_name
  81. if candidate.is_dir():
  82. return candidate
  83. return None
  84. def _repo_relative_path(path: Path, repo_root: Path) -> str:
  85. """Format a path relative to the repo root when possible."""
  86. try:
  87. return path.relative_to(repo_root).as_posix()
  88. except ValueError:
  89. return str(path)
  90. # =============================================================================
  91. # Sub-agent platform detection + JSONL seeding
  92. # =============================================================================
  93. # Config directories of platforms that consume implement.jsonl / check.jsonl.
  94. # Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
  95. # platforms listed in workflow.md's "agent-capable" Skill Routing block
  96. # (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
  97. # Devin are NOT in this list: they do not consume JSONL.
  98. _SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
  99. ".claude",
  100. ".cursor",
  101. ".codex",
  102. ".kiro",
  103. ".gemini",
  104. ".opencode",
  105. ".qoder",
  106. ".codebuddy",
  107. ".factory", # Factory Droid
  108. ".github/copilot",
  109. ".pi", # Pi Agent
  110. ".trae", # Trae IDE
  111. )
  112. _SEED_EXAMPLE = (
  113. "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
  114. "Put spec/research files only — no code paths. "
  115. "Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. "
  116. "Delete this line once real entries are added."
  117. )
  118. def _has_subagent_platform(repo_root: Path) -> bool:
  119. """Return True if any sub-agent-capable platform is configured.
  120. Detected by probing well-known config directories at the repo root. Used
  121. only to decide whether ``task.py create`` should seed empty
  122. ``implement.jsonl`` / ``check.jsonl`` files.
  123. """
  124. for config_dir in _SUBAGENT_CONFIG_DIRS:
  125. if (repo_root / config_dir).is_dir():
  126. return True
  127. return False
  128. def _write_seed_jsonl(path: Path) -> None:
  129. """Write a one-line seed JSONL file with a self-describing ``_example``.
  130. The seed row has no ``file`` field, so downstream consumers (hooks +
  131. preludes) that iterate entries via ``item.get("file")`` naturally skip
  132. it. The row exists purely as an in-file prompt for the AI curator.
  133. """
  134. seed = {"_example": _SEED_EXAMPLE}
  135. path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
  136. def _default_prd_content(title: str, description: str | None = None) -> str:
  137. """Return the default PRD skeleton created with every task."""
  138. goal = (description or "").strip() or "TBD."
  139. heading = title.strip() or "Untitled task"
  140. return f"""# {heading}
  141. ## Goal
  142. {goal}
  143. ## Requirements
  144. - TBD
  145. ## Acceptance Criteria
  146. - [ ] TBD
  147. ## Notes
  148. - Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
  149. - Lightweight tasks can remain PRD-only.
  150. - For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
  151. """
  152. # =============================================================================
  153. # Command: create
  154. # =============================================================================
  155. def cmd_create(args: argparse.Namespace) -> int:
  156. """Create a new task."""
  157. repo_root = get_repo_root()
  158. if not args.title:
  159. print(colored("Error: title is required", Colors.RED), file=sys.stderr)
  160. return 1
  161. # Validate --package (CLI source: fail-fast)
  162. package: str | None = getattr(args, "package", None)
  163. if not is_monorepo(repo_root):
  164. # Single-repo: ignore --package, no package prefix
  165. if package:
  166. print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr)
  167. package = None
  168. elif package:
  169. if not validate_package(package, repo_root):
  170. packages = get_packages(repo_root)
  171. available = ", ".join(sorted(packages.keys())) if packages else "(none)"
  172. print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr)
  173. return 1
  174. else:
  175. # Inferred: default_package → None (no task.json yet for create)
  176. package = resolve_package(repo_root=repo_root)
  177. # Default assignee to current developer
  178. assignee = args.assignee
  179. if not assignee:
  180. assignee = get_developer(repo_root)
  181. if not assignee:
  182. print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
  183. return 1
  184. ensure_tasks_dir(repo_root)
  185. # Get current developer as creator
  186. creator = get_developer(repo_root) or assignee
  187. # Generate slug if not provided
  188. slug = args.slug or _slugify(args.title)
  189. if not slug:
  190. print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
  191. return 1
  192. # Create task directory with MM-DD-slug format
  193. tasks_dir = get_tasks_dir(repo_root)
  194. date_prefix = generate_task_date_prefix()
  195. dir_name = f"{date_prefix}-{slug}"
  196. task_dir = tasks_dir / dir_name
  197. task_json_path = task_dir / FILE_TASK_JSON
  198. archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name)
  199. if archived_task_dir:
  200. print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr)
  201. print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
  202. print("Use a new slug if you intend to create a new task.", file=sys.stderr)
  203. return 1
  204. if task_dir.exists():
  205. print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr)
  206. else:
  207. task_dir.mkdir(parents=True)
  208. today = datetime.now().strftime("%Y-%m-%d")
  209. # Record current branch as base_branch (PR target)
  210. _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
  211. current_branch = branch_out.strip() or "main"
  212. task_data = {
  213. "id": slug,
  214. "name": slug,
  215. "title": args.title,
  216. "description": args.description or "",
  217. "status": "planning",
  218. "dev_type": None,
  219. "scope": None,
  220. "package": package,
  221. "priority": args.priority,
  222. "creator": creator,
  223. "assignee": assignee,
  224. "createdAt": today,
  225. "completedAt": None,
  226. "branch": None,
  227. "base_branch": current_branch,
  228. "worktree_path": None,
  229. "commit": None,
  230. "pr_url": None,
  231. "subtasks": [],
  232. "children": [],
  233. "parent": None,
  234. "relatedFiles": [],
  235. "notes": "",
  236. "meta": {},
  237. }
  238. write_json(task_json_path, task_data)
  239. prd_path = task_dir / "prd.md"
  240. if not prd_path.exists():
  241. prd_path.write_text(
  242. _default_prd_content(args.title, args.description),
  243. encoding="utf-8",
  244. )
  245. # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
  246. # Agent curates real entries during planning when the task needs them.
  247. # Agent-less platforms (Kilo / Antigravity / Devin) skip this — they
  248. # load specs via the trellis-before-dev skill instead of JSONL.
  249. seeded_jsonl = False
  250. if _has_subagent_platform(repo_root):
  251. for jsonl_name in ("implement.jsonl", "check.jsonl"):
  252. jsonl_path = task_dir / jsonl_name
  253. if not jsonl_path.exists():
  254. _write_seed_jsonl(jsonl_path)
  255. seeded_jsonl = True
  256. # Handle --parent: establish bidirectional link
  257. if args.parent:
  258. parent_dir = resolve_task_dir(args.parent, repo_root)
  259. parent_json_path = parent_dir / FILE_TASK_JSON
  260. if not parent_json_path.is_file():
  261. print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
  262. else:
  263. parent_data = read_json(parent_json_path)
  264. if parent_data:
  265. # Add child to parent's children list
  266. parent_children = parent_data.get("children", [])
  267. if dir_name not in parent_children:
  268. parent_children.append(dir_name)
  269. parent_data["children"] = parent_children
  270. write_json(parent_json_path, parent_data)
  271. # Set parent in child's task.json
  272. task_data["parent"] = parent_dir.name
  273. write_json(task_json_path, task_data)
  274. print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
  275. # Auto-activate the new task so the per-turn breadcrumb fires planning
  276. # state. Best-effort: gracefully degrade if no session identity (CLI run
  277. # outside an AI session) — the task is still created, the user can run
  278. # task.py start later. Pointer is session-scoped so this never affects
  279. # other AI sessions.
  280. try:
  281. from .active_task import resolve_context_key, set_active_task
  282. if resolve_context_key():
  283. try:
  284. rel_dir = task_dir.relative_to(repo_root).as_posix()
  285. except ValueError:
  286. rel_dir = str(task_dir)
  287. set_active_task(rel_dir, repo_root)
  288. except Exception:
  289. pass
  290. print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
  291. print("", file=sys.stderr)
  292. print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
  293. print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
  294. print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
  295. print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr)
  296. if seeded_jsonl:
  297. print(
  298. " - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context",
  299. file=sys.stderr,
  300. )
  301. print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr)
  302. print("", file=sys.stderr)
  303. # Output relative path for script chaining
  304. print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
  305. run_task_hooks("after_create", task_json_path, repo_root)
  306. return 0
  307. # =============================================================================
  308. # Command: archive
  309. # =============================================================================
  310. def cmd_archive(args: argparse.Namespace) -> int:
  311. """Archive completed task."""
  312. repo_root = get_repo_root()
  313. task_name = args.name
  314. if not task_name:
  315. print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
  316. return 1
  317. tasks_dir = get_tasks_dir(repo_root)
  318. # Resolve task directory (supports task name, relative path, or absolute path)
  319. task_dir = resolve_task_dir(task_name, repo_root)
  320. if not task_dir or not task_dir.is_dir():
  321. print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
  322. print("Active tasks:", file=sys.stderr)
  323. # Import lazily to avoid circular dependency
  324. from .tasks import iter_active_tasks
  325. for t in iter_active_tasks(tasks_dir):
  326. print(f" - {t.dir_name}/", file=sys.stderr)
  327. return 1
  328. dir_name = task_dir.name
  329. task_json_path = task_dir / FILE_TASK_JSON
  330. # Update status before archiving
  331. today = datetime.now().strftime("%Y-%m-%d")
  332. # Names of child task dirs whose task.json gets modified below; passed
  333. # into safe_archive_paths_to_add so they're staged in this commit.
  334. modified_children: list[str] = []
  335. if task_json_path.is_file():
  336. data = read_json(task_json_path)
  337. if data:
  338. data["status"] = "completed"
  339. data["completedAt"] = today
  340. write_json(task_json_path, data)
  341. # Handle subtask relationships on archive.
  342. # Keep this task in its parent's children list so progress
  343. # counters (children_progress) stay consistent — children
  344. # missing from the active set are treated as completed.
  345. task_children = data.get("children", [])
  346. # If this is a parent, clear parent field in all children
  347. if task_children:
  348. for child_name in task_children:
  349. child_dir_path = find_task_by_name(child_name, tasks_dir)
  350. if child_dir_path:
  351. child_json = child_dir_path / FILE_TASK_JSON
  352. if child_json.is_file():
  353. child_data = read_json(child_json)
  354. if child_data:
  355. child_data["parent"] = None
  356. write_json(child_json, child_data)
  357. modified_children.append(child_dir_path.name)
  358. # Clear any session that still points at this task before the path moves.
  359. from .active_task import clear_task_from_sessions
  360. clear_task_from_sessions(str(task_dir), repo_root)
  361. # Archive
  362. result = archive_task_complete(task_dir, repo_root)
  363. if "archived_to" in result:
  364. archive_dest = Path(result["archived_to"])
  365. year_month = archive_dest.parent.name
  366. print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
  367. # Auto-commit unless --no-commit
  368. if not getattr(args, "no_commit", False):
  369. if not _auto_commit_archive(dir_name, repo_root, modified_children):
  370. print(
  371. colored(
  372. "Archive moved on disk, but git auto-commit did not complete. "
  373. "Resolve `git status` before continuing.",
  374. Colors.RED,
  375. ),
  376. file=sys.stderr,
  377. )
  378. return 1
  379. # Return the archive path
  380. print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
  381. # Run hooks with the archived path
  382. archived_json = archive_dest / FILE_TASK_JSON
  383. run_task_hooks("after_archive", archived_json, repo_root)
  384. return 0
  385. return 1
  386. def _auto_commit_archive(
  387. task_name: str,
  388. repo_root: Path,
  389. modified_children: list[str] | None = None,
  390. ) -> bool:
  391. """Stage Trellis-owned task paths and commit after archive.
  392. Scoped narrowly to the archived task's source + destination paths
  393. plus any child task dirs whose ``task.json`` was edited (parent →
  394. children relationship update). Dirty changes in OTHER active task
  395. dirs are NOT bundled into the archive commit.
  396. If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
  397. retry with ``git add -f``. The warning explicitly forbids
  398. ``git add -f .trellis/`` (which would fan out to caches/backups)
  399. and points users at ``session_auto_commit: false``.
  400. Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
  401. set to ``false``, this function returns immediately without
  402. touching git (the archive directory move on disk is unaffected).
  403. """
  404. if not get_session_auto_commit(repo_root):
  405. print(
  406. "[OK] session_auto_commit: false — skipping git stage/commit.",
  407. file=sys.stderr,
  408. )
  409. return True
  410. source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
  411. rc, tracked_out, _ = run_git(
  412. ["ls-files", "--", source_rel],
  413. cwd=repo_root,
  414. )
  415. source_was_tracked = rc == 0 and bool(tracked_out.strip())
  416. paths = safe_archive_paths_to_add(
  417. repo_root, task_name=task_name, modified_children=modified_children
  418. )
  419. if not paths:
  420. print("[OK] No task changes to commit.", file=sys.stderr)
  421. return True
  422. success, _, err = safe_git_add(paths, repo_root)
  423. if not success:
  424. if err and "ignored by" in err.lower():
  425. print_gitignore_warning(paths)
  426. else:
  427. print(
  428. f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
  429. file=sys.stderr,
  430. )
  431. return not source_was_tracked
  432. # Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
  433. # `git add` (no -A) which only stages additions/modifications. The
  434. # source task directory was moved away by `shutil.move`, so its files
  435. # need an explicit `git rm --cached` to stage the deletions in this
  436. # same commit — otherwise they sit as uncommitted "phantom deletes"
  437. # against HEAD until something later picks them up.
  438. #
  439. # `--ignore-unmatch` makes this a no-op when the task was never tracked
  440. # (e.g. archiving a task that lived only in working tree).
  441. run_git(
  442. ["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
  443. cwd=repo_root,
  444. )
  445. rc, _, _ = run_git(
  446. ["diff", "--cached", "--quiet", "--", *paths, source_rel],
  447. cwd=repo_root,
  448. )
  449. if rc == 0:
  450. print("[OK] No task changes to commit.", file=sys.stderr)
  451. return True
  452. commit_msg = f"chore(task): archive {task_name}"
  453. rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
  454. if rc == 0:
  455. print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
  456. return True
  457. else:
  458. print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
  459. return not source_was_tracked
  460. # =============================================================================
  461. # Command: add-subtask
  462. # =============================================================================
  463. def cmd_add_subtask(args: argparse.Namespace) -> int:
  464. """Link a child task to a parent task."""
  465. repo_root = get_repo_root()
  466. parent_dir = resolve_task_dir(args.parent_dir, repo_root)
  467. child_dir = resolve_task_dir(args.child_dir, repo_root)
  468. parent_json_path = parent_dir / FILE_TASK_JSON
  469. child_json_path = child_dir / FILE_TASK_JSON
  470. if not parent_json_path.is_file():
  471. print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
  472. return 1
  473. if not child_json_path.is_file():
  474. print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
  475. return 1
  476. parent_data = read_json(parent_json_path)
  477. child_data = read_json(child_json_path)
  478. if not parent_data or not child_data:
  479. print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
  480. return 1
  481. # Check if child already has a parent
  482. existing_parent = child_data.get("parent")
  483. if existing_parent:
  484. print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
  485. return 1
  486. # Add child to parent's children list
  487. parent_children = parent_data.get("children", [])
  488. child_dir_name = child_dir.name
  489. if child_dir_name not in parent_children:
  490. parent_children.append(child_dir_name)
  491. parent_data["children"] = parent_children
  492. # Set parent in child's task.json
  493. child_data["parent"] = parent_dir.name
  494. # Write both
  495. write_json(parent_json_path, parent_data)
  496. write_json(child_json_path, child_data)
  497. print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
  498. return 0
  499. # =============================================================================
  500. # Command: remove-subtask
  501. # =============================================================================
  502. def cmd_remove_subtask(args: argparse.Namespace) -> int:
  503. """Unlink a child task from a parent task."""
  504. repo_root = get_repo_root()
  505. parent_dir = resolve_task_dir(args.parent_dir, repo_root)
  506. child_dir = resolve_task_dir(args.child_dir, repo_root)
  507. parent_json_path = parent_dir / FILE_TASK_JSON
  508. child_json_path = child_dir / FILE_TASK_JSON
  509. if not parent_json_path.is_file():
  510. print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
  511. return 1
  512. if not child_json_path.is_file():
  513. print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
  514. return 1
  515. parent_data = read_json(parent_json_path)
  516. child_data = read_json(child_json_path)
  517. if not parent_data or not child_data:
  518. print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
  519. return 1
  520. # Remove child from parent's children list
  521. parent_children = parent_data.get("children", [])
  522. child_dir_name = child_dir.name
  523. if child_dir_name in parent_children:
  524. parent_children.remove(child_dir_name)
  525. parent_data["children"] = parent_children
  526. # Clear parent in child's task.json
  527. child_data["parent"] = None
  528. # Write both
  529. write_json(parent_json_path, parent_data)
  530. write_json(child_json_path, child_data)
  531. print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
  532. return 0
  533. # =============================================================================
  534. # Command: set-branch
  535. # =============================================================================
  536. def cmd_set_branch(args: argparse.Namespace) -> int:
  537. """Set git branch for task."""
  538. repo_root = get_repo_root()
  539. target_dir = resolve_task_dir(args.dir, repo_root)
  540. branch = args.branch
  541. if not branch:
  542. print(colored("Error: Missing arguments", Colors.RED))
  543. print("Usage: python task.py set-branch <task-dir> <branch-name>")
  544. return 1
  545. task_json = target_dir / FILE_TASK_JSON
  546. if not task_json.is_file():
  547. print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
  548. return 1
  549. data = read_json(task_json)
  550. if not data:
  551. return 1
  552. data["branch"] = branch
  553. write_json(task_json, data)
  554. print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
  555. return 0
  556. # =============================================================================
  557. # Command: set-base-branch
  558. # =============================================================================
  559. def cmd_set_base_branch(args: argparse.Namespace) -> int:
  560. """Set the base branch (PR target) for task."""
  561. repo_root = get_repo_root()
  562. target_dir = resolve_task_dir(args.dir, repo_root)
  563. base_branch = args.base_branch
  564. if not base_branch:
  565. print(colored("Error: Missing arguments", Colors.RED))
  566. print("Usage: python task.py set-base-branch <task-dir> <base-branch>")
  567. print("Example: python task.py set-base-branch <dir> develop")
  568. print()
  569. print("This sets the target branch for PR (the branch your feature will merge into).")
  570. return 1
  571. task_json = target_dir / FILE_TASK_JSON
  572. if not task_json.is_file():
  573. print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
  574. return 1
  575. data = read_json(task_json)
  576. if not data:
  577. return 1
  578. data["base_branch"] = base_branch
  579. write_json(task_json, data)
  580. print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
  581. print(f" PR will target: {base_branch}")
  582. return 0
  583. # =============================================================================
  584. # Command: set-scope
  585. # =============================================================================
  586. def cmd_set_scope(args: argparse.Namespace) -> int:
  587. """Set scope for PR title."""
  588. repo_root = get_repo_root()
  589. target_dir = resolve_task_dir(args.dir, repo_root)
  590. scope = args.scope
  591. if not scope:
  592. print(colored("Error: Missing arguments", Colors.RED))
  593. print("Usage: python task.py set-scope <task-dir> <scope>")
  594. return 1
  595. task_json = target_dir / FILE_TASK_JSON
  596. if not task_json.is_file():
  597. print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
  598. return 1
  599. data = read_json(task_json)
  600. if not data:
  601. return 1
  602. data["scope"] = scope
  603. write_json(task_json, data)
  604. print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
  605. return 0