tasktree-manager 2.15.0__tar.gz → 2.16.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/CHANGELOG.md +6 -0
  2. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/README.md +6 -5
  4. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/user-guide.md +4 -0
  5. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/pyproject.toml +1 -1
  6. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/_version.py +2 -2
  7. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/app.py +4 -0
  8. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/cli.py +12 -0
  9. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/task_manager.py +141 -8
  10. tasktree_manager-2.16.0/tests/test_epic_claude_md.py +176 -0
  11. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  12. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  13. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  14. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  15. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  16. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  17. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  18. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  19. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  20. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  21. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  22. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.github/workflows/ci.yml +0 -0
  23. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.github/workflows/release.yml +0 -0
  24. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.gitignore +0 -0
  25. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/CONTRIBUTING.md +0 -0
  26. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/LICENSE +0 -0
  27. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/ROADMAP.md +0 -0
  28. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/claude-code-integration-spec.md +0 -0
  29. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/configuration.md +0 -0
  30. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/installation.md +0 -0
  31. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/troubleshooting.md +0 -0
  32. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/mise.toml +0 -0
  33. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/scripts/bump_version.py +0 -0
  34. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/__init__.py +0 -0
  35. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/commands.py +0 -0
  36. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/__init__.py +0 -0
  37. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/agent_sessions.py +0 -0
  38. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/claude_hooks.py +0 -0
  39. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/claude_sessions.py +0 -0
  40. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/config.py +0 -0
  41. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/forge.py +0 -0
  42. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/git_ops.py +0 -0
  43. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/models.py +0 -0
  44. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/themes.py +0 -0
  45. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/__init__.py +0 -0
  46. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/app_header.py +0 -0
  47. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/create_modal.py +0 -0
  48. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  49. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  50. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/status_panel.py +0 -0
  51. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/task_list.py +0 -0
  52. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  53. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/__init__.py +0 -0
  54. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/conftest.py +0 -0
  55. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_agent_sessions.py +0 -0
  56. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_agent_sessions_decode.py +0 -0
  57. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_app.py +0 -0
  58. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_app_review_fixes.py +0 -0
  59. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_bump_version.py +0 -0
  60. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_claude_hooks.py +0 -0
  61. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_claude_sessions.py +0 -0
  62. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_cli.py +0 -0
  63. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_commands.py +0 -0
  64. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_config.py +0 -0
  65. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_epic_tui.py +0 -0
  66. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_epics.py +0 -0
  67. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_forge.py +0 -0
  68. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_git_ops.py +0 -0
  69. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_integration_git.py +0 -0
  70. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_list_selection.py +0 -0
  71. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_medium_fixes.py +0 -0
  72. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_memory.py +0 -0
  73. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_messages_panel.py +0 -0
  74. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_modals.py +0 -0
  75. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_safety_fixes.py +0 -0
  76. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_setup_modal.py +0 -0
  77. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_status_panel.py +0 -0
  78. {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_task_manager.py +0 -0
  79. {tasktree_manager-2.15.0 → tasktree_manager-2.16.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.16.0] - 2026-10-19
19
+
20
+ ### Added
21
+ - **epic**: Add a managed epic block to task CLAUDE.md files
22
+
23
+
18
24
  ## [2.15.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.15.0
3
+ Version: 2.16.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -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
 
@@ -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.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.15.0'
22
- __version_tuple__ = version_tuple = (2, 15, 0)
21
+ __version__ = version = '2.16.0'
22
+ __version_tuple__ = version_tuple = (2, 16, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1521,6 +1521,10 @@ class TaskTreeApp(App):
1521
1521
  return
1522
1522
  self.notify(escape(message))
1523
1523
  self._log_activity(message, MessageLevel.INFO, task.name)
1524
+ problem = self.task_manager.sync_epic_block(task)
1525
+ if problem:
1526
+ self.notify(escape(problem), severity="warning")
1527
+ self._log_activity(problem, MessageLevel.WARNING, task.name)
1524
1528
  self._load_tasks(select_task=task.name)
1525
1529
 
1526
1530
  def _finish_task(self, task: Task, force: bool = False) -> None:
@@ -213,9 +213,17 @@ def _create(
213
213
  if task.epic_name is not None:
214
214
  line += f" in epic {task.epic_name}"
215
215
  print(line)
216
+ _warn_epic_block(manager, task)
216
217
  return 0
217
218
 
218
219
 
220
+ def _warn_epic_block(manager: TaskManager, task: Task) -> None:
221
+ """Print why the task's CLAUDE.md epic block could not follow a change."""
222
+ problem = manager.sync_epic_block(task)
223
+ if problem:
224
+ print(f"warning: {problem}", file=sys.stderr)
225
+
226
+
219
227
  def _get_tasks(manager: TaskManager, names: list[str]) -> list[Task] | None:
220
228
  """Resolve task names, printing an error for each missing one (None if any)."""
221
229
  tasks = []
@@ -250,6 +258,7 @@ def cmd_epic_add(manager: TaskManager, config: Config, args: argparse.Namespace)
250
258
  print(f"Added {task.name} to epic {epic}")
251
259
  else:
252
260
  print(f"Moved {task.name} from epic {previous} to epic {epic}")
261
+ _warn_epic_block(manager, task)
253
262
  return code
254
263
 
255
264
 
@@ -265,6 +274,7 @@ def cmd_epic_remove(manager: TaskManager, config: Config, args: argparse.Namespa
265
274
  print(f"Task {task.name} is not in an epic")
266
275
  else:
267
276
  print(f"Removed {task.name} from epic {previous}")
277
+ _warn_epic_block(manager, task)
268
278
  return 0
269
279
 
270
280
 
@@ -278,6 +288,7 @@ def cmd_epic_mark(manager: TaskManager, config: Config, args: argparse.Namespace
278
288
  return 0
279
289
  manager.set_task_kind(task, EPIC_KIND)
280
290
  print(f"Marked {args.name} as an epic")
291
+ _warn_epic_block(manager, task)
281
292
  return 0
282
293
 
283
294
 
@@ -291,6 +302,7 @@ def cmd_epic_unmark(manager: TaskManager, config: Config, args: argparse.Namespa
291
302
  return 0
292
303
  manager.set_task_kind(task, TASK_KIND)
293
304
  print(f"Unmarked {args.name}: it is a plain task now")
305
+ _warn_epic_block(manager, task)
294
306
  return 0
295
307
 
296
308
 
@@ -5,6 +5,7 @@ import os
5
5
  import re
6
6
  import shutil
7
7
  import subprocess
8
+ import tempfile
8
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
10
  from datetime import datetime
10
11
  from pathlib import Path
@@ -84,6 +85,37 @@ _STUB_ENTRY = re.compile(r"- \*\*(?P<name>[^*]+)\*\*: `(?P<path>[^`]+)`")
84
85
  _STUB_BRANCH = re.compile(r" - Branch: `[^`]+`")
85
86
 
86
87
 
88
+ # Managed block in a task CLAUDE.md that tells a session about its epic
89
+ EPIC_BLOCK_START = "<!-- tasktree:epic -->"
90
+ EPIC_BLOCK_END = "<!-- /tasktree:epic -->"
91
+
92
+
93
+ def _epic_block_span(text: str) -> tuple[int, int] | None:
94
+ """(start, end) of the one managed epic block, or None.
95
+
96
+ Raises ValueError when the markers are unbalanced, repeated or out of
97
+ order: the file was edited by hand and must be left alone.
98
+ """
99
+ starts, ends = text.count(EPIC_BLOCK_START), text.count(EPIC_BLOCK_END)
100
+ if starts == ends == 0:
101
+ return None
102
+ start, end = text.find(EPIC_BLOCK_START), text.find(EPIC_BLOCK_END)
103
+ if starts != 1 or ends != 1 or end < start:
104
+ raise ValueError("mismatched tasktree:epic markers")
105
+ return start, end + len(EPIC_BLOCK_END)
106
+
107
+
108
+ def _without_epic_block(text: str) -> str:
109
+ """CLAUDE.md text with the managed epic block cut out (unchanged if malformed)."""
110
+ try:
111
+ span = _epic_block_span(text)
112
+ except ValueError:
113
+ return text
114
+ if span is None:
115
+ return text
116
+ return text[: span[0]] + text[span[1] :]
117
+
118
+
87
119
  def _without_worktree_entries(text: str, task_path: Path) -> list[str]:
88
120
  """CLAUDE.md lines minus the generated entries of its Worktrees section.
89
121
 
@@ -648,9 +680,13 @@ class TaskManager:
648
680
  elif marker.exists():
649
681
  marker.unlink()
650
682
 
651
- @staticmethod
652
- def _write_marker(task: Task, marker: str, value: str | None) -> None:
653
- """Write <task>/<marker>, or remove it when value is None."""
683
+ def _write_marker(self, task: Task, marker: str, value: str | None) -> None:
684
+ """Write <task>/<marker>, or remove it when value is None.
685
+
686
+ Every epic change passes through here, so the CLAUDE.md epic block
687
+ follows it. That sync never raises: callers that report problems run
688
+ sync_epic_block again and show what it returns.
689
+ """
654
690
  path = task.path / marker
655
691
  if path.is_symlink():
656
692
  # Never write through a planted link to a file outside the task
@@ -659,6 +695,100 @@ class TaskManager:
659
695
  path.write_text(value + "\n", encoding="utf-8")
660
696
  else:
661
697
  path.unlink(missing_ok=True)
698
+ self.sync_epic_block(task)
699
+
700
+ def _render_epic_block(self, task: Task) -> str | None:
701
+ """The managed CLAUDE.md block for an epic or a child task, else None."""
702
+ tasks_dir = self.config.tasks_dir.absolute()
703
+ if task.is_epic:
704
+ body = [
705
+ f"This folder is the epic `{task.name}`. Its tasks are the folders in",
706
+ f"`{tasks_dir}` whose `.tasktree_epic` file names `{task.name}`; list them",
707
+ "with `tasktree-manager epic list`.",
708
+ ]
709
+ elif task.epic_name is not None:
710
+ epic = task.epic_name
711
+ body = [
712
+ f"This task belongs to the epic `{epic}` at `{tasks_dir / epic}`.",
713
+ "The epic folder holds the shared notes and plans. Its worktrees belong",
714
+ "to the epic, not to this task: read them for context, change code here.",
715
+ f"Sibling tasks are the folders in `{tasks_dir}` whose `.tasktree_epic`",
716
+ f"file names `{epic}`; list them with `tasktree-manager epic list`.",
717
+ ]
718
+ else:
719
+ return None
720
+ return "\n".join([EPIC_BLOCK_START, "## Epic", "", *body, EPIC_BLOCK_END])
721
+
722
+ def sync_epic_block(self, task: Task) -> str | None:
723
+ """Insert, update or remove the epic block in <task>/CLAUDE.md.
724
+
725
+ Returns a problem to show the user, or None; never raises. Text outside
726
+ the markers keeps its bytes and line endings, and the file is replaced
727
+ atomically. A missing CLAUDE.md is left for ensure_claude_md_files; a
728
+ read-only one or one with mismatched markers is not touched.
729
+ """
730
+ path = task.path / "CLAUDE.md"
731
+ if not path.is_file() or path.is_symlink():
732
+ return None
733
+ try:
734
+ # surrogateescape round-trips bytes that are not UTF-8
735
+ text = path.read_bytes().decode("utf-8", "surrogateescape")
736
+ span = _epic_block_span(text)
737
+ except OSError as e:
738
+ return f"could not read {path}: {e}"
739
+ except ValueError:
740
+ return f"{path} has mismatched tasktree:epic markers; the epic block was not updated"
741
+ new = self._with_epic_block(text, span, self._render_epic_block(task))
742
+ if new == text:
743
+ return None
744
+ if not os.access(path, os.W_OK):
745
+ return f"{path} is read-only; the epic block was not updated"
746
+ try:
747
+ self._replace_file(path, new.encode("utf-8", "surrogateescape"))
748
+ except OSError as e:
749
+ return f"could not update the epic block in {path}: {e}"
750
+ return None
751
+
752
+ @staticmethod
753
+ def _with_epic_block(text: str, span: tuple[int, int] | None, block: str | None) -> str:
754
+ """`text` with the block inserted, replaced or removed (in the file's line endings)."""
755
+ nl = "\r\n" if "\r\n" in text else "\n"
756
+ if block is not None:
757
+ block = block.replace("\n", nl)
758
+ if span is not None:
759
+ before, after = text[: span[0]], text[span[1] :]
760
+ if block is not None:
761
+ return before + block + after
762
+ # Drop the line break after the block and the blank line added before it
763
+ after = after.removeprefix(nl)
764
+ if before.endswith(nl + nl):
765
+ before = before[: -len(nl)]
766
+ return before + after
767
+ if block is None:
768
+ return text
769
+ if not text:
770
+ return block + nl
771
+ sep = "" if text.endswith(nl + nl) else nl if text.endswith(nl) else nl + nl
772
+ return text + sep + block + nl
773
+
774
+ @staticmethod
775
+ def _replace_file(path: Path, data: bytes) -> None:
776
+ """Write `data` to `path` via a temp file and an atomic rename.
777
+
778
+ A failed write leaves the old file whole; the hidden temp name keeps
779
+ a leftover out of the archive.
780
+ """
781
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
782
+ try:
783
+ with os.fdopen(fd, "wb") as f:
784
+ f.write(data)
785
+ f.flush()
786
+ os.fsync(f.fileno())
787
+ shutil.copymode(path, tmp)
788
+ os.replace(tmp, path)
789
+ except BaseException:
790
+ Path(tmp).unlink(missing_ok=True)
791
+ raise
662
792
 
663
793
  def _require_epic(self, epic_name: str, task_name: str) -> str:
664
794
  """Return the epic's name as spelled on disk, or raise ValueError.
@@ -754,9 +884,10 @@ class TaskManager:
754
884
  children = self.epic_child_names(task.name) if task.is_epic else []
755
885
  if children:
756
886
  raise ValueError(self._epic_has_children_message(task, children))
757
- self._write_marker(task, ".tasktree_kind", None)
758
- # A parent marker the epic carried was hidden; never let it surface
887
+ # A parent marker the epic carried was hidden; drop it before the
888
+ # kind, or the CLAUDE.md sync would briefly treat it as real
759
889
  self._write_marker(task, ".tasktree_epic", None)
890
+ self._write_marker(task, ".tasktree_kind", None)
760
891
  else:
761
892
  raise ValueError(f"Unknown task kind: {kind}")
762
893
 
@@ -980,9 +1111,10 @@ class TaskManager:
980
1111
  except (OSError, UnicodeDecodeError):
981
1112
  return False
982
1113
  stub = self._render_task_claude_md(task.name, [])
983
- return _without_worktree_entries(text, task.path) == _without_worktree_entries(
984
- stub, task.path
985
- )
1114
+ # The managed epic block is generated too
1115
+ return _without_worktree_entries(
1116
+ _without_epic_block(text), task.path
1117
+ ) == _without_worktree_entries(stub, task.path)
986
1118
 
987
1119
  def _resolve_archive_base(self, worktree: Worktree, recorded_base: str | None) -> str:
988
1120
  """Pick a base branch for the archive diff that actually resolves.
@@ -1362,6 +1494,7 @@ class TaskManager:
1362
1494
  task_claude_md = task.path / "CLAUDE.md"
1363
1495
  if not task_claude_md.exists():
1364
1496
  self._create_task_claude_md(task)
1497
+ self.sync_epic_block(task)
1365
1498
 
1366
1499
  for worktree in task.worktrees:
1367
1500
  wt_claude_md = worktree.path / "CLAUDE.md"
@@ -0,0 +1,176 @@
1
+ """Tests for the managed epic block in task CLAUDE.md files."""
2
+
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from tasktree_manager.cli import run_cli
8
+ from tasktree_manager.services.task_manager import EPIC_BLOCK_END, EPIC_BLOCK_START
9
+
10
+
11
+ @pytest.fixture
12
+ def tm(task_manager):
13
+ task_manager.create_task("E-1", [], kind="epic")
14
+ task_manager.create_task("E-2", [], kind="epic")
15
+ return task_manager
16
+
17
+
18
+ def claude_md(tm, name):
19
+ return (tm.config.tasks_dir / name / "CLAUDE.md").read_text()
20
+
21
+
22
+ def prepare(tm, name):
23
+ tm.ensure_claude_md_files(tm.get_task(name))
24
+ return tm.get_task(name)
25
+
26
+
27
+ class TestEpicBlock:
28
+ def test_child_block_backfilled(self, tm):
29
+ tm.create_task("T-1", [], epic="E-1")
30
+ prepare(tm, "T-1")
31
+ text = claude_md(tm, "T-1")
32
+ assert text.startswith("# Task: T-1")
33
+ assert text.count(EPIC_BLOCK_START) == 1
34
+ assert "belongs to the epic `E-1`" in text
35
+ assert str(tm.config.tasks_dir / "E-1") in text
36
+
37
+ def test_epic_block(self, tm):
38
+ prepare(tm, "E-1")
39
+ assert "This folder is the epic `E-1`" in claude_md(tm, "E-1")
40
+
41
+ def test_sync_twice_changes_nothing(self, tm):
42
+ tm.create_task("T-1", [], epic="E-1")
43
+ task = prepare(tm, "T-1")
44
+ first = claude_md(tm, "T-1")
45
+ tm.sync_epic_block(task)
46
+ tm.ensure_claude_md_files(task)
47
+ assert claude_md(tm, "T-1") == first
48
+
49
+ def test_move_and_detach_keep_user_text(self, tm):
50
+ task = tm.create_task("T-1", [], epic="E-1")
51
+ path = task.path / "CLAUDE.md"
52
+ path.write_text("# My notes\n\nkeep this\n")
53
+ tm.sync_epic_block(task)
54
+ path.write_text(path.read_text() + "\n## After\n\nand this\n")
55
+ tm.set_task_epic(task, "E-2")
56
+ text = path.read_text()
57
+ assert "epic `E-2`" in text and "epic `E-1`" not in text
58
+ assert text.startswith("# My notes\n\nkeep this\n")
59
+ assert text.endswith("## After\n\nand this\n")
60
+ tm.set_task_epic(task, None)
61
+ assert path.read_text() == "# My notes\n\nkeep this\n\n## After\n\nand this\n"
62
+
63
+ def test_unmark_removes_epic_block(self, tm):
64
+ epic = prepare(tm, "E-2")
65
+ tm.set_task_kind(epic, "task")
66
+ assert EPIC_BLOCK_START not in claude_md(tm, "E-2")
67
+
68
+ @pytest.mark.parametrize(
69
+ "broken",
70
+ [
71
+ EPIC_BLOCK_START + "\nhalf",
72
+ EPIC_BLOCK_END + "\n" + EPIC_BLOCK_START,
73
+ (EPIC_BLOCK_START + "\nx\n" + EPIC_BLOCK_END + "\n") * 2,
74
+ ],
75
+ )
76
+ def test_mismatched_markers_left_alone(self, tm, broken):
77
+ task = tm.create_task("T-1", [], epic="E-1")
78
+ path = task.path / "CLAUDE.md"
79
+ path.write_text("notes\n" + broken)
80
+ tm.sync_epic_block(task)
81
+ assert path.read_text() == "notes\n" + broken
82
+
83
+ def test_stub_with_block_is_not_archived(self, tm):
84
+ tm.create_task("T-1", [], epic="E-1")
85
+ task = prepare(tm, "T-1")
86
+ assert tm.archive_task(task) is None
87
+
88
+ def test_edited_file_with_block_is_archived(self, tm):
89
+ tm.create_task("T-1", [], epic="E-1")
90
+ task = prepare(tm, "T-1")
91
+ path = task.path / "CLAUDE.md"
92
+ path.write_text(path.read_text() + "\nmy plan\n")
93
+ result = tm.archive_task(task)
94
+ assert result is not None and "CLAUDE.md" in result.files
95
+
96
+ def test_missing_file_not_created_by_epic_change(self, tm):
97
+ task = tm.create_task("T-1", [])
98
+ tm.set_task_epic(task, "E-1")
99
+ assert not (task.path / "CLAUDE.md").exists()
100
+
101
+ def test_cli_epic_add_updates_block(self, tm, config, capsys):
102
+ tm.create_task("T-1", [])
103
+ prepare(tm, "T-1")
104
+ assert run_cli(["epic", "add", "E-1", "T-1"], config=config) == 0
105
+ assert "epic `E-1`" in claude_md(tm, "T-1")
106
+
107
+
108
+ class TestEpicBlockRobustness:
109
+ def test_crlf_file_round_trips(self, tm):
110
+ task = tm.create_task("T-1", [])
111
+ path = task.path / "CLAUDE.md"
112
+ original = b"# Notes\r\n\r\nline\r\n"
113
+ path.write_bytes(original)
114
+ tm.set_task_epic(task, "E-1")
115
+ data = path.read_bytes()
116
+ assert data.startswith(original)
117
+ assert b"\n" not in data.replace(b"\r\n", b"")
118
+ tm.set_task_epic(task, None)
119
+ assert path.read_bytes() == original
120
+
121
+ def test_non_utf8_file_keeps_bytes_and_prep_runs(self, tm):
122
+ task = tm.create_task("T-1", [], epic="E-1")
123
+ path = task.path / "CLAUDE.md"
124
+ path.write_bytes(b"caf\xe9 notes\n")
125
+ tm.ensure_claude_md_files(task) # must not raise
126
+ data = path.read_bytes()
127
+ assert data.startswith(b"caf\xe9 notes\n")
128
+ assert b"epic `E-1`" in data
129
+
130
+ def test_read_only_file_is_reported_not_raised(self, tm):
131
+ task = tm.create_task("T-1", [])
132
+ path = task.path / "CLAUDE.md"
133
+ path.write_text("notes\n")
134
+ os.chmod(path, 0o444)
135
+ try:
136
+ tm.set_task_epic(task, "E-1")
137
+ problem = tm.sync_epic_block(task)
138
+ tm.ensure_claude_md_files(task)
139
+ finally:
140
+ os.chmod(path, 0o644)
141
+ assert "read-only" in problem
142
+ assert path.read_text() == "notes\n"
143
+
144
+ def test_failed_write_keeps_original(self, tm, monkeypatch):
145
+ task = tm.create_task("T-1", [])
146
+ path = task.path / "CLAUDE.md"
147
+ path.write_text("notes\n" * 50)
148
+
149
+ def full_disk(src, dst):
150
+ raise OSError(28, "No space left on device")
151
+
152
+ monkeypatch.setattr(os, "replace", full_disk)
153
+ tm.set_task_epic(task, "E-1")
154
+ assert path.read_text() == "notes\n" * 50
155
+ assert "No space left" in tm.sync_epic_block(task)
156
+ assert [p.name for p in task.path.iterdir() if p.name.endswith(".tmp")] == []
157
+
158
+ def test_unmark_with_planted_parent_marker(self, tm):
159
+ epic = prepare(tm, "E-2")
160
+ (epic.path / ".tasktree_epic").write_text("E-1\n")
161
+ tm.set_task_kind(epic, "task")
162
+ assert EPIC_BLOCK_START not in claude_md(tm, "E-2")
163
+
164
+ def test_broken_markers_count_as_edited_for_archive(self, tm):
165
+ tm.create_task("T-1", [], epic="E-1")
166
+ task = prepare(tm, "T-1")
167
+ path = task.path / "CLAUDE.md"
168
+ path.write_text(path.read_text() + EPIC_BLOCK_START + "\n")
169
+ result = tm.archive_task(task)
170
+ assert result is not None and "CLAUDE.md" in result.files
171
+
172
+ def test_cli_warns_on_mismatched_markers(self, tm, config, capsys):
173
+ task = tm.create_task("T-1", [])
174
+ (task.path / "CLAUDE.md").write_text(EPIC_BLOCK_START + "\n")
175
+ assert run_cli(["epic", "add", "E-1", "T-1"], config=config) == 0
176
+ assert "mismatched tasktree:epic markers" in capsys.readouterr().err