tasktree-manager 2.15.0__tar.gz → 2.16.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.github/workflows/ci.yml +8 -2
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/CHANGELOG.md +12 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/PKG-INFO +1 -1
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/README.md +6 -5
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/configuration.md +3 -1
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/user-guide.md +4 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/pyproject.toml +1 -1
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/app.py +17 -6
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/cli.py +18 -3
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/models.py +64 -17
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/task_manager.py +193 -51
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/task_list.py +5 -2
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/conftest.py +7 -1
- tasktree_manager-2.16.1/tests/test_epic_claude_md.py +176 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_epic_tui.py +25 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_epics.py +78 -1
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_safety_fixes.py +4 -1
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/.gitignore +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/LICENSE +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/ROADMAP.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/installation.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/mise.toml +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/agent_sessions.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/config.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/__init__.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_app.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_app_review_fixes.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_cli.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_commands.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_config.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_forge.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_memory.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_modals.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_task_manager.py +0 -0
- {tasktree_manager-2.15.0 → tasktree_manager-2.16.1}/tests/test_worktree_badges.py +0 -0
|
@@ -31,12 +31,18 @@ jobs:
|
|
|
31
31
|
run: ruff format --check tasktree_manager tests
|
|
32
32
|
|
|
33
33
|
test:
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# Linux jobs keep the names branch protection requires
|
|
35
|
+
name: ${{ matrix.os == 'macos-latest' && format('Test (macOS, Python {0})', matrix.python-version) || format('Test (Python {0})', matrix.python-version) }}
|
|
36
|
+
runs-on: ${{ matrix.os }}
|
|
36
37
|
strategy:
|
|
37
38
|
fail-fast: false
|
|
38
39
|
matrix:
|
|
40
|
+
os: [ubuntu-latest]
|
|
39
41
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
42
|
+
include:
|
|
43
|
+
# Case-insensitive APFS: runs the case-variant epic tests Linux skips
|
|
44
|
+
- os: macos-latest
|
|
45
|
+
python-version: "3.13"
|
|
40
46
|
|
|
41
47
|
steps:
|
|
42
48
|
- name: Checkout code
|
|
@@ -15,6 +15,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
15
15
|
- Git hooks support
|
|
16
16
|
- Custom task metadata
|
|
17
17
|
|
|
18
|
+
## [2.16.1] - 2026-10-19
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **epic**: One membership rule and safer marker writes (#12)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## [2.16.0] - 2026-10-19
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **epic**: Add a managed epic block to task CLAUDE.md files
|
|
28
|
+
|
|
29
|
+
|
|
18
30
|
## [2.15.0] - 2026-10-19
|
|
19
31
|
|
|
20
32
|
### Added
|
|
@@ -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
|
|
|
@@ -611,7 +611,8 @@ All 27 customizable keybindings with descriptions:
|
|
|
611
611
|
| `add_repo` | `a` | Add repository to current task | `add_repo = "ctrl+a"` |
|
|
612
612
|
| `delete_task` | `d` | Delete/finish current task | `delete_task = "delete"` |
|
|
613
613
|
| `rename_task` | `R` | Rename task display label (alias only) | `rename_task = "ctrl+r"` |
|
|
614
|
-
| `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+e"`
|
|
614
|
+
| `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+shift+e"` |
|
|
615
|
+
| `delete_worktree` | `D` | Remove the selected worktree (the task stays) | `delete_worktree = "ctrl+d"` |
|
|
615
616
|
|
|
616
617
|
### Git Operations
|
|
617
618
|
|
|
@@ -632,6 +633,7 @@ All 27 customizable keybindings with descriptions:
|
|
|
632
633
|
| `open_claude_resume` | `c` | Open Claude CLI (resume if session exists, else new) | `open_claude_resume = "ctrl+c"` |
|
|
633
634
|
| `open_claude_gui_code`| `C` | Open Claude desktop on Code page in task folder | `open_claude_gui_code = "ctrl+shift+c"` |
|
|
634
635
|
| `toggle_messages` | `m` | Show/hide activity messages panel | `toggle_messages = "ctrl+m"` |
|
|
636
|
+
| `dispatch_agent` | `b` | Dispatch a background Claude agent into the worktree | `dispatch_agent = "ctrl+b"` |
|
|
635
637
|
|
|
636
638
|
### Navigation
|
|
637
639
|
|
|
@@ -85,6 +85,10 @@ names their epic, so nothing moves on disk.
|
|
|
85
85
|
- `enter` or `space` folds an epic. A folded epic row still shows the dirty
|
|
86
86
|
count and the most urgent Claude state of its tasks.
|
|
87
87
|
- Epics don't nest. An epic that still has tasks can't be deleted.
|
|
88
|
+
- Each task's `CLAUDE.md` gets a short block between `<!-- tasktree:epic -->` markers
|
|
89
|
+
that names the epic folder and how to find sibling tasks, so a Claude session knows
|
|
90
|
+
the bigger picture. It updates on every epic change; text outside the markers is
|
|
91
|
+
never touched.
|
|
88
92
|
- From scripts: `tasktree-manager epic create/add/remove/mark/unmark/list`.
|
|
89
93
|
|
|
90
94
|
### Worktrees
|
|
@@ -71,7 +71,7 @@ version-file = "tasktree_manager/_version.py"
|
|
|
71
71
|
# Semantic Release Configuration
|
|
72
72
|
[tool.semantic_release]
|
|
73
73
|
version_toml = ["pyproject.toml:tool.semantic_release.version"]
|
|
74
|
-
version = "2.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1473
|
-
|
|
1477
|
+
parent,
|
|
1478
|
+
parent is None,
|
|
1474
1479
|
),
|
|
1475
1480
|
handle_choice,
|
|
1476
1481
|
)
|
|
@@ -1521,6 +1526,10 @@ class TaskTreeApp(App):
|
|
|
1521
1526
|
return
|
|
1522
1527
|
self.notify(escape(message))
|
|
1523
1528
|
self._log_activity(message, MessageLevel.INFO, task.name)
|
|
1529
|
+
problem = self.task_manager.sync_epic_block(task)
|
|
1530
|
+
if problem:
|
|
1531
|
+
self.notify(escape(problem), severity="warning")
|
|
1532
|
+
self._log_activity(problem, MessageLevel.WARNING, task.name)
|
|
1524
1533
|
self._load_tasks(select_task=task.name)
|
|
1525
1534
|
|
|
1526
1535
|
def _finish_task(self, task: Task, force: bool = False) -> None:
|
|
@@ -2080,7 +2089,9 @@ class TaskTreeApp(App):
|
|
|
2080
2089
|
"""
|
|
2081
2090
|
fresh_task = self.task_manager.get_task(task.name)
|
|
2082
2091
|
if fresh_task:
|
|
2083
|
-
self.task_manager.ensure_claude_md_files(fresh_task)
|
|
2092
|
+
problem = self.task_manager.ensure_claude_md_files(fresh_task)
|
|
2093
|
+
if problem:
|
|
2094
|
+
self.call_from_thread(self.notify, escape(problem), severity="warning")
|
|
2084
2095
|
self.task_manager.ensure_worktree_settings(fresh_task)
|
|
2085
2096
|
ensure_claude_hooks(task.path, self.config.claude_memory_dir)
|
|
2086
2097
|
|
|
@@ -8,6 +8,7 @@ errors on stderr, exit code 0 on success and 1 on failure.
|
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import json
|
|
11
|
+
import os
|
|
11
12
|
import shutil
|
|
12
13
|
import sys
|
|
13
14
|
from pathlib import Path
|
|
@@ -16,7 +17,7 @@ from . import __version__
|
|
|
16
17
|
from .services import forge
|
|
17
18
|
from .services.config import Config, ConfigError
|
|
18
19
|
from .services.git_ops import GitOps
|
|
19
|
-
from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task
|
|
20
|
+
from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task, parent_epic_name
|
|
20
21
|
from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
|
|
21
22
|
|
|
22
23
|
|
|
@@ -213,9 +214,17 @@ def _create(
|
|
|
213
214
|
if task.epic_name is not None:
|
|
214
215
|
line += f" in epic {task.epic_name}"
|
|
215
216
|
print(line)
|
|
217
|
+
_warn_epic_block(manager, task)
|
|
216
218
|
return 0
|
|
217
219
|
|
|
218
220
|
|
|
221
|
+
def _warn_epic_block(manager: TaskManager, task: Task) -> None:
|
|
222
|
+
"""Print why the task's CLAUDE.md epic block could not follow a change."""
|
|
223
|
+
problem = manager.sync_epic_block(task)
|
|
224
|
+
if problem:
|
|
225
|
+
print(f"warning: {problem}", file=sys.stderr)
|
|
226
|
+
|
|
227
|
+
|
|
219
228
|
def _get_tasks(manager: TaskManager, names: list[str]) -> list[Task] | None:
|
|
220
229
|
"""Resolve task names, printing an error for each missing one (None if any)."""
|
|
221
230
|
tasks = []
|
|
@@ -250,6 +259,7 @@ def cmd_epic_add(manager: TaskManager, config: Config, args: argparse.Namespace)
|
|
|
250
259
|
print(f"Added {task.name} to epic {epic}")
|
|
251
260
|
else:
|
|
252
261
|
print(f"Moved {task.name} from epic {previous} to epic {epic}")
|
|
262
|
+
_warn_epic_block(manager, task)
|
|
253
263
|
return code
|
|
254
264
|
|
|
255
265
|
|
|
@@ -265,6 +275,7 @@ def cmd_epic_remove(manager: TaskManager, config: Config, args: argparse.Namespa
|
|
|
265
275
|
print(f"Task {task.name} is not in an epic")
|
|
266
276
|
else:
|
|
267
277
|
print(f"Removed {task.name} from epic {previous}")
|
|
278
|
+
_warn_epic_block(manager, task)
|
|
268
279
|
return 0
|
|
269
280
|
|
|
270
281
|
|
|
@@ -278,6 +289,7 @@ def cmd_epic_mark(manager: TaskManager, config: Config, args: argparse.Namespace
|
|
|
278
289
|
return 0
|
|
279
290
|
manager.set_task_kind(task, EPIC_KIND)
|
|
280
291
|
print(f"Marked {args.name} as an epic")
|
|
292
|
+
_warn_epic_block(manager, task)
|
|
281
293
|
return 0
|
|
282
294
|
|
|
283
295
|
|
|
@@ -291,6 +303,7 @@ def cmd_epic_unmark(manager: TaskManager, config: Config, args: argparse.Namespa
|
|
|
291
303
|
return 0
|
|
292
304
|
manager.set_task_kind(task, TASK_KIND)
|
|
293
305
|
print(f"Unmarked {args.name}: it is a plain task now")
|
|
306
|
+
_warn_epic_block(manager, task)
|
|
294
307
|
return 0
|
|
295
308
|
|
|
296
309
|
|
|
@@ -342,6 +355,8 @@ def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> b
|
|
|
342
355
|
def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
343
356
|
"""List tasks with their repos and dirty state."""
|
|
344
357
|
tasks = manager.list_tasks()
|
|
358
|
+
# One TASKS_DIR listing for every parent_epic_name lookup below
|
|
359
|
+
listing = set(os.listdir(config.tasks_dir)) if config.tasks_dir.is_dir() else set()
|
|
345
360
|
dirty: dict[str, bool] = {}
|
|
346
361
|
errored: dict[str, bool] = {}
|
|
347
362
|
for task in tasks:
|
|
@@ -357,7 +372,7 @@ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) ->
|
|
|
357
372
|
"name": task.name,
|
|
358
373
|
"display_name": task.display_name,
|
|
359
374
|
"kind": task.kind,
|
|
360
|
-
"epic": task
|
|
375
|
+
"epic": parent_epic_name(task, listing),
|
|
361
376
|
"path": str(task.path),
|
|
362
377
|
"repos": [wt.name for wt in task.worktrees],
|
|
363
378
|
"dirty": dirty[task.name],
|
|
@@ -623,7 +638,7 @@ def cmd_status(manager: TaskManager, config: Config, args: argparse.Namespace) -
|
|
|
623
638
|
payload = {
|
|
624
639
|
"task": task.name,
|
|
625
640
|
"kind": task.kind,
|
|
626
|
-
"epic": task
|
|
641
|
+
"epic": parent_epic_name(task),
|
|
627
642
|
"path": str(task.path),
|
|
628
643
|
"claude": claude,
|
|
629
644
|
"worktrees": [
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
+
from collections.abc import Collection
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
@@ -128,23 +129,60 @@ class Task:
|
|
|
128
129
|
return value
|
|
129
130
|
|
|
130
131
|
|
|
131
|
-
def
|
|
132
|
-
"""The
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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:
|