tasktree-manager 2.13.3__tar.gz → 2.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/CHANGELOG.md +6 -0
  2. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/README.md +17 -0
  4. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/pyproject.toml +1 -1
  5. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/_version.py +2 -2
  6. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/app.py +10 -0
  7. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/cli.py +204 -11
  8. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/models.py +65 -5
  9. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/task_manager.py +170 -30
  10. tasktree_manager-2.14.0/tests/test_epics.py +430 -0
  11. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  12. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  13. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  14. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  15. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  16. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  17. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  18. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  19. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  20. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  21. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  22. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.github/workflows/ci.yml +0 -0
  23. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.github/workflows/release.yml +0 -0
  24. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.gitignore +0 -0
  25. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/CONTRIBUTING.md +0 -0
  26. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/LICENSE +0 -0
  27. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/ROADMAP.md +0 -0
  28. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/claude-code-integration-spec.md +0 -0
  29. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/configuration.md +0 -0
  30. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/installation.md +0 -0
  31. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/troubleshooting.md +0 -0
  32. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/user-guide.md +0 -0
  33. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/mise.toml +0 -0
  34. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/scripts/bump_version.py +0 -0
  35. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/__init__.py +0 -0
  36. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/commands.py +0 -0
  37. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/__init__.py +0 -0
  38. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/agent_sessions.py +0 -0
  39. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/claude_hooks.py +0 -0
  40. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/claude_sessions.py +0 -0
  41. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/config.py +0 -0
  42. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/forge.py +0 -0
  43. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/git_ops.py +0 -0
  44. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/themes.py +0 -0
  45. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/__init__.py +0 -0
  46. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/app_header.py +0 -0
  47. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/create_modal.py +0 -0
  48. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  49. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  50. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/status_panel.py +0 -0
  51. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/task_list.py +0 -0
  52. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  53. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/__init__.py +0 -0
  54. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/conftest.py +0 -0
  55. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_agent_sessions.py +0 -0
  56. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_agent_sessions_decode.py +0 -0
  57. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_app.py +0 -0
  58. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_app_review_fixes.py +0 -0
  59. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_bump_version.py +0 -0
  60. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_claude_hooks.py +0 -0
  61. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_claude_sessions.py +0 -0
  62. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_cli.py +0 -0
  63. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_commands.py +0 -0
  64. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_config.py +0 -0
  65. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_forge.py +0 -0
  66. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_git_ops.py +0 -0
  67. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_integration_git.py +0 -0
  68. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_list_selection.py +0 -0
  69. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_medium_fixes.py +0 -0
  70. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_memory.py +0 -0
  71. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_messages_panel.py +0 -0
  72. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_modals.py +0 -0
  73. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_safety_fixes.py +0 -0
  74. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_setup_modal.py +0 -0
  75. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_status_panel.py +0 -0
  76. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_task_manager.py +0 -0
  77. {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_worktree_badges.py +0 -0
@@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
  - Git hooks support
16
16
  - Custom task metadata
17
17
 
18
+ ## [2.14.0] - 2026-10-19
19
+
20
+ ### Added
21
+ - **epic**: Group tasks under epics from the CLI
22
+
23
+
18
24
  ## [2.13.3] - 2026-10-16
19
25
 
20
26
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.13.3
3
+ Version: 2.14.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -86,6 +86,23 @@ tasktree-manager finish DIC-1901-argocd-tls # safety sweep -> archive diff + t
86
86
  tasktree-manager delete DIC-1901-argocd-tls # archive diff + task files -> delete; refuses on unfinished work; --force overrides
87
87
  ```
88
88
 
89
+ Epics group related tasks, for example a Jira epic and its child tasks. An epic is a task
90
+ folder with a `.tasktree_kind` marker. It can hold its own notes, Claude session and
91
+ worktrees. A child task names its epic in `.tasktree_epic`. Folders never move:
92
+
93
+ ```bash
94
+ tasktree-manager epic create DIC-2466-pcaps-epic # --repos is optional for an epic
95
+ tasktree-manager create DIC-2861-pcaps-logs --repos infra --epic DIC-2466-pcaps-epic
96
+ tasktree-manager epic add DIC-2466-pcaps-epic DIC-2862-pcaps-s3 # attach or move existing tasks
97
+ tasktree-manager epic remove DIC-2862-pcaps-s3 # detach
98
+ tasktree-manager epic mark DIC-2466-pcaps-epic # turn an existing task into an epic; unmark reverts
99
+ tasktree-manager epic list # epics and their tasks; also --json
100
+ ```
101
+
102
+ Epics don't nest. `list --json` and `status --json` carry `kind` (`task` or `epic`) and
103
+ `epic` (the parent epic or `null`). `delete` and `finish` refuse an epic that still has
104
+ tasks, even with `--force`.
105
+
89
106
  Exit code is 0 on success, 1 on failure with the reason on stderr. `delete` and `finish` run
90
107
  the same safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion);
91
108
  with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
@@ -71,7 +71,7 @@ version-file = "tasktree_manager/_version.py"
71
71
  # Semantic Release Configuration
72
72
  [tool.semantic_release]
73
73
  version_toml = ["pyproject.toml:tool.semantic_release.version"]
74
- version = "2.13.3"
74
+ version = "2.14.0"
75
75
  tag_format = "v{version}"
76
76
  branch = "main"
77
77
  commit_message = "chore(release): {version} [skip ci]"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.13.3'
22
- __version_tuple__ = version_tuple = (2, 13, 3)
21
+ __version__ = version = '2.14.0'
22
+ __version_tuple__ = version_tuple = (2, 14, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1300,6 +1300,16 @@ class TaskTreeApp(App):
1300
1300
  if not self.current_task:
1301
1301
  self.notify("No task selected", severity="warning")
1302
1302
  return
1303
+ if self.current_task.is_epic:
1304
+ children = self.task_manager.epic_child_names(self.current_task.name)
1305
+ if children:
1306
+ self.notify(
1307
+ escape(
1308
+ f"Epic '{self.current_task.name}' still has tasks: {', '.join(children)}"
1309
+ ),
1310
+ severity="warning",
1311
+ )
1312
+ return
1303
1313
 
1304
1314
  # Safety checks hit the network (git fetch per worktree), so run them
1305
1315
  # in a background thread and show the dialog when they complete
@@ -16,7 +16,7 @@ from . import __version__
16
16
  from .services import forge
17
17
  from .services.config import Config, ConfigError
18
18
  from .services.git_ops import GitOps
19
- from .services.models import ArchiveResult, Task
19
+ from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task
20
20
  from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
21
21
 
22
22
 
@@ -41,8 +41,43 @@ def build_parser() -> argparse.ArgumentParser:
41
41
  default=None,
42
42
  help="Base branch for the worktrees (default: git.default_base_branch from config)",
43
43
  )
44
+ create.add_argument("--epic", default=None, help="Attach the new task to this epic")
44
45
  create.set_defaults(func=cmd_create)
45
46
 
47
+ epic = sub.add_parser("epic", help="Group tasks under epics")
48
+ epic_sub = epic.add_subparsers(dest="epic_command", required=True)
49
+
50
+ epic_create = epic_sub.add_parser("create", help="Create an epic (repos are optional)")
51
+ epic_create.add_argument("name", help="Epic name (also the branch name of its worktrees)")
52
+ epic_create.add_argument(
53
+ "--repos", default="", help="Comma-separated repo names from REPOS_DIR"
54
+ )
55
+ epic_create.add_argument("--base", default=None, help="Base branch for the worktrees")
56
+ epic_create.set_defaults(func=cmd_epic_create)
57
+
58
+ epic_add = epic_sub.add_parser(
59
+ "add", help="Attach tasks to an epic (moves them from another epic)"
60
+ )
61
+ epic_add.add_argument("epic", help="Epic name")
62
+ epic_add.add_argument("tasks", nargs="+", help="Task names")
63
+ epic_add.set_defaults(func=cmd_epic_add)
64
+
65
+ epic_remove = epic_sub.add_parser("remove", help="Detach tasks from their epic")
66
+ epic_remove.add_argument("tasks", nargs="+", help="Task names")
67
+ epic_remove.set_defaults(func=cmd_epic_remove)
68
+
69
+ epic_mark = epic_sub.add_parser("mark", help="Turn an existing task into an epic")
70
+ epic_mark.add_argument("name", help="Task name")
71
+ epic_mark.set_defaults(func=cmd_epic_mark)
72
+
73
+ epic_unmark = epic_sub.add_parser("unmark", help="Turn an epic without tasks back into a task")
74
+ epic_unmark.add_argument("name", help="Epic name")
75
+ epic_unmark.set_defaults(func=cmd_epic_unmark)
76
+
77
+ epic_list = epic_sub.add_parser("list", help="List epics and their tasks")
78
+ epic_list.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
79
+ epic_list.set_defaults(func=cmd_epic_list)
80
+
46
81
  list_parser = sub.add_parser("list", help="List tasks with their repos and dirty state")
47
82
  list_parser.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
48
83
  list_parser.set_defaults(func=cmd_list)
@@ -56,7 +91,7 @@ def build_parser() -> argparse.ArgumentParser:
56
91
  "--force",
57
92
  action="store_true",
58
93
  help="Delete even with uncommitted, unpushed or unmerged work, or task files "
59
- "that could not be archived",
94
+ "that could not be archived (never an epic that still has tasks)",
60
95
  )
61
96
  delete.set_defaults(func=cmd_delete)
62
97
 
@@ -81,7 +116,7 @@ def build_parser() -> argparse.ArgumentParser:
81
116
  "--force",
82
117
  action="store_true",
83
118
  help="Finish even with unfinished work or task files that could not be archived "
84
- "(the archive is still written)",
119
+ "(the archive is still written; never an epic that still has tasks)",
85
120
  )
86
121
  finish.set_defaults(func=cmd_finish)
87
122
 
@@ -123,19 +158,42 @@ def _unknown_repos_error(config: Config, repos: list[str]) -> str | None:
123
158
  return None
124
159
 
125
160
 
161
+ def _split_repos(value: str) -> list[str]:
162
+ return [r.strip() for r in value.split(",") if r.strip()]
163
+
164
+
126
165
  def cmd_create(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
127
166
  """Create a task with worktrees for the given repos."""
128
- repos = [r.strip() for r in args.repos.split(",") if r.strip()]
167
+ repos = _split_repos(args.repos)
129
168
  if not repos:
130
169
  return _error("--repos needs at least one repo name")
131
- error = _unknown_repos_error(config, repos)
170
+ return _create(manager, config, args.name, repos, args.base, epic=args.epic)
171
+
172
+
173
+ def cmd_epic_create(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
174
+ """Create an epic, with or without worktrees."""
175
+ return _create(manager, config, args.name, _split_repos(args.repos), args.base, kind=EPIC_KIND)
176
+
177
+
178
+ def _create(
179
+ manager: TaskManager,
180
+ config: Config,
181
+ name: str,
182
+ repos: list[str],
183
+ base: str | None,
184
+ *,
185
+ epic: str | None = None,
186
+ kind: str = TASK_KIND,
187
+ ) -> int:
188
+ """Shared body of `create` and `epic create`."""
189
+ error = _unknown_repos_error(config, repos) if repos else None
132
190
  if error:
133
191
  return _error(error)
134
- if manager.get_task(args.name):
135
- return _error(f"task already exists: {args.name}")
192
+ if manager.get_task(name):
193
+ return _error(f"task already exists: {name}")
136
194
 
137
- base = args.base or config.default_base_branch
138
- task = manager.create_task(args.name, repos, base)
195
+ base = base or config.default_base_branch
196
+ task = manager.create_task(name, repos, base, epic=epic, kind=kind)
139
197
  # CLI-created tasks may never pass through the TUI's lazy Claude prep,
140
198
  # so materialize CLAUDE.md files and worktree settings right away. These
141
199
  # are best-effort polish on a task that already exists: a failure here
@@ -149,11 +207,138 @@ def cmd_create(manager: TaskManager, config: Config, args: argparse.Namespace) -
149
207
  except Exception as e:
150
208
  print(f"warning: {step_name} setup failed: {e}", file=sys.stderr)
151
209
 
152
- repo_list = ", ".join(wt.name for wt in task.worktrees)
153
- print(f"Created task {task.name} at {task.path} ({len(task.worktrees)} repo(s): {repo_list})")
210
+ noun = "epic" if task.is_epic else "task"
211
+ repo_list = ", ".join(wt.name for wt in task.worktrees) or "none"
212
+ line = f"Created {noun} {task.name} at {task.path} ({len(task.worktrees)} repo(s): {repo_list})"
213
+ if task.epic_name is not None:
214
+ line += f" in epic {task.epic_name}"
215
+ print(line)
216
+ return 0
217
+
218
+
219
+ def _get_tasks(manager: TaskManager, names: list[str]) -> list[Task] | None:
220
+ """Resolve task names, printing an error for each missing one (None if any)."""
221
+ tasks = []
222
+ missing = False
223
+ for name in names:
224
+ task = manager.get_task(name)
225
+ if task is None:
226
+ _error(f"no such task: {name}")
227
+ missing = True
228
+ else:
229
+ tasks.append(task)
230
+ return None if missing else tasks
231
+
232
+
233
+ def cmd_epic_add(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
234
+ """Attach tasks to an epic; a task in another epic moves."""
235
+ tasks = _get_tasks(manager, args.tasks)
236
+ if tasks is None:
237
+ return 1
238
+ code = 0
239
+ for task in tasks:
240
+ previous = task.epic_name
241
+ try:
242
+ # Always through the setter: it re-checks that the epic still exists
243
+ epic = manager.set_task_epic(task, args.epic)
244
+ except ValueError as e:
245
+ code = _error(str(e))
246
+ continue
247
+ if previous == epic:
248
+ print(f"Task {task.name} already in epic {epic}")
249
+ elif previous is None:
250
+ print(f"Added {task.name} to epic {epic}")
251
+ else:
252
+ print(f"Moved {task.name} from epic {previous} to epic {epic}")
253
+ return code
254
+
255
+
256
+ def cmd_epic_remove(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
257
+ """Detach tasks from their epic."""
258
+ tasks = _get_tasks(manager, args.tasks)
259
+ if tasks is None:
260
+ return 1
261
+ for task in tasks:
262
+ previous = task.epic_name
263
+ manager.set_task_epic(task, None)
264
+ if previous is None:
265
+ print(f"Task {task.name} is not in an epic")
266
+ else:
267
+ print(f"Removed {task.name} from epic {previous}")
268
+ return 0
269
+
270
+
271
+ def cmd_epic_mark(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
272
+ """Turn an existing task into an epic."""
273
+ task = manager.get_task(args.name)
274
+ if not task:
275
+ return _error(f"no such task: {args.name}")
276
+ if task.is_epic:
277
+ print(f"Task {args.name} is already an epic")
278
+ return 0
279
+ manager.set_task_kind(task, EPIC_KIND)
280
+ print(f"Marked {args.name} as an epic")
281
+ return 0
282
+
283
+
284
+ def cmd_epic_unmark(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
285
+ """Turn an epic without tasks back into a plain task."""
286
+ task = manager.get_task(args.name)
287
+ if not task:
288
+ return _error(f"no such task: {args.name}")
289
+ if not task.is_epic:
290
+ print(f"Task {args.name} is not an epic")
291
+ return 0
292
+ manager.set_task_kind(task, TASK_KIND)
293
+ print(f"Unmarked {args.name}: it is a plain task now")
294
+ return 0
295
+
296
+
297
+ def cmd_epic_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
298
+ """List epics and the tasks attached to each (no git calls)."""
299
+ epics = [task for task in manager.list_tasks() if task.is_epic]
300
+ children = {epic.name: manager.epic_child_names(epic.name) for epic in epics}
301
+
302
+ if args.as_json:
303
+ payload = [
304
+ {
305
+ "name": epic.name,
306
+ "display_name": epic.display_name,
307
+ "path": str(epic.path),
308
+ "repos": [wt.name for wt in epic.worktrees],
309
+ "tasks": children[epic.name],
310
+ }
311
+ for epic in epics
312
+ ]
313
+ print(json.dumps(payload, indent=2))
314
+ return 0
315
+
316
+ if not epics:
317
+ print("No epics")
318
+ return 0
319
+ for epic in epics:
320
+ names = children[epic.name]
321
+ print(f"{epic.name}\t{len(names)} task(s)\t{', '.join(names) or '(no tasks)'}")
154
322
  return 0
155
323
 
156
324
 
325
+ def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> bool:
326
+ """Print an error and return True if `task` is an epic that still has tasks.
327
+
328
+ Checked before the safety sweep: --force covers git state, not hierarchy.
329
+ """
330
+ if not task.is_epic:
331
+ return False
332
+ children = manager.epic_child_names(task.name)
333
+ if not children:
334
+ return False
335
+ _error(
336
+ f"cannot {verb} epic {task.name}: it still has {len(children)} task(s): "
337
+ f"{', '.join(children)}; finish them or run: tasktree-manager epic remove TASK"
338
+ )
339
+ return True
340
+
341
+
157
342
  def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
158
343
  """List tasks with their repos and dirty state."""
159
344
  tasks = manager.list_tasks()
@@ -171,6 +356,8 @@ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) ->
171
356
  {
172
357
  "name": task.name,
173
358
  "display_name": task.display_name,
359
+ "kind": task.kind,
360
+ "epic": task.epic_name,
174
361
  "path": str(task.path),
175
362
  "repos": [wt.name for wt in task.worktrees],
176
363
  "dirty": dirty[task.name],
@@ -252,6 +439,8 @@ def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -
252
439
  task = manager.get_task(args.name)
253
440
  if not task:
254
441
  return _error(f"no such task: {args.name}")
442
+ if _refuse_epic_with_children(manager, task, "delete"):
443
+ return 1
255
444
 
256
445
  report = manager.check_task_safety(task)
257
446
  if not report.is_safe() and not args.force:
@@ -276,6 +465,8 @@ def cmd_finish(manager: TaskManager, config: Config, args: argparse.Namespace) -
276
465
  task = manager.get_task(args.name)
277
466
  if not task:
278
467
  return _error(f"no such task: {args.name}")
468
+ if _refuse_epic_with_children(manager, task, "finish"):
469
+ return 1
279
470
 
280
471
  # Push unconditionally when asked (TUI parity: `git push -u origin HEAD`).
281
472
  # Gating on has_unpushed() would skip never-pushed --no-track branches,
@@ -431,6 +622,8 @@ def cmd_status(manager: TaskManager, config: Config, args: argparse.Namespace) -
431
622
  if args.as_json:
432
623
  payload = {
433
624
  "task": task.name,
625
+ "kind": task.kind,
626
+ "epic": task.epic_name,
434
627
  "path": str(task.path),
435
628
  "claude": claude,
436
629
  "worktrees": [
@@ -1,8 +1,46 @@
1
1
  """Data models for tasktree-manager."""
2
2
 
3
+ import re
3
4
  from dataclasses import dataclass, field
4
5
  from pathlib import Path
5
6
 
7
+ # Task name validation pattern. No '/': a task is exactly one directory
8
+ # level under TASKS_DIR (list_tasks() only reads that level), and a slashed
9
+ # name would be surfaced as its parent directory with the wrong branch name.
10
+ TASK_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9._\-]+$")
11
+
12
+ EPIC_KIND = "epic"
13
+ TASK_KIND = "task"
14
+
15
+
16
+ def validate_task_name(name: str) -> str | None:
17
+ """Validate a task name, returning an error message or None if valid.
18
+
19
+ Task names become a directory under TASKS_DIR and a git branch name, so
20
+ they must be a single path segment that cannot traverse ('.', '..') or
21
+ look like a git option (leading '-').
22
+ """
23
+ if not name:
24
+ return "Task name cannot be empty"
25
+ if name.startswith("-"):
26
+ return "Task name cannot start with '-'"
27
+ if name.startswith("."):
28
+ # list_tasks() hides dot-dirs (so the task would vanish from the
29
+ # list), and the archive dir itself lives at TASKS_DIR/.archive
30
+ return "Task name cannot start with '.'"
31
+ if not TASK_NAME_PATTERN.fullmatch(name):
32
+ return "Task name can only contain letters, numbers, '.', '_', '-'"
33
+ return None
34
+
35
+
36
+ def _read_marker(path: Path) -> str | None:
37
+ """Stripped content of a marker file, or None when missing or empty."""
38
+ try:
39
+ value = path.read_text(encoding="utf-8").strip()
40
+ except (OSError, UnicodeDecodeError):
41
+ return None
42
+ return value or None
43
+
6
44
 
7
45
  @dataclass
8
46
  class Worktree:
@@ -55,17 +93,39 @@ class Task:
55
93
  Read lazily from disk so every Task construction site picks it up
56
94
  and a rename is visible on the next render without reloading.
57
95
  """
58
- try:
59
- value = (self.path / ".tasktree_name").read_text(encoding="utf-8").strip()
60
- except OSError:
61
- return None
62
- return value or None
96
+ return _read_marker(self.path / ".tasktree_name")
63
97
 
64
98
  @property
65
99
  def display_label(self) -> str:
66
100
  """What the task list shows: the alias when set, else the real name."""
67
101
  return self.display_name or self.name
68
102
 
103
+ @property
104
+ def kind(self) -> str:
105
+ """'epic' when <task>/.tasktree_kind says so, else 'task'."""
106
+ if _read_marker(self.path / ".tasktree_kind") == EPIC_KIND:
107
+ return EPIC_KIND
108
+ return TASK_KIND
109
+
110
+ @property
111
+ def is_epic(self) -> bool:
112
+ """True for an epic: a task folder that groups other tasks."""
113
+ return self.kind == EPIC_KIND
114
+
115
+ @property
116
+ def epic_name(self) -> str | None:
117
+ """Parent epic from <task>/.tasktree_epic, or None.
118
+
119
+ The marker becomes a path, so a value that is not a valid task name
120
+ or names the task itself reads as None. Epics never have a parent.
121
+ """
122
+ value = _read_marker(self.path / ".tasktree_epic")
123
+ if value is None or value == self.name or validate_task_name(value):
124
+ return None
125
+ if self.is_epic:
126
+ return None
127
+ return value
128
+
69
129
 
70
130
  @dataclass
71
131
  class GitStatus: