tasktree-manager 2.14.0__tar.gz → 2.16.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 (79) hide show
  1. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/CHANGELOG.md +12 -0
  2. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/README.md +11 -5
  4. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/docs/configuration.md +3 -1
  5. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/docs/user-guide.md +30 -0
  6. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/app.py +177 -17
  9. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/cli.py +12 -0
  10. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/config.py +2 -0
  11. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/models.py +39 -0
  12. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/task_manager.py +147 -11
  13. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/create_modal.py +156 -6
  14. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/status_panel.py +55 -1
  15. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/task_list.py +139 -38
  16. tasktree_manager-2.16.0/tests/test_epic_claude_md.py +176 -0
  17. tasktree_manager-2.16.0/tests/test_epic_tui.py +470 -0
  18. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_epics.py +12 -0
  19. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  20. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  21. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  22. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  23. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  24. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  25. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  26. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  27. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  28. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  29. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  30. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.github/workflows/ci.yml +0 -0
  31. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.github/workflows/release.yml +0 -0
  32. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/.gitignore +0 -0
  33. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/CONTRIBUTING.md +0 -0
  34. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/LICENSE +0 -0
  35. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/ROADMAP.md +0 -0
  36. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/docs/claude-code-integration-spec.md +0 -0
  37. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/docs/installation.md +0 -0
  38. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/docs/troubleshooting.md +0 -0
  39. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/mise.toml +0 -0
  40. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/scripts/bump_version.py +0 -0
  41. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/__init__.py +0 -0
  42. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/commands.py +0 -0
  43. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/__init__.py +0 -0
  44. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/agent_sessions.py +0 -0
  45. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/claude_hooks.py +0 -0
  46. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/claude_sessions.py +0 -0
  47. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/forge.py +0 -0
  48. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/services/git_ops.py +0 -0
  49. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/themes.py +0 -0
  50. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/__init__.py +0 -0
  51. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/app_header.py +0 -0
  52. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  53. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  54. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  55. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/__init__.py +0 -0
  56. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/conftest.py +0 -0
  57. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_agent_sessions.py +0 -0
  58. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_agent_sessions_decode.py +0 -0
  59. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_app.py +0 -0
  60. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_app_review_fixes.py +0 -0
  61. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_bump_version.py +0 -0
  62. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_claude_hooks.py +0 -0
  63. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_claude_sessions.py +0 -0
  64. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_cli.py +0 -0
  65. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_commands.py +0 -0
  66. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_config.py +0 -0
  67. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_forge.py +0 -0
  68. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_git_ops.py +0 -0
  69. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_integration_git.py +0 -0
  70. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_list_selection.py +0 -0
  71. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_medium_fixes.py +0 -0
  72. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_memory.py +0 -0
  73. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_messages_panel.py +0 -0
  74. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_modals.py +0 -0
  75. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_safety_fixes.py +0 -0
  76. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_setup_modal.py +0 -0
  77. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_status_panel.py +0 -0
  78. {tasktree_manager-2.14.0 → tasktree_manager-2.16.0}/tests/test_task_manager.py +0 -0
  79. {tasktree_manager-2.14.0 → tasktree_manager-2.16.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.16.0] - 2026-10-19
19
+
20
+ ### Added
21
+ - **epic**: Add a managed epic block to task CLAUDE.md files
22
+
23
+
24
+ ## [2.15.0] - 2026-10-19
25
+
26
+ ### Added
27
+ - **epic**: Show epics as foldable groups in the task list
28
+
29
+
18
30
  ## [2.14.0] - 2026-10-19
19
31
 
20
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.14.0
3
+ Version: 2.16.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
 
@@ -99,7 +102,9 @@ tasktree-manager epic mark DIC-2466-pcaps-epic # turn an existing
99
102
  tasktree-manager epic list # epics and their tasks; also --json
100
103
  ```
101
104
 
102
- Epics don't nest. `list --json` and `status --json` carry `kind` (`task` or `epic`) and
105
+ Each task's `CLAUDE.md` gets a managed block naming its epic and how to find sibling
106
+ tasks; it follows every epic change and leaves the rest of the file alone. Epics don't
107
+ nest. `list --json` and `status --json` carry `kind` (`task` or `epic`) and
103
108
  `epic` (the parent epic or `null`). `delete` and `finish` refuse an epic that still has
104
109
  tasks, even with `--force`.
105
110
 
@@ -141,7 +146,8 @@ a Claude Code statusline or shell prompt.
141
146
  | `r` | Refresh | `s` | Sort tasks |
142
147
  | `S` | Group worktrees | `m` | Messages |
143
148
  | `t` | Cycle theme | `Tab` | Next panel |
144
- | `R` | Rename task | | |
149
+ | `R` | Rename task | `E` | Set epic |
150
+ | `enter` | Fold epic | | |
145
151
  | `?` | Show help | `q` | Quit |
146
152
 
147
153
  **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.
@@ -223,10 +229,10 @@ For comprehensive troubleshooting, see the [Troubleshooting Guide](docs/troubles
223
229
  Recently shipped: agent dashboard (per-worktree Claude session badges + background dispatch),
224
230
  forge-aware merged detection (glab/gh, squash/rebase merges), MR/CI status badges,
225
231
  guided `finish` flow that archives the diff and the task-folder files, `status` subcommand
226
- for scripts and statuslines, Claude session recap in the Info panel.
232
+ for scripts and statuslines, Claude session recap in the Info panel, epics in the CLI, the
233
+ task list and each task's CLAUDE.md.
227
234
 
228
- Next: epics, a layer that groups related tasks (for example a Jira epic and its child
229
- tasks) in the task list.
235
+ Ideas and bugs go to [GitHub Issues](https://github.com/ruslanfialkovskii/tasktree-manager/issues).
230
236
 
231
237
  See [CHANGELOG.md](CHANGELOG.md) for the release history.
232
238
 
@@ -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,32 @@ 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
+ - Each task's `CLAUDE.md` gets a short block between `<!-- tasktree:epic -->` markers
89
+ that names the epic folder and how to find sibling tasks, so a Claude session knows
90
+ the bigger picture. It updates on every epic change; text outside the markers is
91
+ never touched.
92
+ - From scripts: `tasktree-manager epic create/add/remove/mark/unmark/list`.
93
+
68
94
  ### Worktrees
69
95
 
70
96
  A **worktree** is a git working directory for a specific repository within a task.
@@ -186,6 +212,8 @@ Shows all your tasks:
186
212
  - **CLAUDE.md indicator** `◆`: Blue diamond — task has a CLAUDE.md file
187
213
  - **Claude session status**: `⟳` running (magenta), `!` waiting (yellow), `✓` ended (green)
188
214
  - **Sort mode**: Press `s` to cycle through sort modes (name ↑/↓, date ↑/↓, dirty/clean first)
215
+ - **Epics**: `▾`/`▸` marks an epic with its task count; its tasks follow with `├`/`└`.
216
+ An epic sorts by its newest task and counts as dirty when any task is dirty
189
217
 
190
218
  **Navigation:**
191
219
  - `j/k` or arrow keys: Move up/down
@@ -318,6 +346,8 @@ what is displayed. Press `?` for the full reference at any time.
318
346
  | `a` | Add repository | Add additional repositories to the current task |
319
347
  | `d` | Delete/finish | Delete current task and remove worktrees |
320
348
  | `R` | Rename task | Set a display alias shown in the task list (folder/branch unchanged; empty input reverts) |
349
+ | `E` | Set epic | Add the task to an epic, move or remove it, or make it an epic |
350
+ | `enter`/`space` | Fold epic | Fold or unfold the highlighted epic (on a task, folds its epic) |
321
351
 
322
352
  **Task creation workflow:**
323
353
  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.14.0"
74
+ version = "2.16.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.14.0'
22
- __version_tuple__ = version_tuple = (2, 14, 0)
21
+ __version__ = version = '2.16.0'
22
+ __version_tuple__ = version_tuple = (2, 16, 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
  )
@@ -1376,6 +1442,91 @@ class TaskTreeApp(App):
1376
1442
 
1377
1443
  self.push_screen(RenameTaskModal(task.name, task.display_name), handle_rename)
1378
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
+ problem = self.task_manager.sync_epic_block(task)
1525
+ if problem:
1526
+ self.notify(escape(problem), severity="warning")
1527
+ self._log_activity(problem, MessageLevel.WARNING, task.name)
1528
+ self._load_tasks(select_task=task.name)
1529
+
1379
1530
  def _finish_task(self, task: Task, force: bool = False) -> None:
1380
1531
  """Kick off task deletion in a background thread."""
1381
1532
  if not self._begin_mutation("task-list"):
@@ -1831,6 +1982,9 @@ class TaskTreeApp(App):
1831
1982
  return
1832
1983
 
1833
1984
  task = self.current_task
1985
+ if not task.worktrees:
1986
+ self.notify("Task has no worktrees", severity="information")
1987
+ return
1834
1988
 
1835
1989
  # Building the combined diff is local git work, but it scales with
1836
1990
  # the size of the trees (one add + diff per worktree), so it runs in
@@ -2262,6 +2416,9 @@ class TaskTreeApp(App):
2262
2416
  # that's disappearing under it.
2263
2417
  self.notify("Another task operation is still running", severity="warning")
2264
2418
  return
2419
+ if not self.current_task.worktrees:
2420
+ self.notify("Task has no worktrees", severity="information")
2421
+ return
2265
2422
 
2266
2423
  worktree_list = self.query_one("#worktree-list", WorktreeList)
2267
2424
  worktree_list.loading = True
@@ -2285,6 +2442,9 @@ class TaskTreeApp(App):
2285
2442
  if self._mutation_in_flight():
2286
2443
  self.notify("Another task operation is still running", severity="warning")
2287
2444
  return
2445
+ if not self.current_task.worktrees:
2446
+ self.notify("Task has no worktrees", severity="information")
2447
+ return
2288
2448
 
2289
2449
  worktree_list = self.query_one("#worktree-list", WorktreeList)
2290
2450
  worktree_list.loading = True
@@ -213,9 +213,17 @@ def _create(
213
213
  if task.epic_name is not None:
214
214
  line += f" in epic {task.epic_name}"
215
215
  print(line)
216
+ _warn_epic_block(manager, task)
216
217
  return 0
217
218
 
218
219
 
220
+ def _warn_epic_block(manager: TaskManager, task: Task) -> None:
221
+ """Print why the task's CLAUDE.md epic block could not follow a change."""
222
+ problem = manager.sync_epic_block(task)
223
+ if problem:
224
+ print(f"warning: {problem}", file=sys.stderr)
225
+
226
+
219
227
  def _get_tasks(manager: TaskManager, names: list[str]) -> list[Task] | None:
220
228
  """Resolve task names, printing an error for each missing one (None if any)."""
221
229
  tasks = []
@@ -250,6 +258,7 @@ def cmd_epic_add(manager: TaskManager, config: Config, args: argparse.Namespace)
250
258
  print(f"Added {task.name} to epic {epic}")
251
259
  else:
252
260
  print(f"Moved {task.name} from epic {previous} to epic {epic}")
261
+ _warn_epic_block(manager, task)
253
262
  return code
254
263
 
255
264
 
@@ -265,6 +274,7 @@ def cmd_epic_remove(manager: TaskManager, config: Config, args: argparse.Namespa
265
274
  print(f"Task {task.name} is not in an epic")
266
275
  else:
267
276
  print(f"Removed {task.name} from epic {previous}")
277
+ _warn_epic_block(manager, task)
268
278
  return 0
269
279
 
270
280
 
@@ -278,6 +288,7 @@ def cmd_epic_mark(manager: TaskManager, config: Config, args: argparse.Namespace
278
288
  return 0
279
289
  manager.set_task_kind(task, EPIC_KIND)
280
290
  print(f"Marked {args.name} as an epic")
291
+ _warn_epic_block(manager, task)
281
292
  return 0
282
293
 
283
294
 
@@ -291,6 +302,7 @@ def cmd_epic_unmark(manager: TaskManager, config: Config, args: argparse.Namespa
291
302
  return 0
292
303
  manager.set_task_kind(task, TASK_KIND)
293
304
  print(f"Unmarked {args.name}: it is a plain task now")
305
+ _warn_epic_block(manager, task)
294
306
  return 0
295
307
 
296
308
 
@@ -26,6 +26,7 @@ DEFAULT_KEYBINDINGS: dict[str, str] = {
26
26
  "add_repo": "a",
27
27
  "delete_task": "d",
28
28
  "rename_task": "R",
29
+ "set_epic": "E",
29
30
  "open_lazygit": "g",
30
31
  "show_diff": "h",
31
32
  "open_folder": "o",
@@ -509,6 +510,7 @@ clone_task = "{self._toml_escape(self.keybindings.get("clone_task", "y"))}"
509
510
  add_repo = "{self._toml_escape(self.keybindings.get("add_repo", "a"))}"
510
511
  delete_task = "{self._toml_escape(self.keybindings.get("delete_task", "d"))}"
511
512
  rename_task = "{self._toml_escape(self.keybindings.get("rename_task", "R"))}"
513
+ set_epic = "{self._toml_escape(self.keybindings.get("set_epic", "E"))}"
512
514
  open_lazygit = "{self._toml_escape(self.keybindings.get("open_lazygit", "g"))}"
513
515
  show_diff = "{self._toml_escape(self.keybindings.get("show_diff", "h"))}"
514
516
  open_folder = "{self._toml_escape(self.keybindings.get("open_folder", "o"))}"
@@ -1,5 +1,6 @@
1
1
  """Data models for tasktree-manager."""
2
2
 
3
+ import os
3
4
  import re
4
5
  from dataclasses import dataclass, field
5
6
  from pathlib import Path
@@ -127,6 +128,44 @@ class Task:
127
128
  return value
128
129
 
129
130
 
131
+ def resolve_epic(task: Task, epics: dict[str, Task]) -> str | None:
132
+ """The epic in `epics` that the task's marker names, or None.
133
+
134
+ A marker spelled in another case matches only when it opens the same
135
+ folder, as on a case-insensitive disk.
136
+ """
137
+ parent = task.epic_name
138
+ if parent is None or parent in epics:
139
+ return parent
140
+ for name, epic in epics.items():
141
+ if name.casefold() == parent.casefold():
142
+ try:
143
+ if os.path.samefile(task.path.parent / parent, epic.path):
144
+ return name
145
+ except OSError:
146
+ pass
147
+ return None
148
+
149
+
150
+ def group_by_epic(tasks: list[Task]) -> tuple[list[Task], dict[str, list[Task]]]:
151
+ """Split tasks into top-level rows and the children of each epic.
152
+
153
+ A task is a child when its marker names an epic in `tasks`, by the same
154
+ rule as TaskManager.epic_child_names (the delete guard). Anything else, an
155
+ orphan included, is top-level. Order within both is kept.
156
+ """
157
+ epics = {task.name: task for task in tasks if task.is_epic}
158
+ children: dict[str, list[Task]] = {name: [] for name in epics}
159
+ top: list[Task] = []
160
+ for task in tasks:
161
+ parent = None if task.name in epics else resolve_epic(task, epics)
162
+ if parent is None:
163
+ top.append(task)
164
+ else:
165
+ children[parent].append(task)
166
+ return top, children
167
+
168
+
130
169
  @dataclass
131
170
  class GitStatus:
132
171
  """Represents the git status of a worktree."""