tasktree-manager 2.16.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.16.0 → tasktree_manager-2.16.1}/.github/workflows/ci.yml +8 -2
  2. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/CHANGELOG.md +6 -0
  3. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/PKG-INFO +1 -1
  4. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/configuration.md +3 -1
  5. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/pyproject.toml +1 -1
  6. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/_version.py +2 -2
  7. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/app.py +13 -6
  8. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/cli.py +6 -3
  9. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/models.py +64 -17
  10. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/task_manager.py +59 -50
  11. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/task_list.py +5 -2
  12. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/conftest.py +7 -1
  13. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_epic_claude_md.py +2 -2
  14. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_epic_tui.py +25 -0
  15. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_epics.py +78 -1
  16. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_safety_fixes.py +4 -1
  17. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/SKILL.md +0 -0
  18. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/README.md +0 -0
  19. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  20. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  21. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  22. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  23. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/layouts.md +0 -0
  24. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  25. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/styling.md +0 -0
  26. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/widgets.md +0 -0
  27. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.github/templates/CHANGELOG.md.j2 +0 -0
  28. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.github/workflows/release.yml +0 -0
  29. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.gitignore +0 -0
  30. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/CONTRIBUTING.md +0 -0
  31. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/LICENSE +0 -0
  32. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/README.md +0 -0
  33. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/ROADMAP.md +0 -0
  34. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/claude-code-integration-spec.md +0 -0
  35. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/installation.md +0 -0
  36. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/troubleshooting.md +0 -0
  37. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/user-guide.md +0 -0
  38. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/mise.toml +0 -0
  39. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/scripts/bump_version.py +0 -0
  40. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/__init__.py +0 -0
  41. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/commands.py +0 -0
  42. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/__init__.py +0 -0
  43. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/agent_sessions.py +0 -0
  44. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_hooks.py +0 -0
  45. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_sessions.py +0 -0
  46. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/config.py +0 -0
  47. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/forge.py +0 -0
  48. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/git_ops.py +0 -0
  49. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/themes.py +0 -0
  50. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/__init__.py +0 -0
  51. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/app_header.py +0 -0
  52. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/create_modal.py +0 -0
  53. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/messages_panel.py +0 -0
  54. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/setup_modal.py +0 -0
  55. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/status_panel.py +0 -0
  56. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/worktree_list.py +0 -0
  57. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/__init__.py +0 -0
  58. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_agent_sessions.py +0 -0
  59. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_agent_sessions_decode.py +0 -0
  60. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_app.py +0 -0
  61. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_app_review_fixes.py +0 -0
  62. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_bump_version.py +0 -0
  63. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_claude_hooks.py +0 -0
  64. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_claude_sessions.py +0 -0
  65. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_cli.py +0 -0
  66. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_commands.py +0 -0
  67. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_config.py +0 -0
  68. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_forge.py +0 -0
  69. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_git_ops.py +0 -0
  70. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_integration_git.py +0 -0
  71. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_list_selection.py +0 -0
  72. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_medium_fixes.py +0 -0
  73. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_memory.py +0 -0
  74. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_messages_panel.py +0 -0
  75. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_modals.py +0 -0
  76. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_setup_modal.py +0 -0
  77. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_status_panel.py +0 -0
  78. {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_task_manager.py +0 -0
  79. {tasktree_manager-2.16.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,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.16.1] - 2026-10-19
19
+
20
+ ### Fixed
21
+ - **epic**: One membership rule and safer marker writes (#12)
22
+
23
+
18
24
  ## [2.16.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.16.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
@@ -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
 
@@ -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.16.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.16.0'
22
- __version_tuple__ = version_tuple = (2, 16, 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
  )
@@ -2084,7 +2089,9 @@ class TaskTreeApp(App):
2084
2089
  """
2085
2090
  fresh_task = self.task_manager.get_task(task.name)
2086
2091
  if fresh_task:
2087
- 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")
2088
2095
  self.task_manager.ensure_worktree_settings(fresh_task)
2089
2096
  ensure_claude_hooks(task.path, self.config.claude_memory_dir)
2090
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
 
@@ -354,6 +355,8 @@ def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> b
354
355
  def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
355
356
  """List tasks with their repos and dirty state."""
356
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()
357
360
  dirty: dict[str, bool] = {}
358
361
  errored: dict[str, bool] = {}
359
362
  for task in tasks:
@@ -369,7 +372,7 @@ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) ->
369
372
  "name": task.name,
370
373
  "display_name": task.display_name,
371
374
  "kind": task.kind,
372
- "epic": task.epic_name,
375
+ "epic": parent_epic_name(task, listing),
373
376
  "path": str(task.path),
374
377
  "repos": [wt.name for wt in task.worktrees],
375
378
  "dirty": dirty[task.name],
@@ -635,7 +638,7 @@ def cmd_status(manager: TaskManager, config: Config, args: argparse.Namespace) -
635
638
  payload = {
636
639
  "task": task.name,
637
640
  "kind": task.kind,
638
- "epic": task.epic_name,
641
+ "epic": parent_epic_name(task),
639
642
  "path": str(task.path),
640
643
  "claude": claude,
641
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:
@@ -22,6 +22,9 @@ from .models import (
22
22
  Task,
23
23
  TaskSafetyReport,
24
24
  Worktree,
25
+ on_disk_name,
26
+ parent_epic_name,
27
+ same_folder,
25
28
  validate_task_name,
26
29
  )
27
30
 
@@ -272,13 +275,7 @@ class TaskManager:
272
275
  On a case-insensitive filesystem `epic-1` opens `EPIC-1`; markers and
273
276
  branches must use the real spelling or the epic checks miss children.
274
277
  """
275
- path = self.config.tasks_dir / name
276
- if any(item.name == name for item in self.config.tasks_dir.iterdir()):
277
- return name
278
- for item in self.config.tasks_dir.iterdir():
279
- if item.name.casefold() == name.casefold() and os.path.samefile(item, path):
280
- return item.name
281
- return name
278
+ return on_disk_name(self.config.tasks_dir / name)
282
279
 
283
280
  def create_task(
284
281
  self,
@@ -669,16 +666,29 @@ class TaskManager:
669
666
  lives in <task>/.tasktree_name. Empty input — or an alias equal to the
670
667
  real name — clears the file.
671
668
  """
672
- marker = task.path / ".tasktree_name"
673
- if marker.is_symlink():
674
- marker.unlink()
675
669
  name = (display_name or "").strip()
676
670
  if name == task.name:
677
671
  name = ""
678
- if name:
679
- marker.write_text(name + "\n", encoding="utf-8")
680
- elif marker.exists():
681
- marker.unlink()
672
+ self._write_file_marker(task, ".tasktree_name", name or None)
673
+
674
+ def _write_file_marker(self, task: Task, marker: str, value: str | None) -> None:
675
+ """Replace or remove <task>/<marker> without following links.
676
+
677
+ A planted symlink or hard link is swapped for a new file, never
678
+ written through; a task folder outside TASKS_DIR is refused.
679
+ """
680
+ self._require_inside(task)
681
+ path = task.path / marker
682
+ if value is not None:
683
+ self._replace_file(path, (value + "\n").encode("utf-8"))
684
+ elif path.is_symlink() or path.exists():
685
+ path.unlink()
686
+
687
+ def _require_inside(self, task: Task) -> None:
688
+ """Raise ValueError unless the task folder is a real folder in TASKS_DIR."""
689
+ tasks_dir = self.config.tasks_dir.resolve()
690
+ if task.path.is_symlink() or task.path.resolve().parent != tasks_dir:
691
+ raise ValueError(f"'{task.name}' is not a task folder inside {tasks_dir}")
682
692
 
683
693
  def _write_marker(self, task: Task, marker: str, value: str | None) -> None:
684
694
  """Write <task>/<marker>, or remove it when value is None.
@@ -687,14 +697,7 @@ class TaskManager:
687
697
  follows it. That sync never raises: callers that report problems run
688
698
  sync_epic_block again and show what it returns.
689
699
  """
690
- path = task.path / marker
691
- if path.is_symlink():
692
- # Never write through a planted link to a file outside the task
693
- path.unlink()
694
- if value is not None:
695
- path.write_text(value + "\n", encoding="utf-8")
696
- else:
697
- path.unlink(missing_ok=True)
700
+ self._write_file_marker(task, marker, value)
698
701
  self.sync_epic_block(task)
699
702
 
700
703
  def _render_epic_block(self, task: Task) -> str | None:
@@ -706,8 +709,7 @@ class TaskManager:
706
709
  f"`{tasks_dir}` whose `.tasktree_epic` file names `{task.name}`; list them",
707
710
  "with `tasktree-manager epic list`.",
708
711
  ]
709
- elif task.epic_name is not None:
710
- epic = task.epic_name
712
+ elif (epic := parent_epic_name(task)) is not None:
711
713
  body = [
712
714
  f"This task belongs to the epic `{epic}` at `{tasks_dir / epic}`.",
713
715
  "The epic folder holds the shared notes and plans. Its worktrees belong",
@@ -784,7 +786,10 @@ class TaskManager:
784
786
  f.write(data)
785
787
  f.flush()
786
788
  os.fsync(f.fileno())
787
- shutil.copymode(path, tmp)
789
+ if path.is_file() and not path.is_symlink():
790
+ shutil.copymode(path, tmp)
791
+ else:
792
+ os.chmod(tmp, 0o644)
788
793
  os.replace(tmp, path)
789
794
  except BaseException:
790
795
  Path(tmp).unlink(missing_ok=True)
@@ -801,6 +806,9 @@ class TaskManager:
801
806
  epic_path = self.config.tasks_dir / epic_name
802
807
  if not epic_path.is_dir():
803
808
  raise ValueError(f"No such epic: {epic_name}")
809
+ if epic_path.is_symlink():
810
+ # An alias would let delete/finish miss the children of the real epic
811
+ raise ValueError(f"'{epic_name}' is a symlink, not an epic folder")
804
812
  epic_name = self._on_disk_name(epic_name)
805
813
  if not Task(name=epic_name, path=epic_path).is_epic:
806
814
  raise ValueError(
@@ -811,37 +819,28 @@ class TaskManager:
811
819
  def epic_child_names(self, epic_name: str) -> list[str]:
812
820
  """Names of the tasks whose .tasktree_epic marker names this epic.
813
821
 
814
- Reads markers only: list_tasks() walks every task's worktrees.
822
+ Reads markers only: list_tasks() walks every task's worktrees. A child
823
+ counts when its parent (parent_epic_name) is the same folder as
824
+ `epic_name`, so a case variant or symlink alias of the epic still
825
+ finds every child.
815
826
  """
816
827
  if not self.config.tasks_dir.exists():
817
828
  return []
818
829
  names = []
830
+ listing = set(os.listdir(self.config.tasks_dir))
831
+ # Symlinked task folders count too: the CLI and TUI show them as children
819
832
  for item in sorted(self.config.tasks_dir.iterdir()):
820
833
  if item.is_dir() and not item.name.startswith("."):
821
- parent = Task(name=item.name, path=item).epic_name
822
- if parent == epic_name or (
823
- parent is not None
824
- and parent.casefold() == epic_name.casefold()
825
- and self._same_task(parent, epic_name)
834
+ parent = parent_epic_name(Task(name=item.name, path=item), listing)
835
+ if parent is not None and (
836
+ parent == epic_name or self._same_task(parent, epic_name)
826
837
  ):
827
838
  names.append(item.name)
828
839
  return names
829
840
 
830
841
  def _same_task(self, a: str, b: str) -> bool:
831
- """True when two task names open the same folder (case-insensitive disks)."""
832
- try:
833
- return os.path.samefile(self.config.tasks_dir / a, self.config.tasks_dir / b)
834
- except OSError:
835
- return False
836
-
837
- def epic_children(self, epic: Task) -> list[Task]:
838
- """The tasks attached to an epic, with their worktrees, sorted by name."""
839
- children = []
840
- for name in self.epic_child_names(epic.name):
841
- child = self.get_task(name)
842
- if child is not None:
843
- children.append(child)
844
- return children
842
+ """True when two task names open the same folder."""
843
+ return same_folder(self.config.tasks_dir / a, self.config.tasks_dir / b)
845
844
 
846
845
  @staticmethod
847
846
  def _epic_has_children_message(epic: Task, children: list[str]) -> str:
@@ -856,6 +855,7 @@ class TaskManager:
856
855
  Returns the epic name as written. Epics don't nest: an epic cannot
857
856
  join an epic.
858
857
  """
858
+ self._require_inside(task)
859
859
  if epic_name is None:
860
860
  self._write_marker(task, ".tasktree_epic", None)
861
861
  return None
@@ -871,17 +871,22 @@ class TaskManager:
871
871
  A task that belongs to an epic cannot become one, and an epic with
872
872
  children cannot become a task.
873
873
  """
874
+ self._require_inside(task)
874
875
  if kind == EPIC_KIND:
875
- if task.epic_name is not None:
876
+ parent = parent_epic_name(task)
877
+ if parent is not None:
876
878
  raise ValueError(
877
- f"'{task.name}' belongs to epic '{task.epic_name}'; "
879
+ f"'{task.name}' belongs to epic '{parent}'; "
878
880
  "detach it first (tasktree-manager epic remove)"
879
881
  )
880
882
  self._write_marker(task, ".tasktree_kind", EPIC_KIND)
881
883
  # An unreadable or self-naming parent marker reads as None; drop it
882
884
  self._write_marker(task, ".tasktree_epic", None)
883
885
  elif kind == TASK_KIND:
884
- children = self.epic_child_names(task.name) if task.is_epic else []
886
+ if not task.is_epic:
887
+ # A plain task stays as it is; its parent marker is not ours to drop
888
+ return
889
+ children = self.epic_child_names(task.name)
885
890
  if children:
886
891
  raise ValueError(self._epic_has_children_message(task, children))
887
892
  # A parent marker the epic carried was hidden; drop it before the
@@ -1480,7 +1485,7 @@ class TaskManager:
1480
1485
  if repo_path.exists() and worktree.path.exists():
1481
1486
  ensure_worktree_claude_settings(worktree.path, repo_path, status_file)
1482
1487
 
1483
- def ensure_claude_md_files(self, task: Task) -> None:
1488
+ def ensure_claude_md_files(self, task: Task) -> str | None:
1484
1489
  """Create the task CLAUDE.md and backfill worktree ones from the repo.
1485
1490
 
1486
1491
  The task-level file is generated (the task dir is not a git repo).
@@ -1490,16 +1495,20 @@ class TaskManager:
1490
1495
 
1491
1496
  Args:
1492
1497
  task: The task to create CLAUDE.md files for
1498
+
1499
+ Returns:
1500
+ Why the epic block could not be synced, or None
1493
1501
  """
1494
1502
  task_claude_md = task.path / "CLAUDE.md"
1495
1503
  if not task_claude_md.exists():
1496
1504
  self._create_task_claude_md(task)
1497
- self.sync_epic_block(task)
1505
+ problem = self.sync_epic_block(task)
1498
1506
 
1499
1507
  for worktree in task.worktrees:
1500
1508
  wt_claude_md = worktree.path / "CLAUDE.md"
1501
1509
  if not wt_claude_md.exists():
1502
1510
  self._copy_repo_claude_md(worktree)
1511
+ return problem
1503
1512
 
1504
1513
  def _create_task_claude_md(self, task: Task) -> None:
1505
1514
  """Generate task CLAUDE.md with task name + worktree paths.
@@ -84,7 +84,7 @@ class TaskList(OptionList):
84
84
  self._bindings.bind(key, f"app.{action}", description, show=True)
85
85
  # Enter folds an epic instead of OptionList's select (a click still
86
86
  # selects); a fold key the user bound to an action stays theirs
87
- claimed = {key for key, _, _ in context_bindings or []}
87
+ claimed = {key.strip() for keys, _, _ in context_bindings or [] for key in keys.split(",")}
88
88
  for i, key in enumerate(k for k in fold_keys if k not in claimed):
89
89
  self._bindings.key_to_bindings.pop(key, None)
90
90
  self._bindings.bind(key, "toggle_epic", "Fold", show=i == 0)
@@ -308,7 +308,10 @@ class TaskList(OptionList):
308
308
 
309
309
  @staticmethod
310
310
  def _mtime(task: Task) -> float:
311
- return task.path.stat().st_mtime if task.path.exists() else 0
311
+ try:
312
+ return task.path.stat().st_mtime
313
+ except OSError: # deleted or unreadable since it was listed
314
+ return 0
312
315
 
313
316
  def _sort_tasks(self, tasks: list[Task]) -> list[Task]:
314
317
  """Sort tasks according to current sort mode.
@@ -120,8 +120,14 @@ def sample_repos(temp_dirs):
120
120
 
121
121
 
122
122
  @pytest.fixture
123
- def app(config):
123
+ def app(config, tmp_path, monkeypatch):
124
124
  """Create app with test config."""
125
+ # TaskTreeApp() loads the real config first: keep it (and the default
126
+ # ~/tasks it would create) inside the test's temp dir
127
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
128
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "home" / ".config"))
129
+ for var in ("TASKS_DIR", "REPOS_DIR"):
130
+ monkeypatch.delenv(var, raising=False)
125
131
  config.ensure_dirs()
126
132
  # Keep TUI tests hermetic: never poll the real `claude` CLI from on_mount.
127
133
  # Badge/dispatch tests drive the poll/apply methods directly instead.
@@ -149,10 +149,10 @@ class TestEpicBlockRobustness:
149
149
  def full_disk(src, dst):
150
150
  raise OSError(28, "No space left on device")
151
151
 
152
+ (task.path / ".tasktree_epic").write_text("E-1\n")
152
153
  monkeypatch.setattr(os, "replace", full_disk)
153
- tm.set_task_epic(task, "E-1")
154
- assert path.read_text() == "notes\n" * 50
155
154
  assert "No space left" in tm.sync_epic_block(task)
155
+ assert path.read_text() == "notes\n" * 50
156
156
  assert [p.name for p in task.path.iterdir() if p.name.endswith(".tmp")] == []
157
157
 
158
158
  def test_unmark_with_planted_parent_marker(self, tm):
@@ -468,3 +468,28 @@ class TestReviewFixesTui:
468
468
  await pilot.press("h")
469
469
  await pilot.pause()
470
470
  assert started == []
471
+
472
+
473
+ class TestCumulativeReviewTui:
474
+ async def test_comma_separated_binding_keeps_space(self, app, epic_tasks):
475
+ app.config.keybindings["cycle_sort"] = "s,space"
476
+ async with app.run_test() as pilot:
477
+ await pilot.pause()
478
+ task_list = app.query_one("#task-list", TaskList)
479
+ task_list.focus()
480
+ await select(pilot, task_list, "E-1")
481
+ await pilot.press("space")
482
+ await pilot.pause()
483
+ assert task_list._sort_mode is SortMode.NAME_DESC
484
+
485
+ @pytest.mark.parametrize(("row", "expected"), [("E-1", None), ("T-1", "E-1")])
486
+ async def test_clone_preselects_source_parent(
487
+ self, app, epic_tasks, sample_repos, row, expected
488
+ ):
489
+ async with app.run_test() as pilot:
490
+ await pilot.pause()
491
+ await select(pilot, app.query_one("#task-list", TaskList), row)
492
+ await pilot.press("y")
493
+ await pilot.pause()
494
+ assert isinstance(app.screen, CreateTaskModal)
495
+ assert app.screen.initial_epic == expected
@@ -1,6 +1,7 @@
1
1
  """Tests for epics: the markers, the service rules and the `epic` CLI."""
2
2
 
3
3
  import json
4
+ import os
4
5
 
5
6
  import pytest
6
7
 
@@ -159,7 +160,6 @@ class TestHierarchy:
159
160
  task_manager.create_task("T-1", [], epic="EPIC-1")
160
161
  task_manager.create_task("T-3", [])
161
162
  assert task_manager.epic_child_names("EPIC-1") == ["T-1", "T-2"]
162
- assert [t.name for t in task_manager.epic_children(epic)] == ["T-1", "T-2"]
163
163
 
164
164
  def test_finish_refuses_epic_with_children(self, task_manager, epic):
165
165
  child = task_manager.create_task("T-1", [], epic="EPIC-1")
@@ -440,3 +440,80 @@ class TestMarkerSymlink:
440
440
  assert outside.read_text() == "keep\n"
441
441
  assert not (task.path / ".tasktree_epic").is_symlink()
442
442
  assert task.epic_name == "EPIC-1"
443
+
444
+
445
+ class TestCumulativeReviewFixes:
446
+ def test_symlink_alias_is_not_an_epic(self, cli_config, capsys):
447
+ cli(cli_config, "epic", "create", "EPIC-1")
448
+ cli(cli_config, "create", "T-1", "--repos", "repo-alpha", "--epic", "EPIC-1")
449
+ (cli_config.tasks_dir / "alias").symlink_to(cli_config.tasks_dir / "EPIC-1")
450
+ capsys.readouterr()
451
+ assert cli(cli_config, "epic", "add", "alias", "T-1") == 1
452
+ assert "is a symlink" in capsys.readouterr().err
453
+ # Deleting through the alias still sees the real epic's children
454
+ assert cli(cli_config, "delete", "alias", "--force") == 1
455
+ assert "T-1" in capsys.readouterr().err
456
+ assert (cli_config.tasks_dir / "EPIC-1").exists()
457
+
458
+ def test_alias_marker_is_an_orphan(self, cli_config, capsys):
459
+ cli(cli_config, "epic", "create", "EPIC-1")
460
+ cli(cli_config, "create", "T-2", "--repos", "repo-alpha")
461
+ (cli_config.tasks_dir / "alias").symlink_to(cli_config.tasks_dir / "EPIC-1")
462
+ (cli_config.tasks_dir / "T-2" / ".tasktree_epic").write_text("alias\n")
463
+ capsys.readouterr()
464
+ assert cli(cli_config, "list", "--json") == 0
465
+ payload = {t["name"]: t for t in json.loads(capsys.readouterr().out)}
466
+ assert payload["T-2"]["epic"] is None
467
+
468
+ def test_orphan_marker_is_null_everywhere(self, cli_config, task_manager, capsys):
469
+ cli(cli_config, "create", "T-1", "--repos", "repo-alpha")
470
+ task = task_manager.get_task("T-1")
471
+ (task.path / ".tasktree_epic").write_text("GHOST\n")
472
+ task_manager.sync_epic_block(task)
473
+ assert "GHOST" not in (task.path / "CLAUDE.md").read_text()
474
+ capsys.readouterr()
475
+ assert cli(cli_config, "list", "--json") == 0
476
+ assert json.loads(capsys.readouterr().out)[0]["epic"] is None
477
+ assert cli(cli_config, "epic", "mark", "T-1") == 0 # an orphan may become an epic
478
+ assert not (task.path / ".tasktree_epic").exists()
479
+
480
+ def test_hard_linked_marker_not_written_through(self, task_manager, epic, tmp_path):
481
+ outside = tmp_path / "outside.txt"
482
+ outside.write_text("keep\n")
483
+ task = task_manager.create_task("T-1", [])
484
+ os.link(outside, task.path / ".tasktree_epic")
485
+ task_manager.set_task_epic(task, "EPIC-1")
486
+ assert outside.read_text() == "keep\n"
487
+ assert task.epic_name == "EPIC-1"
488
+
489
+ def test_task_folder_outside_tasks_dir_refused(self, task_manager, epic, tmp_path):
490
+ real = tmp_path / "elsewhere"
491
+ real.mkdir()
492
+ link = task_manager.config.tasks_dir / "T-LINK"
493
+ link.symlink_to(real)
494
+ task = Task(name="T-LINK", path=link)
495
+ with pytest.raises(ValueError, match="not a task folder"):
496
+ task_manager.set_task_epic(task, "EPIC-1")
497
+ assert not (real / ".tasktree_epic").exists()
498
+
499
+ def test_unmark_on_plain_child_keeps_its_epic(self, task_manager, epic):
500
+ child = task_manager.create_task("T-1", [], epic="EPIC-1")
501
+ task_manager.set_task_kind(child, "task")
502
+ assert child.epic_name == "EPIC-1"
503
+
504
+ def test_ensure_reports_block_problem(self, task_manager, epic):
505
+ from tasktree_manager.services.task_manager import EPIC_BLOCK_START
506
+
507
+ child = task_manager.create_task("T-1", [], epic="EPIC-1")
508
+ (child.path / "CLAUDE.md").write_text(EPIC_BLOCK_START + "\n")
509
+ assert "mismatched" in task_manager.ensure_claude_md_files(child)
510
+
511
+ def test_symlinked_child_still_blocks_epic_delete(self, cli_config, task_manager, capsys):
512
+ cli(cli_config, "epic", "create", "EPIC-1")
513
+ real = cli_config.tasks_dir.parent / "real-child"
514
+ real.mkdir()
515
+ (real / ".tasktree_epic").write_text("EPIC-1\n")
516
+ (cli_config.tasks_dir / "T-LINK").symlink_to(real)
517
+ assert task_manager.epic_child_names("EPIC-1") == ["T-LINK"]
518
+ capsys.readouterr()
519
+ assert cli(cli_config, "delete", "EPIC-1", "--force") == 1
@@ -272,7 +272,10 @@ class TestDeleteWorktreeSafety:
272
272
  await pilot.pause()
273
273
  modal = app.screen_stack[-1]
274
274
  assert isinstance(modal, ConfirmModal)
275
- modal.dismiss(True)
275
+ await modal.dismiss(True)
276
+ # The confirm callback starts the worker on a later message, so
277
+ # let it run before waiting, or the wait returns with no worker.
278
+ await pilot.pause()
276
279
  await app.workers.wait_for_complete()
277
280
  await pilot.pause()
278
281
  assert not wt.path.exists()