tasktree-manager 2.13.3__tar.gz → 2.15.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 (78) hide show
  1. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/CHANGELOG.md +12 -0
  2. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/README.md +26 -4
  4. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/configuration.md +3 -1
  5. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/user-guide.md +26 -0
  6. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/app.py +183 -17
  9. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/cli.py +204 -11
  10. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/config.py +2 -0
  11. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/models.py +104 -5
  12. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/task_manager.py +173 -30
  13. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/create_modal.py +156 -6
  14. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/status_panel.py +55 -1
  15. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/task_list.py +139 -38
  16. tasktree_manager-2.15.0/tests/test_epic_tui.py +470 -0
  17. tasktree_manager-2.15.0/tests/test_epics.py +442 -0
  18. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  19. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  20. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  21. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  22. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  23. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  24. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  25. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  26. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  27. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  28. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  29. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.github/workflows/ci.yml +0 -0
  30. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.github/workflows/release.yml +0 -0
  31. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.gitignore +0 -0
  32. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/CONTRIBUTING.md +0 -0
  33. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/LICENSE +0 -0
  34. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/ROADMAP.md +0 -0
  35. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/claude-code-integration-spec.md +0 -0
  36. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/installation.md +0 -0
  37. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/troubleshooting.md +0 -0
  38. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/mise.toml +0 -0
  39. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/scripts/bump_version.py +0 -0
  40. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/__init__.py +0 -0
  41. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/commands.py +0 -0
  42. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/__init__.py +0 -0
  43. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/agent_sessions.py +0 -0
  44. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/claude_hooks.py +0 -0
  45. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/claude_sessions.py +0 -0
  46. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/forge.py +0 -0
  47. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/git_ops.py +0 -0
  48. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/themes.py +0 -0
  49. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/__init__.py +0 -0
  50. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/app_header.py +0 -0
  51. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  52. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  53. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  54. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/__init__.py +0 -0
  55. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/conftest.py +0 -0
  56. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_agent_sessions.py +0 -0
  57. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_agent_sessions_decode.py +0 -0
  58. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_app.py +0 -0
  59. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_app_review_fixes.py +0 -0
  60. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_bump_version.py +0 -0
  61. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_claude_hooks.py +0 -0
  62. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_claude_sessions.py +0 -0
  63. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_cli.py +0 -0
  64. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_commands.py +0 -0
  65. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_config.py +0 -0
  66. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_forge.py +0 -0
  67. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_git_ops.py +0 -0
  68. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_integration_git.py +0 -0
  69. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_list_selection.py +0 -0
  70. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_medium_fixes.py +0 -0
  71. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_memory.py +0 -0
  72. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_messages_panel.py +0 -0
  73. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_modals.py +0 -0
  74. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_safety_fixes.py +0 -0
  75. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_setup_modal.py +0 -0
  76. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_status_panel.py +0 -0
  77. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_task_manager.py +0 -0
  78. {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_worktree_badges.py +0 -0
@@ -15,6 +15,18 @@ 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.15.0] - 2026-10-19
19
+
20
+ ### Added
21
+ - **epic**: Show epics as foldable groups in the task list
22
+
23
+
24
+ ## [2.14.0] - 2026-10-19
25
+
26
+ ### Added
27
+ - **epic**: Group tasks under epics from the CLI
28
+
29
+
18
30
  ## [2.13.3] - 2026-10-16
19
31
 
20
32
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.13.3
3
+ Version: 2.15.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -22,6 +22,7 @@
22
22
  - 🛰️ **Agent Dashboard** - Live Claude Code session state per worktree (from `claude agents`), plus one-key background-agent dispatch — the multi-repo agent view Claude Code lacks natively
23
23
  - ✻ **Session Recap** - The Info panel shows each task's latest Claude Code session: title, last turn duration, and Claude's own idle recap of what was done and what comes next — read straight from the transcript, no CLI call
24
24
  - 🔀 **Forge-Aware Safety** - MR/PR and CI state via `glab`/`gh`; squash- and rebase-merged branches count as merged instead of blocking deletion forever
25
+ - 🗂️ **Epics** - Group related tasks under an epic: a foldable row with rolled-up dirty and Claude state, `E` to attach tasks
25
26
  - 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) and the task-folder files outside the worktrees (plans, ADRs, notes) to an archive before deleting
26
27
 
27
28
  ## 📊 Status Indicators
@@ -39,6 +40,8 @@
39
40
  | `↓N` yellow | N commits behind remote |
40
41
  | `○` green / `●` magenta / `×` red | MR open / merged / closed |
41
42
  | `◐` yellow / `✔` green / `✘` red | CI running / passed / failed |
43
+ | `▾` / `▸` | Epic, unfolded / folded (`· N` = its task count) |
44
+ | `├` / `└` | Task inside the epic above |
42
45
 
43
46
  ## 🚀 Quick Start
44
47
 
@@ -86,6 +89,23 @@ tasktree-manager finish DIC-1901-argocd-tls # safety sweep -> archive diff + t
86
89
  tasktree-manager delete DIC-1901-argocd-tls # archive diff + task files -> delete; refuses on unfinished work; --force overrides
87
90
  ```
88
91
 
92
+ Epics group related tasks, for example a Jira epic and its child tasks. An epic is a task
93
+ folder with a `.tasktree_kind` marker. It can hold its own notes, Claude session and
94
+ worktrees. A child task names its epic in `.tasktree_epic`. Folders never move:
95
+
96
+ ```bash
97
+ tasktree-manager epic create DIC-2466-pcaps-epic # --repos is optional for an epic
98
+ tasktree-manager create DIC-2861-pcaps-logs --repos infra --epic DIC-2466-pcaps-epic
99
+ tasktree-manager epic add DIC-2466-pcaps-epic DIC-2862-pcaps-s3 # attach or move existing tasks
100
+ tasktree-manager epic remove DIC-2862-pcaps-s3 # detach
101
+ tasktree-manager epic mark DIC-2466-pcaps-epic # turn an existing task into an epic; unmark reverts
102
+ tasktree-manager epic list # epics and their tasks; also --json
103
+ ```
104
+
105
+ Epics don't nest. `list --json` and `status --json` carry `kind` (`task` or `epic`) and
106
+ `epic` (the parent epic or `null`). `delete` and `finish` refuse an epic that still has
107
+ tasks, even with `--force`.
108
+
89
109
  Exit code is 0 on success, 1 on failure with the reason on stderr. `delete` and `finish` run
90
110
  the same safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion);
91
111
  with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
@@ -124,7 +144,8 @@ a Claude Code statusline or shell prompt.
124
144
  | `r` | Refresh | `s` | Sort tasks |
125
145
  | `S` | Group worktrees | `m` | Messages |
126
146
  | `t` | Cycle theme | `Tab` | Next panel |
127
- | `R` | Rename task | | |
147
+ | `R` | Rename task | `E` | Set epic |
148
+ | `enter` | Fold epic | | |
128
149
  | `?` | Show help | `q` | Quit |
129
150
 
130
151
  **Themes:** The default `tasktree` theme pairs a lazygit-classic ANSI palette with layered panels, numbered title bars, green focus borders, and a keycap footer. Five more design-system palettes ship tuned to the same shape — tokyo-night, catppuccin-mocha, gruvbox, dracula, nord — and every other Textual built-in theme works too (`Ctrl+P` to switch); the whole UI is styled with Textual design tokens, so any theme restyles it consistently.
@@ -206,10 +227,11 @@ For comprehensive troubleshooting, see the [Troubleshooting Guide](docs/troubles
206
227
  Recently shipped: agent dashboard (per-worktree Claude session badges + background dispatch),
207
228
  forge-aware merged detection (glab/gh, squash/rebase merges), MR/CI status badges,
208
229
  guided `finish` flow that archives the diff and the task-folder files, `status` subcommand
209
- for scripts and statuslines, Claude session recap in the Info panel.
230
+ for scripts and statuslines, Claude session recap in the Info panel, epics in the CLI and
231
+ the task list.
210
232
 
211
- Next: epics, a layer that groups related tasks (for example a Jira epic and its child
212
- tasks) in the task list.
233
+ Next: an epic context block in each task's CLAUDE.md, and epic support in the `/wtask`
234
+ skill.
213
235
 
214
236
  See [CHANGELOG.md](CHANGELOG.md) for the release history.
215
237
 
@@ -246,6 +246,7 @@ new_task = "n" # Create new task
246
246
  add_repo = "a" # Add repository to current task
247
247
  delete_task = "d" # Delete/finish task
248
248
  rename_task = "R" # Rename task display label (folder/branch unchanged, shift+r)
249
+ set_epic = "E" # Set the task's epic, or make the task an epic (shift+e)
249
250
  delete_worktree = "D" # Remove single worktree from task (shift+d)
250
251
 
251
252
  # Git operations
@@ -592,7 +593,7 @@ blocklist = ["*"]
592
593
 
593
594
  ## Keybindings Reference
594
595
 
595
- All 22 customizable keybindings with descriptions:
596
+ All 27 customizable keybindings with descriptions:
596
597
 
597
598
  ### Application Control
598
599
 
@@ -610,6 +611,7 @@ All 22 customizable keybindings with descriptions:
610
611
  | `add_repo` | `a` | Add repository to current task | `add_repo = "ctrl+a"` |
611
612
  | `delete_task` | `d` | Delete/finish current task | `delete_task = "delete"` |
612
613
  | `rename_task` | `R` | Rename task display label (alias only) | `rename_task = "ctrl+r"` |
614
+ | `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+e"` |
613
615
 
614
616
  ### Git Operations
615
617
 
@@ -65,6 +65,28 @@ Task: FEAT-123
65
65
  - Stored in `TASKS_DIR/task-name/`
66
66
  - Each task gets its own set of branches across selected repos
67
67
 
68
+ ### Epics
69
+
70
+ An **epic** groups related tasks, for example a Jira epic and its child tasks.
71
+ An epic is a task folder too: it can hold notes, a Claude session and its own
72
+ worktrees, or none. Child tasks keep their own folders; only a marker file
73
+ names their epic, so nothing moves on disk.
74
+
75
+ ```
76
+ ▾ ● DIC-2466-pcaps-epic · 3
77
+ ├ DIC-2861-pcaps-logs
78
+ ├ ● DIC-2862-pcaps-s3
79
+ └ DIC-2865-pcaps-docs
80
+ ```
81
+
82
+ - Press `E` on a task to add it to an epic, move it, remove it, create a new
83
+ empty epic for it, or turn the task itself into an epic.
84
+ - `n` and `y` preselect the epic of the highlighted row.
85
+ - `enter` or `space` folds an epic. A folded epic row still shows the dirty
86
+ count and the most urgent Claude state of its tasks.
87
+ - Epics don't nest. An epic that still has tasks can't be deleted.
88
+ - From scripts: `tasktree-manager epic create/add/remove/mark/unmark/list`.
89
+
68
90
  ### Worktrees
69
91
 
70
92
  A **worktree** is a git working directory for a specific repository within a task.
@@ -186,6 +208,8 @@ Shows all your tasks:
186
208
  - **CLAUDE.md indicator** `◆`: Blue diamond — task has a CLAUDE.md file
187
209
  - **Claude session status**: `⟳` running (magenta), `!` waiting (yellow), `✓` ended (green)
188
210
  - **Sort mode**: Press `s` to cycle through sort modes (name ↑/↓, date ↑/↓, dirty/clean first)
211
+ - **Epics**: `▾`/`▸` marks an epic with its task count; its tasks follow with `├`/`└`.
212
+ An epic sorts by its newest task and counts as dirty when any task is dirty
189
213
 
190
214
  **Navigation:**
191
215
  - `j/k` or arrow keys: Move up/down
@@ -318,6 +342,8 @@ what is displayed. Press `?` for the full reference at any time.
318
342
  | `a` | Add repository | Add additional repositories to the current task |
319
343
  | `d` | Delete/finish | Delete current task and remove worktrees |
320
344
  | `R` | Rename task | Set a display alias shown in the task list (folder/branch unchanged; empty input reverts) |
345
+ | `E` | Set epic | Add the task to an epic, move or remove it, or make it an epic |
346
+ | `enter`/`space` | Fold epic | Fold or unfold the highlighted epic (on a task, folds its epic) |
321
347
 
322
348
  **Task creation workflow:**
323
349
  1. Press `n`
@@ -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.15.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.15.0'
22
+ __version_tuple__ = version_tuple = (2, 15, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -24,7 +24,7 @@ from .services.claude_sessions import ClaudeRecap, read_claude_recap, transcript
24
24
  from .services.config import Config, ConfigError
25
25
  from .services.forge import Forge, ForgeStatus, get_forge_status
26
26
  from .services.git_ops import GitOps
27
- from .services.models import ArchiveResult, GitStatus, Task, TaskSafetyReport, Worktree
27
+ from .services.models import EPIC_KIND, ArchiveResult, GitStatus, Task, TaskSafetyReport, Worktree
28
28
  from .services.task_manager import ARCHIVE_NOTES_SHOWN, TaskManager
29
29
  from .themes import ALL_THEMES, THEME_CYCLE
30
30
  from .widgets.app_header import AppHeader
@@ -33,7 +33,9 @@ from .widgets.create_modal import (
33
33
  ConfirmModal,
34
34
  CreateTaskModal,
35
35
  DispatchAgentModal,
36
+ EpicPickerModal,
36
37
  HelpModal,
38
+ NewEpicModal,
37
39
  PushResultModal,
38
40
  RenameTaskModal,
39
41
  SafeDeleteModal,
@@ -199,6 +201,7 @@ class TaskTreeApp(App):
199
201
  Binding("y", "clone_task", "Clone", show=False),
200
202
  Binding("a", "add_repo", "Add Repo", show=False),
201
203
  Binding("d", "delete_task", "Delete", show=False),
204
+ Binding("E", "set_epic", "Epic", show=False),
202
205
  Binding("g", "open_lazygit", "Lazygit", show=False),
203
206
  Binding("h", "show_diff", "Diff", show=False),
204
207
  Binding("e", "open_editor", "Editor", show=False),
@@ -281,6 +284,7 @@ class TaskTreeApp(App):
281
284
  Binding(kb.get("add_repo", "a"), "add_repo", "Add Repo", show=False),
282
285
  Binding(kb.get("delete_task", "d"), "delete_task", "Delete", show=False),
283
286
  Binding(kb.get("rename_task", "R"), "rename_task", "Rename", show=False),
287
+ Binding(kb.get("set_epic", "E"), "set_epic", "Epic", show=False),
284
288
  Binding(kb.get("open_lazygit", "g"), "open_lazygit", "Lazygit", show=False),
285
289
  Binding(kb.get("show_diff", "h"), "show_diff", "Diff", show=False),
286
290
  Binding(kb.get("open_editor", "e"), "open_editor", "Editor", show=False),
@@ -347,6 +351,7 @@ class TaskTreeApp(App):
347
351
  (kb.get("add_repo", "a"), "add_repo", "Add Repo"),
348
352
  (kb.get("delete_task", "d"), "delete_task", "Delete"),
349
353
  (kb.get("rename_task", "R"), "rename_task", "Rename"),
354
+ (kb.get("set_epic", "E"), "set_epic", "Epic"),
350
355
  (kb.get("show_diff", "h"), "show_diff", "Diff"),
351
356
  (kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude"),
352
357
  (kb.get("cycle_sort", "s"), "cycle_sort", "Sort"),
@@ -373,7 +378,12 @@ class TaskTreeApp(App):
373
378
  id="task-panel-title",
374
379
  )
375
380
  yield Static("", classes="panel-counter", id="task-panel-counter")
376
- yield TaskList(id="task-list", context_bindings=task_panel_bindings)
381
+ yield TaskList(
382
+ id="task-list",
383
+ context_bindings=task_panel_bindings,
384
+ # A fold key bound to any action in config stays that action
385
+ fold_keys=tuple(k for k in ("enter", "space") if k not in kb.values()),
386
+ )
377
387
  with Vertical(id="worktree-panel"):
378
388
  with Horizontal(classes="panel-title-bar"):
379
389
  yield Static(
@@ -591,7 +601,7 @@ class TaskTreeApp(App):
591
601
  follow-up scan can skip its UI reload when nothing actually changed.
592
602
  """
593
603
  try:
594
- previous = self.query_one("#task-list", TaskList).tasks
604
+ previous = self.query_one("#task-list", TaskList).all_tasks
595
605
  except Exception:
596
606
  return
597
607
  known = {(task.name, wt.name): wt for task in previous for wt in task.worktrees}
@@ -645,7 +655,8 @@ class TaskTreeApp(App):
645
655
  return
646
656
 
647
657
  statuses: dict[str, str] = {}
648
- for task in task_list.tasks:
658
+ # all_tasks: a folded child still feeds its epic row rollup
659
+ for task in task_list.all_tasks:
649
660
  status_file = task.path / ".claude_status"
650
661
  if not status_file.exists():
651
662
  continue
@@ -659,7 +670,9 @@ class TaskTreeApp(App):
659
670
  # Valid JSON but not an object (e.g. "[]") - .get() below
660
671
  # would raise AttributeError and kill the poll interval
661
672
  continue
662
- statuses[task.name] = data.get("status", "unknown")
673
+ status = data.get("status", "unknown")
674
+ if isinstance(status, str):
675
+ statuses[task.name] = status
663
676
 
664
677
  if statuses != self._claude_statuses:
665
678
  self._claude_statuses = statuses
@@ -670,6 +683,9 @@ class TaskTreeApp(App):
670
683
  except Exception:
671
684
  return
672
685
  status_panel.set_claude_live_status(statuses.get(self.current_task.name))
686
+ if self.current_task.is_epic:
687
+ children, _ = self._epic_context(self.current_task, task_list)
688
+ status_panel.set_epic_children(self.current_task.name, children or [])
673
689
 
674
690
  # Same tick keeps the recap fresh: a stat per poll, a read only on change
675
691
  if self.config.claude_recap and self.current_task and task_list.has_focus:
@@ -719,7 +735,7 @@ class TaskTreeApp(App):
719
735
  task_list = self.query_one("#task-list", TaskList)
720
736
  except Exception:
721
737
  return
722
- paths = [str(wt.path) for task in task_list.tasks for wt in task.worktrees]
738
+ paths = [str(wt.path) for task in task_list.all_tasks for wt in task.worktrees]
723
739
  if paths:
724
740
  self._run_agent_poll(paths)
725
741
 
@@ -850,6 +866,8 @@ class TaskTreeApp(App):
850
866
  return tuple(
851
867
  (
852
868
  task.name,
869
+ task.kind,
870
+ task.epic_name,
853
871
  tuple((wt.name, wt.branch, wt.is_dirty, wt.changed_files) for wt in task.worktrees),
854
872
  )
855
873
  for task in tasks
@@ -1131,10 +1149,46 @@ class TaskTreeApp(App):
1131
1149
  status_panel = self.query_one("#status-display", StatusPanel)
1132
1150
  task_list = self.query_one("#task-list", TaskList)
1133
1151
  if task_list.has_focus:
1134
- status_panel.update_task_summary(task, statuses)
1152
+ children, parent = self._epic_context(task, task_list)
1153
+ status_panel.update_task_summary(
1154
+ task, statuses, epic_children=children, epic_label=parent
1155
+ )
1135
1156
  except Exception:
1136
1157
  pass
1137
1158
 
1159
+ def _epic_context(
1160
+ self, task: Task, task_list: TaskList
1161
+ ) -> tuple[list[tuple[str, bool, str | None]] | None, str | None]:
1162
+ """The Info panel epic block (children) or parent line for a task."""
1163
+ if task.is_epic:
1164
+ children = [
1165
+ (child.display_label, child.is_dirty, self._claude_statuses.get(child.name))
1166
+ for child in task_list.epic_children(task.name)
1167
+ ]
1168
+ return children, None
1169
+ epic_name = task_list.epic_of(task.name)
1170
+ if epic_name is None:
1171
+ return None, None
1172
+ parent = next(t for t in task_list.all_tasks if t.name == epic_name)
1173
+ return None, parent.display_label
1174
+
1175
+ def _epic_choices(self) -> list[tuple[str, str]]:
1176
+ """(label, name) of every loaded epic, sorted by label."""
1177
+ try:
1178
+ tasks = self.query_one("#task-list", TaskList).all_tasks
1179
+ except Exception:
1180
+ return []
1181
+ epics = [(task.display_label, task.name) for task in tasks if task.is_epic]
1182
+ return sorted(epics, key=lambda pair: pair[0].lower())
1183
+
1184
+ def _epic_of(self, task: Task | None) -> str | None:
1185
+ """The epic a new task next to `task` should join: itself or its parent."""
1186
+ if task is None:
1187
+ return None
1188
+ if task.is_epic:
1189
+ return task.name
1190
+ return self.query_one("#task-list", TaskList).epic_of(task.name)
1191
+
1138
1192
  def action_quit(self) -> None:
1139
1193
  """Quit the application."""
1140
1194
  self.exit()
@@ -1168,16 +1222,23 @@ class TaskTreeApp(App):
1168
1222
 
1169
1223
  def handle_result(result):
1170
1224
  if result and self._begin_mutation("task-list"):
1171
- name, repos, base_branch = result
1172
- self._create_task_worker(name, repos, base_branch, verb="created")
1225
+ name, repos, base_branch, epic = result
1226
+ self._create_task_worker(name, repos, base_branch, verb="created", epic=epic)
1173
1227
 
1174
1228
  self.push_screen(
1175
- CreateTaskModal(available_repos, initial_base_branch=self.config.default_base_branch),
1229
+ CreateTaskModal(
1230
+ available_repos,
1231
+ initial_base_branch=self.config.default_base_branch,
1232
+ epics=self._epic_choices(),
1233
+ initial_epic=self._epic_of(self.current_task),
1234
+ ),
1176
1235
  handle_result,
1177
1236
  )
1178
1237
 
1179
1238
  @work(thread=True, group="task_mutation")
1180
- def _create_task_worker(self, name: str, repos: list[str], base_branch: str, verb: str) -> None:
1239
+ def _create_task_worker(
1240
+ self, name: str, repos: list[str], base_branch: str, verb: str, epic: str | None = None
1241
+ ) -> None:
1181
1242
  """Create a task in a background thread.
1182
1243
 
1183
1244
  Creation fetches the base branch from origin for every repo, so it
@@ -1186,7 +1247,7 @@ class TaskTreeApp(App):
1186
1247
  """
1187
1248
  error: str | None = None
1188
1249
  try:
1189
- self.task_manager.create_task(name, repos, base_branch)
1250
+ self.task_manager.create_task(name, repos, base_branch, epic=epic)
1190
1251
  except FileNotFoundError as e:
1191
1252
  error = f"Repository not found: {e}"
1192
1253
  except PermissionError:
@@ -1196,10 +1257,10 @@ class TaskTreeApp(App):
1196
1257
  error = str(e)
1197
1258
  except Exception as e:
1198
1259
  error = f"{type(e).__name__}: {e}"
1199
- self.call_from_thread(self._apply_create_task_result, name, len(repos), verb, error)
1260
+ self.call_from_thread(self._apply_create_task_result, name, len(repos), verb, error, epic)
1200
1261
 
1201
1262
  def _apply_create_task_result(
1202
- self, name: str, repo_count: int, verb: str, error: str | None
1263
+ self, name: str, repo_count: int, verb: str, error: str | None, epic: str | None = None
1203
1264
  ) -> None:
1204
1265
  """Report the create/clone outcome and reload (runs on the main thread)."""
1205
1266
  if error:
@@ -1208,8 +1269,11 @@ class TaskTreeApp(App):
1208
1269
  self._load_tasks()
1209
1270
  else:
1210
1271
  self.notify(escape(f"Created task: {name}"))
1272
+ in_epic = f" in epic '{epic}'" if epic else ""
1211
1273
  self._log_activity(
1212
- f"Task '{name}' {verb} with {repo_count} repo(s)", MessageLevel.SUCCESS, name
1274
+ f"Task '{name}' {verb} with {repo_count} repo(s){in_epic}",
1275
+ MessageLevel.SUCCESS,
1276
+ name,
1213
1277
  )
1214
1278
  self._load_tasks(select_task=name)
1215
1279
 
@@ -1228,8 +1292,8 @@ class TaskTreeApp(App):
1228
1292
 
1229
1293
  def handle_result(result):
1230
1294
  if result and self._begin_mutation("task-list"):
1231
- name, repos, base_branch = result
1232
- self._create_task_worker(name, repos, base_branch, verb="cloned")
1295
+ name, repos, base_branch, epic = result
1296
+ self._create_task_worker(name, repos, base_branch, verb="cloned", epic=epic)
1233
1297
 
1234
1298
  self.push_screen(
1235
1299
  CreateTaskModal(
@@ -1237,6 +1301,8 @@ class TaskTreeApp(App):
1237
1301
  initial_repos=source_repos,
1238
1302
  initial_base_branch=self.config.default_base_branch,
1239
1303
  title=f"Clone Task: {self.current_task.name}",
1304
+ epics=self._epic_choices(),
1305
+ initial_epic=self._epic_of(self.current_task),
1240
1306
  ),
1241
1307
  handle_result,
1242
1308
  )
@@ -1300,6 +1366,16 @@ class TaskTreeApp(App):
1300
1366
  if not self.current_task:
1301
1367
  self.notify("No task selected", severity="warning")
1302
1368
  return
1369
+ if self.current_task.is_epic:
1370
+ children = self.task_manager.epic_child_names(self.current_task.name)
1371
+ if children:
1372
+ self.notify(
1373
+ escape(
1374
+ f"Epic '{self.current_task.name}' still has tasks: {', '.join(children)}"
1375
+ ),
1376
+ severity="warning",
1377
+ )
1378
+ return
1303
1379
 
1304
1380
  # Safety checks hit the network (git fetch per worktree), so run them
1305
1381
  # in a background thread and show the dialog when they complete
@@ -1366,6 +1442,87 @@ class TaskTreeApp(App):
1366
1442
 
1367
1443
  self.push_screen(RenameTaskModal(task.name, task.display_name), handle_rename)
1368
1444
 
1445
+ def action_set_epic(self) -> None:
1446
+ """Attach the current task to an epic, detach it, or make it an epic."""
1447
+ task = self.current_task
1448
+ if not task:
1449
+ self.notify("No task selected", severity="warning")
1450
+ return
1451
+ if task.is_epic:
1452
+ self.notify("Epics don't nest: an epic can't join another epic", severity="warning")
1453
+ return
1454
+ epics = [(label, name) for label, name in self._epic_choices() if name != task.name]
1455
+
1456
+ def handle_choice(choice: tuple[str, str | None] | None) -> None:
1457
+ if choice is None:
1458
+ return
1459
+ action, epic = choice
1460
+ if action == "new":
1461
+ self.push_screen(
1462
+ NewEpicModal(task.name),
1463
+ lambda name: self._apply_epic_choice(task, "new", name) if name else None,
1464
+ )
1465
+ else:
1466
+ self._apply_epic_choice(task, action, epic)
1467
+
1468
+ self.push_screen(
1469
+ EpicPickerModal(
1470
+ task.display_label,
1471
+ epics,
1472
+ self.query_one("#task-list", TaskList).epic_of(task.name),
1473
+ task.epic_name is None,
1474
+ ),
1475
+ handle_choice,
1476
+ )
1477
+
1478
+ def _apply_epic_choice(self, task: Task, action: str, epic: str | None) -> None:
1479
+ """Write the picked epic change; marker writes are local and fast."""
1480
+ if self._mutation_in_flight():
1481
+ # A delete worker may be rmtree-ing the very folders written here
1482
+ self.notify("Another task operation is still running", severity="warning")
1483
+ return
1484
+ previous = task.epic_name
1485
+ try:
1486
+ if action == "new" and epic:
1487
+ self.task_manager.create_task(epic, [], kind=EPIC_KIND)
1488
+ try:
1489
+ epic = self.task_manager.set_task_epic(task, epic)
1490
+ except (ValueError, OSError) as e:
1491
+ # The epic exists now; show it and say what did not happen
1492
+ self.notify(
1493
+ escape(f"Created epic '{epic}' but could not add '{task.name}': {e}"),
1494
+ severity="warning",
1495
+ )
1496
+ self._load_tasks(select_task=epic)
1497
+ return
1498
+ message = f"Created epic '{epic}' and added '{task.name}' to it"
1499
+ elif action == "set" and epic:
1500
+ epic = self.task_manager.set_task_epic(task, epic)
1501
+ if previous == epic:
1502
+ message = f"Task '{task.name}' already in epic '{epic}'"
1503
+ elif previous:
1504
+ message = f"Moved '{task.name}' from epic '{previous}' to '{epic}'"
1505
+ else:
1506
+ message = f"Added '{task.name}' to epic '{epic}'"
1507
+ elif action == "none":
1508
+ self.task_manager.set_task_epic(task, None)
1509
+ message = (
1510
+ f"Removed '{task.name}' from epic '{previous}'"
1511
+ if previous
1512
+ else f"Task '{task.name}' is not in an epic"
1513
+ )
1514
+ elif action == "mark":
1515
+ self.task_manager.set_task_kind(task, EPIC_KIND)
1516
+ message = f"Task '{task.name}' is an epic now"
1517
+ else:
1518
+ return
1519
+ except (ValueError, OSError) as e:
1520
+ self.notify(escape(f"Epic change failed: {e}"), severity="error")
1521
+ return
1522
+ self.notify(escape(message))
1523
+ self._log_activity(message, MessageLevel.INFO, task.name)
1524
+ self._load_tasks(select_task=task.name)
1525
+
1369
1526
  def _finish_task(self, task: Task, force: bool = False) -> None:
1370
1527
  """Kick off task deletion in a background thread."""
1371
1528
  if not self._begin_mutation("task-list"):
@@ -1821,6 +1978,9 @@ class TaskTreeApp(App):
1821
1978
  return
1822
1979
 
1823
1980
  task = self.current_task
1981
+ if not task.worktrees:
1982
+ self.notify("Task has no worktrees", severity="information")
1983
+ return
1824
1984
 
1825
1985
  # Building the combined diff is local git work, but it scales with
1826
1986
  # the size of the trees (one add + diff per worktree), so it runs in
@@ -2252,6 +2412,9 @@ class TaskTreeApp(App):
2252
2412
  # that's disappearing under it.
2253
2413
  self.notify("Another task operation is still running", severity="warning")
2254
2414
  return
2415
+ if not self.current_task.worktrees:
2416
+ self.notify("Task has no worktrees", severity="information")
2417
+ return
2255
2418
 
2256
2419
  worktree_list = self.query_one("#worktree-list", WorktreeList)
2257
2420
  worktree_list.loading = True
@@ -2275,6 +2438,9 @@ class TaskTreeApp(App):
2275
2438
  if self._mutation_in_flight():
2276
2439
  self.notify("Another task operation is still running", severity="warning")
2277
2440
  return
2441
+ if not self.current_task.worktrees:
2442
+ self.notify("Task has no worktrees", severity="information")
2443
+ return
2278
2444
 
2279
2445
  worktree_list = self.query_one("#worktree-list", WorktreeList)
2280
2446
  worktree_list.loading = True