tasktree-manager 2.14.0__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.14.0 → tasktree_manager-2.15.0}/CHANGELOG.md +6 -0
  2. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/README.md +9 -4
  4. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/configuration.md +3 -1
  5. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/user-guide.md +26 -0
  6. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/app.py +173 -17
  9. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/config.py +2 -0
  10. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/models.py +39 -0
  11. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/task_manager.py +6 -3
  12. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/create_modal.py +156 -6
  13. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/status_panel.py +55 -1
  14. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/task_list.py +139 -38
  15. tasktree_manager-2.15.0/tests/test_epic_tui.py +470 -0
  16. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_epics.py +12 -0
  17. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  18. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  19. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  20. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  21. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  22. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  23. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  24. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  25. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  26. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  27. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  28. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.github/workflows/ci.yml +0 -0
  29. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.github/workflows/release.yml +0 -0
  30. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.gitignore +0 -0
  31. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/CONTRIBUTING.md +0 -0
  32. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/LICENSE +0 -0
  33. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/ROADMAP.md +0 -0
  34. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/claude-code-integration-spec.md +0 -0
  35. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/installation.md +0 -0
  36. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/troubleshooting.md +0 -0
  37. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/mise.toml +0 -0
  38. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/scripts/bump_version.py +0 -0
  39. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/__init__.py +0 -0
  40. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/cli.py +0 -0
  41. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/commands.py +0 -0
  42. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/__init__.py +0 -0
  43. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/agent_sessions.py +0 -0
  44. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/claude_hooks.py +0 -0
  45. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/claude_sessions.py +0 -0
  46. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/forge.py +0 -0
  47. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/git_ops.py +0 -0
  48. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/themes.py +0 -0
  49. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/__init__.py +0 -0
  50. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/app_header.py +0 -0
  51. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  52. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  53. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  54. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/__init__.py +0 -0
  55. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/conftest.py +0 -0
  56. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_agent_sessions.py +0 -0
  57. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_agent_sessions_decode.py +0 -0
  58. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_app.py +0 -0
  59. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_app_review_fixes.py +0 -0
  60. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_bump_version.py +0 -0
  61. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_claude_hooks.py +0 -0
  62. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_claude_sessions.py +0 -0
  63. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_cli.py +0 -0
  64. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_commands.py +0 -0
  65. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_config.py +0 -0
  66. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_forge.py +0 -0
  67. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_git_ops.py +0 -0
  68. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_integration_git.py +0 -0
  69. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_list_selection.py +0 -0
  70. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_medium_fixes.py +0 -0
  71. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_memory.py +0 -0
  72. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_messages_panel.py +0 -0
  73. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_modals.py +0 -0
  74. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_safety_fixes.py +0 -0
  75. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_setup_modal.py +0 -0
  76. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_status_panel.py +0 -0
  77. {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_task_manager.py +0 -0
  78. {tasktree_manager-2.14.0 → tasktree_manager-2.15.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.15.0] - 2026-10-19
19
+
20
+ ### Added
21
+ - **epic**: Show epics as foldable groups in the task list
22
+
23
+
18
24
  ## [2.14.0] - 2026-10-19
19
25
 
20
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.14.0
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
 
@@ -141,7 +144,8 @@ a Claude Code statusline or shell prompt.
141
144
  | `r` | Refresh | `s` | Sort tasks |
142
145
  | `S` | Group worktrees | `m` | Messages |
143
146
  | `t` | Cycle theme | `Tab` | Next panel |
144
- | `R` | Rename task | | |
147
+ | `R` | Rename task | `E` | Set epic |
148
+ | `enter` | Fold epic | | |
145
149
  | `?` | Show help | `q` | Quit |
146
150
 
147
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.
@@ -223,10 +227,11 @@ For comprehensive troubleshooting, see the [Troubleshooting Guide](docs/troubles
223
227
  Recently shipped: agent dashboard (per-worktree Claude session badges + background dispatch),
224
228
  forge-aware merged detection (glab/gh, squash/rebase merges), MR/CI status badges,
225
229
  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.
230
+ for scripts and statuslines, Claude session recap in the Info panel, epics in the CLI and
231
+ the task list.
227
232
 
228
- Next: epics, a layer that groups related tasks (for example a Jira epic and its child
229
- 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.
230
235
 
231
236
  See [CHANGELOG.md](CHANGELOG.md) for the release history.
232
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.14.0"
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.14.0'
22
- __version_tuple__ = version_tuple = (2, 14, 0)
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
  )
@@ -1376,6 +1442,87 @@ 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
+ self._load_tasks(select_task=task.name)
1525
+
1379
1526
  def _finish_task(self, task: Task, force: bool = False) -> None:
1380
1527
  """Kick off task deletion in a background thread."""
1381
1528
  if not self._begin_mutation("task-list"):
@@ -1831,6 +1978,9 @@ class TaskTreeApp(App):
1831
1978
  return
1832
1979
 
1833
1980
  task = self.current_task
1981
+ if not task.worktrees:
1982
+ self.notify("Task has no worktrees", severity="information")
1983
+ return
1834
1984
 
1835
1985
  # Building the combined diff is local git work, but it scales with
1836
1986
  # the size of the trees (one add + diff per worktree), so it runs in
@@ -2262,6 +2412,9 @@ class TaskTreeApp(App):
2262
2412
  # that's disappearing under it.
2263
2413
  self.notify("Another task operation is still running", severity="warning")
2264
2414
  return
2415
+ if not self.current_task.worktrees:
2416
+ self.notify("Task has no worktrees", severity="information")
2417
+ return
2265
2418
 
2266
2419
  worktree_list = self.query_one("#worktree-list", WorktreeList)
2267
2420
  worktree_list.loading = True
@@ -2285,6 +2438,9 @@ class TaskTreeApp(App):
2285
2438
  if self._mutation_in_flight():
2286
2439
  self.notify("Another task operation is still running", severity="warning")
2287
2440
  return
2441
+ if not self.current_task.worktrees:
2442
+ self.notify("Task has no worktrees", severity="information")
2443
+ return
2288
2444
 
2289
2445
  worktree_list = self.query_one("#worktree-list", WorktreeList)
2290
2446
  worktree_list.loading = True
@@ -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."""
@@ -275,9 +275,7 @@ class TaskManager:
275
275
  created_dir = not task_path.exists()
276
276
  if not created_dir and (epic is not None or kind == EPIC_KIND):
277
277
  # The hierarchy rules for an existing task live in the two setters
278
- raise ValueError(
279
- f"Task '{name}' already exists; use tasktree-manager epic add or epic mark"
280
- )
278
+ raise ValueError(f"Task '{name}' already exists")
281
279
  task_path.mkdir(parents=True, exist_ok=True)
282
280
 
283
281
  task = Task(name=name, path=task_path)
@@ -640,6 +638,8 @@ class TaskManager:
640
638
  real name — clears the file.
641
639
  """
642
640
  marker = task.path / ".tasktree_name"
641
+ if marker.is_symlink():
642
+ marker.unlink()
643
643
  name = (display_name or "").strip()
644
644
  if name == task.name:
645
645
  name = ""
@@ -652,6 +652,9 @@ class TaskManager:
652
652
  def _write_marker(task: Task, marker: str, value: str | None) -> None:
653
653
  """Write <task>/<marker>, or remove it when value is None."""
654
654
  path = task.path / marker
655
+ if path.is_symlink():
656
+ # Never write through a planted link to a file outside the task
657
+ path.unlink()
655
658
  if value is not None:
656
659
  path.write_text(value + "\n", encoding="utf-8")
657
660
  else: