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.
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/CHANGELOG.md +6 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/PKG-INFO +1 -1
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/README.md +6 -5
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/user-guide.md +4 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/pyproject.toml +1 -1
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/app.py +4 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/cli.py +12 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/task_manager.py +141 -8
- tasktree_manager-2.16.0/tests/test_epic_claude_md.py +176 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.gitignore +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/LICENSE +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/configuration.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/installation.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/mise.toml +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/agent_sessions.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/config.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/models.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_app_review_fixes.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_cli.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_config.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_epic_tui.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_epics.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_forge.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tests/test_task_manager.py +0 -0
- {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
|
|
@@ -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
|
-
|
|
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
|
|
231
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
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
|
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/task_manager.py
RENAMED
|
@@ -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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
758
|
-
#
|
|
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
|
-
|
|
984
|
-
|
|
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
|
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/README.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/data_viewer.py
RENAMED
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/todo_app.py
RENAMED
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/assets/worker_demo.py
RENAMED
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/layouts.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/styling.md
RENAMED
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/.claude/skills/textual-tui/references/widgets.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/agent_sessions.py
RENAMED
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/claude_hooks.py
RENAMED
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/services/claude_sessions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/create_modal.py
RENAMED
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/messages_panel.py
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/status_panel.py
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.15.0 → tasktree_manager-2.16.0}/tasktree_manager/widgets/worktree_list.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|