tasktree-manager 2.15.0__tar.gz → 2.16.1__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.15.0 → tasktree_manager-2.16.1}/.github/workflows/ci.yml +8 -2
  2. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/CHANGELOG.md +12 -0
  3. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/PKG-INFO +1 -1
  4. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/README.md +6 -5
  5. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/configuration.md +3 -1
  6. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/user-guide.md +4 -0
  7. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/pyproject.toml +1 -1
  8. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/_version.py +2 -2
  9. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/app.py +17 -6
  10. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/cli.py +18 -3
  11. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/models.py +64 -17
  12. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/task_manager.py +193 -51
  13. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/task_list.py +5 -2
  14. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/conftest.py +7 -1
  15. tasktree_manager-2.16.1/tests/test_epic_claude_md.py +176 -0
  16. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_epic_tui.py +25 -0
  17. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_epics.py +78 -1
  18. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_safety_fixes.py +4 -1
  19. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/SKILL.md +0 -0
  20. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/README.md +0 -0
  21. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  22. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  23. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  24. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  25. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/layouts.md +0 -0
  26. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  27. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/styling.md +0 -0
  28. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/widgets.md +0 -0
  29. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.github/templates/CHANGELOG.md.j2 +0 -0
  30. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.github/workflows/release.yml +0 -0
  31. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.gitignore +0 -0
  32. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/CONTRIBUTING.md +0 -0
  33. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/LICENSE +0 -0
  34. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/ROADMAP.md +0 -0
  35. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/claude-code-integration-spec.md +0 -0
  36. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/installation.md +0 -0
  37. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/troubleshooting.md +0 -0
  38. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/mise.toml +0 -0
  39. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/scripts/bump_version.py +0 -0
  40. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/__init__.py +0 -0
  41. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/commands.py +0 -0
  42. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/__init__.py +0 -0
  43. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/agent_sessions.py +0 -0
  44. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_hooks.py +0 -0
  45. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_sessions.py +0 -0
  46. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/config.py +0 -0
  47. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/forge.py +0 -0
  48. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/git_ops.py +0 -0
  49. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/themes.py +0 -0
  50. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/__init__.py +0 -0
  51. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/app_header.py +0 -0
  52. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/create_modal.py +0 -0
  53. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/messages_panel.py +0 -0
  54. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/setup_modal.py +0 -0
  55. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/status_panel.py +0 -0
  56. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/worktree_list.py +0 -0
  57. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/__init__.py +0 -0
  58. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_agent_sessions.py +0 -0
  59. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_agent_sessions_decode.py +0 -0
  60. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_app.py +0 -0
  61. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_app_review_fixes.py +0 -0
  62. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_bump_version.py +0 -0
  63. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_claude_hooks.py +0 -0
  64. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_claude_sessions.py +0 -0
  65. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_cli.py +0 -0
  66. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_commands.py +0 -0
  67. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_config.py +0 -0
  68. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_forge.py +0 -0
  69. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_git_ops.py +0 -0
  70. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_integration_git.py +0 -0
  71. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_list_selection.py +0 -0
  72. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_medium_fixes.py +0 -0
  73. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_memory.py +0 -0
  74. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_messages_panel.py +0 -0
  75. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_modals.py +0 -0
  76. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_setup_modal.py +0 -0
  77. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_status_panel.py +0 -0
  78. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_task_manager.py +0 -0
  79. {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_worktree_badges.py +0 -0
@@ -31,12 +31,18 @@ jobs:
31
31
  run: ruff format --check tasktree_manager tests
32
32
 
33
33
  test:
34
- name: Test (Python ${{ matrix.python-version }})
35
- runs-on: ubuntu-latest
34
+ # Linux jobs keep the names branch protection requires
35
+ name: ${{ matrix.os == 'macos-latest' && format('Test (macOS, Python {0})', matrix.python-version) || format('Test (Python {0})', matrix.python-version) }}
36
+ runs-on: ${{ matrix.os }}
36
37
  strategy:
37
38
  fail-fast: false
38
39
  matrix:
40
+ os: [ubuntu-latest]
39
41
  python-version: ["3.10", "3.11", "3.12", "3.13"]
42
+ include:
43
+ # Case-insensitive APFS: runs the case-variant epic tests Linux skips
44
+ - os: macos-latest
45
+ python-version: "3.13"
40
46
 
41
47
  steps:
42
48
  - name: Checkout code
@@ -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.1] - 2026-10-19
19
+
20
+ ### Fixed
21
+ - **epic**: One membership rule and safer marker writes (#12)
22
+
23
+
24
+ ## [2.16.0] - 2026-10-19
25
+
26
+ ### Added
27
+ - **epic**: Add a managed epic block to task CLAUDE.md files
28
+
29
+
18
30
  ## [2.15.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.15.0
3
+ Version: 2.16.1
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -102,7 +102,9 @@ tasktree-manager epic mark DIC-2466-pcaps-epic # turn an existing
102
102
  tasktree-manager epic list # epics and their tasks; also --json
103
103
  ```
104
104
 
105
- 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
106
108
  `epic` (the parent epic or `null`). `delete` and `finish` refuse an epic that still has
107
109
  tasks, even with `--force`.
108
110
 
@@ -227,11 +229,10 @@ For comprehensive troubleshooting, see the [Troubleshooting Guide](docs/troubles
227
229
  Recently shipped: agent dashboard (per-worktree Claude session badges + background dispatch),
228
230
  forge-aware merged detection (glab/gh, squash/rebase merges), MR/CI status badges,
229
231
  guided `finish` flow that archives the diff and the task-folder files, `status` subcommand
230
- for scripts and statuslines, Claude session recap in the Info panel, epics in the CLI and
231
- the task list.
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.
232
234
 
233
- Next: an epic context block in each task's CLAUDE.md, and epic support in the `/wtask`
234
- skill.
235
+ Ideas and bugs go to [GitHub Issues](https://github.com/ruslanfialkovskii/tasktree-manager/issues).
235
236
 
236
237
  See [CHANGELOG.md](CHANGELOG.md) for the release history.
237
238
 
@@ -611,7 +611,8 @@ All 27 customizable keybindings with descriptions:
611
611
  | `add_repo` | `a` | Add repository to current task | `add_repo = "ctrl+a"` |
612
612
  | `delete_task` | `d` | Delete/finish current task | `delete_task = "delete"` |
613
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"` |
614
+ | `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+shift+e"` |
615
+ | `delete_worktree` | `D` | Remove the selected worktree (the task stays) | `delete_worktree = "ctrl+d"` |
615
616
 
616
617
  ### Git Operations
617
618
 
@@ -632,6 +633,7 @@ All 27 customizable keybindings with descriptions:
632
633
  | `open_claude_resume` | `c` | Open Claude CLI (resume if session exists, else new) | `open_claude_resume = "ctrl+c"` |
633
634
  | `open_claude_gui_code`| `C` | Open Claude desktop on Code page in task folder | `open_claude_gui_code = "ctrl+shift+c"` |
634
635
  | `toggle_messages` | `m` | Show/hide activity messages panel | `toggle_messages = "ctrl+m"` |
636
+ | `dispatch_agent` | `b` | Dispatch a background Claude agent into the worktree | `dispatch_agent = "ctrl+b"` |
635
637
 
636
638
  ### Navigation
637
639
 
@@ -85,6 +85,10 @@ names their epic, so nothing moves on disk.
85
85
  - `enter` or `space` folds an epic. A folded epic row still shows the dirty
86
86
  count and the most urgent Claude state of its tasks.
87
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.
88
92
  - From scripts: `tasktree-manager epic create/add/remove/mark/unmark/list`.
89
93
 
90
94
  ### Worktrees
@@ -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.15.0"
74
+ version = "2.16.1"
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.15.0'
22
- __version_tuple__ = version_tuple = (2, 15, 0)
21
+ __version__ = version = '2.16.1'
22
+ __version_tuple__ = version_tuple = (2, 16, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -366,6 +366,9 @@ class TaskTreeApp(App):
366
366
  (kb.get("delete_worktree", "D"), "delete_worktree", "Del WT"),
367
367
  ]
368
368
 
369
+ # Every key a configured action uses ("s,space" binds two keys)
370
+ claimed_keys = {key.strip() for keys in kb.values() for key in keys.split(",")}
371
+
369
372
  yield AppHeader()
370
373
  with Horizontal(id="main-container"):
371
374
  # Left column: Tasks (top) and Worktrees (bottom)
@@ -382,7 +385,7 @@ class TaskTreeApp(App):
382
385
  id="task-list",
383
386
  context_bindings=task_panel_bindings,
384
387
  # 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()),
388
+ fold_keys=tuple(k for k in ("enter", "space") if k not in claimed_keys),
386
389
  )
387
390
  with Vertical(id="worktree-panel"):
388
391
  with Horizontal(classes="panel-title-bar"):
@@ -1302,7 +1305,8 @@ class TaskTreeApp(App):
1302
1305
  initial_base_branch=self.config.default_base_branch,
1303
1306
  title=f"Clone Task: {self.current_task.name}",
1304
1307
  epics=self._epic_choices(),
1305
- initial_epic=self._epic_of(self.current_task),
1308
+ # A clone joins the source task's epic; a clone of an epic joins none
1309
+ initial_epic=self.query_one("#task-list", TaskList).epic_of(self.current_task.name),
1306
1310
  ),
1307
1311
  handle_result,
1308
1312
  )
@@ -1432,7 +1436,7 @@ class TaskTreeApp(App):
1432
1436
  return
1433
1437
  try:
1434
1438
  self.task_manager.set_task_display_name(task, new_name)
1435
- except OSError as e:
1439
+ except (OSError, ValueError) as e:
1436
1440
  self.notify(escape(f"Rename failed: {e}"), severity="error")
1437
1441
  return
1438
1442
  label = task.display_label
@@ -1452,6 +1456,7 @@ class TaskTreeApp(App):
1452
1456
  self.notify("Epics don't nest: an epic can't join another epic", severity="warning")
1453
1457
  return
1454
1458
  epics = [(label, name) for label, name in self._epic_choices() if name != task.name]
1459
+ parent = self.query_one("#task-list", TaskList).epic_of(task.name)
1455
1460
 
1456
1461
  def handle_choice(choice: tuple[str, str | None] | None) -> None:
1457
1462
  if choice is None:
@@ -1469,8 +1474,8 @@ class TaskTreeApp(App):
1469
1474
  EpicPickerModal(
1470
1475
  task.display_label,
1471
1476
  epics,
1472
- self.query_one("#task-list", TaskList).epic_of(task.name),
1473
- task.epic_name is None,
1477
+ parent,
1478
+ parent is None,
1474
1479
  ),
1475
1480
  handle_choice,
1476
1481
  )
@@ -1521,6 +1526,10 @@ class TaskTreeApp(App):
1521
1526
  return
1522
1527
  self.notify(escape(message))
1523
1528
  self._log_activity(message, MessageLevel.INFO, task.name)
1529
+ problem = self.task_manager.sync_epic_block(task)
1530
+ if problem:
1531
+ self.notify(escape(problem), severity="warning")
1532
+ self._log_activity(problem, MessageLevel.WARNING, task.name)
1524
1533
  self._load_tasks(select_task=task.name)
1525
1534
 
1526
1535
  def _finish_task(self, task: Task, force: bool = False) -> None:
@@ -2080,7 +2089,9 @@ class TaskTreeApp(App):
2080
2089
  """
2081
2090
  fresh_task = self.task_manager.get_task(task.name)
2082
2091
  if fresh_task:
2083
- self.task_manager.ensure_claude_md_files(fresh_task)
2092
+ problem = self.task_manager.ensure_claude_md_files(fresh_task)
2093
+ if problem:
2094
+ self.call_from_thread(self.notify, escape(problem), severity="warning")
2084
2095
  self.task_manager.ensure_worktree_settings(fresh_task)
2085
2096
  ensure_claude_hooks(task.path, self.config.claude_memory_dir)
2086
2097
 
@@ -8,6 +8,7 @@ errors on stderr, exit code 0 on success and 1 on failure.
8
8
 
9
9
  import argparse
10
10
  import json
11
+ import os
11
12
  import shutil
12
13
  import sys
13
14
  from pathlib import Path
@@ -16,7 +17,7 @@ from . import __version__
16
17
  from .services import forge
17
18
  from .services.config import Config, ConfigError
18
19
  from .services.git_ops import GitOps
19
- from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task
20
+ from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task, parent_epic_name
20
21
  from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
21
22
 
22
23
 
@@ -213,9 +214,17 @@ def _create(
213
214
  if task.epic_name is not None:
214
215
  line += f" in epic {task.epic_name}"
215
216
  print(line)
217
+ _warn_epic_block(manager, task)
216
218
  return 0
217
219
 
218
220
 
221
+ def _warn_epic_block(manager: TaskManager, task: Task) -> None:
222
+ """Print why the task's CLAUDE.md epic block could not follow a change."""
223
+ problem = manager.sync_epic_block(task)
224
+ if problem:
225
+ print(f"warning: {problem}", file=sys.stderr)
226
+
227
+
219
228
  def _get_tasks(manager: TaskManager, names: list[str]) -> list[Task] | None:
220
229
  """Resolve task names, printing an error for each missing one (None if any)."""
221
230
  tasks = []
@@ -250,6 +259,7 @@ def cmd_epic_add(manager: TaskManager, config: Config, args: argparse.Namespace)
250
259
  print(f"Added {task.name} to epic {epic}")
251
260
  else:
252
261
  print(f"Moved {task.name} from epic {previous} to epic {epic}")
262
+ _warn_epic_block(manager, task)
253
263
  return code
254
264
 
255
265
 
@@ -265,6 +275,7 @@ def cmd_epic_remove(manager: TaskManager, config: Config, args: argparse.Namespa
265
275
  print(f"Task {task.name} is not in an epic")
266
276
  else:
267
277
  print(f"Removed {task.name} from epic {previous}")
278
+ _warn_epic_block(manager, task)
268
279
  return 0
269
280
 
270
281
 
@@ -278,6 +289,7 @@ def cmd_epic_mark(manager: TaskManager, config: Config, args: argparse.Namespace
278
289
  return 0
279
290
  manager.set_task_kind(task, EPIC_KIND)
280
291
  print(f"Marked {args.name} as an epic")
292
+ _warn_epic_block(manager, task)
281
293
  return 0
282
294
 
283
295
 
@@ -291,6 +303,7 @@ def cmd_epic_unmark(manager: TaskManager, config: Config, args: argparse.Namespa
291
303
  return 0
292
304
  manager.set_task_kind(task, TASK_KIND)
293
305
  print(f"Unmarked {args.name}: it is a plain task now")
306
+ _warn_epic_block(manager, task)
294
307
  return 0
295
308
 
296
309
 
@@ -342,6 +355,8 @@ def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> b
342
355
  def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
343
356
  """List tasks with their repos and dirty state."""
344
357
  tasks = manager.list_tasks()
358
+ # One TASKS_DIR listing for every parent_epic_name lookup below
359
+ listing = set(os.listdir(config.tasks_dir)) if config.tasks_dir.is_dir() else set()
345
360
  dirty: dict[str, bool] = {}
346
361
  errored: dict[str, bool] = {}
347
362
  for task in tasks:
@@ -357,7 +372,7 @@ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) ->
357
372
  "name": task.name,
358
373
  "display_name": task.display_name,
359
374
  "kind": task.kind,
360
- "epic": task.epic_name,
375
+ "epic": parent_epic_name(task, listing),
361
376
  "path": str(task.path),
362
377
  "repos": [wt.name for wt in task.worktrees],
363
378
  "dirty": dirty[task.name],
@@ -623,7 +638,7 @@ def cmd_status(manager: TaskManager, config: Config, args: argparse.Namespace) -
623
638
  payload = {
624
639
  "task": task.name,
625
640
  "kind": task.kind,
626
- "epic": task.epic_name,
641
+ "epic": parent_epic_name(task),
627
642
  "path": str(task.path),
628
643
  "claude": claude,
629
644
  "worktrees": [
@@ -2,6 +2,7 @@
2
2
 
3
3
  import os
4
4
  import re
5
+ from collections.abc import Collection
5
6
  from dataclasses import dataclass, field
6
7
  from pathlib import Path
7
8
 
@@ -128,23 +129,60 @@ class Task:
128
129
  return value
129
130
 
130
131
 
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.
132
+ def on_disk_name(path: Path, names: Collection[str] | None = None) -> str:
133
+ """The last path segment as spelled on disk, for an existing path.
133
134
 
134
- A marker spelled in another case matches only when it opens the same
135
- folder, as on a case-insensitive disk.
135
+ On a case-insensitive disk `epic-1` opens `EPIC-1`; markers, branches and
136
+ the epic checks must all use the real spelling. `names` is the listing of
137
+ path.parent when the caller already has it (one listing per scan).
136
138
  """
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
139
+ if names is None:
140
+ try:
141
+ names = set(os.listdir(path.parent))
142
+ except OSError:
143
+ return path.name
144
+ if path.name in names:
145
+ return path.name
146
+ for name in names:
147
+ if name.casefold() == path.name.casefold() and same_folder(path.parent / name, path):
148
+ return name
149
+ return path.name
150
+
151
+
152
+ def same_folder(a: Path, b: Path) -> bool:
153
+ """True when two paths open the same file or folder."""
154
+ try:
155
+ return os.path.samefile(a, b)
156
+ except OSError:
157
+ return False
158
+
159
+
160
+ def parent_epic_name(task: Task, names: Collection[str] | None = None) -> str | None:
161
+ """The epic a task belongs to, spelled as on disk, or None.
162
+
163
+ The one membership rule for the CLI, the TUI, the delete guard and the
164
+ CLAUDE.md block: the marker must name a real epic folder next to the
165
+ task. A symlink alias, a missing folder or a plain task makes it an orphan.
166
+ `names` is the TASKS_DIR listing, passed by callers that resolve many tasks.
167
+ """
168
+ marker = task.epic_name
169
+ if marker is None:
170
+ return None
171
+ path = task.path.parent / marker
172
+ if path.is_symlink() or not path.is_dir():
173
+ return None
174
+ name = on_disk_name(path, names)
175
+ if not Task(name=name, path=path.parent / name).is_epic:
176
+ return None
177
+ return name
178
+
179
+
180
+ def resolve_epic(
181
+ task: Task, epics: dict[str, Task], names: Collection[str] | None = None
182
+ ) -> str | None:
183
+ """The epic in `epics` the task belongs to (see parent_epic_name), or None."""
184
+ parent = parent_epic_name(task, names)
185
+ return parent if parent in epics else None
148
186
 
149
187
 
150
188
  def group_by_epic(tasks: list[Task]) -> tuple[list[Task], dict[str, list[Task]]]:
@@ -154,11 +192,20 @@ def group_by_epic(tasks: list[Task]) -> tuple[list[Task], dict[str, list[Task]]]
154
192
  rule as TaskManager.epic_child_names (the delete guard). Anything else, an
155
193
  orphan included, is top-level. Order within both is kept.
156
194
  """
157
- epics = {task.name: task for task in tasks if task.is_epic}
195
+ # A symlinked folder never counts as an epic: it would alias a real one
196
+ epics = {task.name: task for task in tasks if task.is_epic and not task.path.is_symlink()}
158
197
  children: dict[str, list[Task]] = {name: [] for name in epics}
159
198
  top: list[Task] = []
199
+ listings: dict[Path, set[str]] = {}
160
200
  for task in tasks:
161
- parent = None if task.name in epics else resolve_epic(task, epics)
201
+ folder = task.path.parent
202
+ if folder not in listings:
203
+ try:
204
+ listings[folder] = set(os.listdir(folder))
205
+ except OSError:
206
+ listings[folder] = set()
207
+ names = listings[folder]
208
+ parent = None if task.name in epics else resolve_epic(task, epics, names)
162
209
  if parent is None:
163
210
  top.append(task)
164
211
  else: