tasktree-manager 2.13.0__tar.gz → 2.13.2__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.13.0 → tasktree_manager-2.13.2}/.github/workflows/release.yml +17 -2
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.gitignore +1 -1
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/CHANGELOG.md +21 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/PKG-INFO +1 -1
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/README.md +4 -2
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/docs/configuration.md +7 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/docs/user-guide.md +3 -2
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/pyproject.toml +1 -1
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/scripts/bump_version.py +17 -4
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/app.py +170 -34
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/cli.py +48 -15
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/agent_sessions.py +1 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/claude_hooks.py +64 -26
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/config.py +54 -11
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/forge.py +1 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/git_ops.py +14 -5
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/models.py +3 -1
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/task_manager.py +278 -53
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/widgets/create_modal.py +12 -4
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/widgets/setup_modal.py +9 -4
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/widgets/task_list.py +9 -12
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/widgets/worktree_list.py +9 -2
- tasktree_manager-2.13.2/tests/test_agent_sessions_decode.py +31 -0
- tasktree_manager-2.13.2/tests/test_app_review_fixes.py +511 -0
- tasktree_manager-2.13.2/tests/test_bump_version.py +135 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_claude_hooks.py +142 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_claude_sessions.py +21 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_cli.py +153 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_config.py +131 -1
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_forge.py +21 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_git_ops.py +64 -0
- tasktree_manager-2.13.2/tests/test_list_selection.py +130 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_modals.py +78 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_task_manager.py +412 -1
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_worktree_badges.py +1 -1
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/LICENSE +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/OPENSPEC.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/ROADMAP.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/docs/installation.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/mise.toml +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/__init__.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/conftest.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_app.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_commands.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_memory.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.13.0 → tasktree_manager-2.13.2}/tests/test_status_panel.py +0 -0
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
# - Manual dispatch: choose release type, pre-release, and draft options.
|
|
15
15
|
# Both `draft` and `pre_release` skip the PyPI upload (the pipeline can
|
|
16
16
|
# only produce final X.Y.Z versions) and only shape the GitHub release.
|
|
17
|
+
# workflow_dispatch can target ANY branch, not just main (branch
|
|
18
|
+
# protection also has enforce_admins=false), so prepare-release and
|
|
19
|
+
# tag-release both refuse to run off main — see their `if:` guards.
|
|
17
20
|
|
|
18
21
|
name: Release
|
|
19
22
|
|
|
@@ -46,6 +49,12 @@ on:
|
|
|
46
49
|
jobs:
|
|
47
50
|
prepare-release:
|
|
48
51
|
name: Prepare Release
|
|
52
|
+
# workflow_dispatch can target any branch; the push trigger is already
|
|
53
|
+
# restricted to main via `on.push.branches`, so this only closes the
|
|
54
|
+
# workflow_dispatch path. Everything downstream (lint/test/tag-release/
|
|
55
|
+
# build/publish-pypi) needs this job and is skipped by cascade when it
|
|
56
|
+
# is; github-release guards itself separately via needs.build.result.
|
|
57
|
+
if: github.ref == 'refs/heads/main'
|
|
49
58
|
runs-on: ubuntu-latest
|
|
50
59
|
outputs:
|
|
51
60
|
new_version: ${{ steps.determine_version.outputs.new_version }}
|
|
@@ -174,6 +183,10 @@ jobs:
|
|
|
174
183
|
tag-release:
|
|
175
184
|
name: Commit and Tag
|
|
176
185
|
needs: [prepare-release, lint, test]
|
|
186
|
+
# Belt and suspenders: this is the job that pushes to main and pushes
|
|
187
|
+
# the tag using the owner PAT, so it gets its own explicit ref guard
|
|
188
|
+
# rather than relying only on the cascade from prepare-release.
|
|
189
|
+
if: github.ref == 'refs/heads/main'
|
|
177
190
|
runs-on: ubuntu-latest
|
|
178
191
|
steps:
|
|
179
192
|
- name: Checkout code
|
|
@@ -206,8 +219,10 @@ jobs:
|
|
|
206
219
|
git diff --cached --quiet && echo "No changes to commit" || \
|
|
207
220
|
git commit -m "chore(release): ${new_version} [skip ci]"
|
|
208
221
|
git tag -a "v${new_version}" -m "Release v${new_version}"
|
|
209
|
-
|
|
210
|
-
|
|
222
|
+
# Atomic: one push carrying both refs, so a rejected tag (already
|
|
223
|
+
# exists, race with another push) can never leave main bumped
|
|
224
|
+
# without the tag that's supposed to go with it.
|
|
225
|
+
git push --atomic origin HEAD:main "v${new_version}"
|
|
211
226
|
|
|
212
227
|
build:
|
|
213
228
|
name: Build Package
|
|
@@ -15,6 +15,27 @@ 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.13.2] - 2026-09-28
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Document list error state, config value types and task name rules
|
|
22
|
+
- **release**: Release only from main and push main and tag atomically
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **tui**: Harden workers, refresh and selection handling
|
|
26
|
+
- **cli**: Report status errors, setup failures and missing git correctly
|
|
27
|
+
- **claude**: Resolve symlinked paths and write settings atomically
|
|
28
|
+
- **config**: Validate booleans and section tables in config.toml
|
|
29
|
+
- **git**: Count partially staged files as staged and modified
|
|
30
|
+
- **tasks**: Protect existing branches and unchecked worktrees on create/delete
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## [2.13.1] - 2026-09-16
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- **tui**: Size modal button row to its buttons
|
|
37
|
+
|
|
38
|
+
|
|
18
39
|
## [2.13.0] - 2026-09-11
|
|
19
40
|
|
|
20
41
|
### Added
|
|
@@ -78,7 +78,7 @@ workflows (e.g. a skill that creates a Jira ticket and then the matching task):
|
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
80
|
tasktree-manager create DIC-1901-argocd-tls --repos backend,frontend # --base overrides the default base branch
|
|
81
|
-
tasktree-manager list --json # tasks with repos and dirty
|
|
81
|
+
tasktree-manager list --json # tasks with repos and state: clean / dirty / error (plain text without --json)
|
|
82
82
|
tasktree-manager repos # available repos in REPOS_DIR
|
|
83
83
|
tasktree-manager add-repo DIC-1901-argocd-tls infra
|
|
84
84
|
tasktree-manager status # current task inferred from $PWD; also --json / --oneline / --forge
|
|
@@ -88,7 +88,9 @@ tasktree-manager delete DIC-1901-argocd-tls # archive diff -> delete; refuses
|
|
|
88
88
|
|
|
89
89
|
Exit code is 0 on success, 1 on failure with the reason on stderr. `delete` and `finish` run
|
|
90
90
|
the same safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion);
|
|
91
|
-
with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged.
|
|
91
|
+
with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
|
|
92
|
+
diff cannot be archived for any repo, the CLI stops before deleting and prints the path of
|
|
93
|
+
the partial archive; the TUI warns and deletes anyway.
|
|
92
94
|
`status --oneline` emits a compact `TASK repoA✓ repoB●2↑1` summary that slots straight into
|
|
93
95
|
a Claude Code statusline or shell prompt.
|
|
94
96
|
|
|
@@ -59,6 +59,13 @@ rm ~/.config/tasktree-manager/config.toml
|
|
|
59
59
|
tasktree-manager
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
### Value Types
|
|
63
|
+
|
|
64
|
+
Boolean settings take TOML `true`/`false`; the quoted strings `"true"`/`"yes"`/`"1"`
|
|
65
|
+
and `"false"`/`"no"`/`"0"` (any case) are accepted too. Any other value, or a section such
|
|
66
|
+
as `[keybindings]` set to a plain value instead of a table, stops startup with an error that
|
|
67
|
+
names the key.
|
|
68
|
+
|
|
62
69
|
## Complete Configuration Example
|
|
63
70
|
|
|
64
71
|
Here's a fully annotated `config.toml` with all available options:
|
|
@@ -328,8 +328,9 @@ what is displayed. Press `?` for the full reference at any time.
|
|
|
328
328
|
6. Tab to "Create" button, press `Enter`
|
|
329
329
|
|
|
330
330
|
Task names may contain letters, numbers, `.`, `_` and `-`. The name becomes a
|
|
331
|
-
single directory under your tasks dir (and a git branch), so `/`,
|
|
332
|
-
|
|
331
|
+
single directory under your tasks dir (and a git branch), so `/`, a leading `.`
|
|
332
|
+
(hidden from the task list, and `.archive` is the archive dir) and a leading `-`
|
|
333
|
+
are rejected; base branch names must be plain branch names too (no
|
|
333
334
|
leading `-`/`+`, no `:` refspecs, none of the characters git forbids in refs).
|
|
334
335
|
|
|
335
336
|
**Clone task workflow:**
|
|
@@ -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.13.
|
|
74
|
+
version = "2.13.2"
|
|
75
75
|
tag_format = "v{version}"
|
|
76
76
|
branch = "main"
|
|
77
77
|
commit_message = "chore(release): {version} [skip ci]"
|
|
@@ -117,8 +117,14 @@ def update_pyproject_version(current_version: str, new_version: str, dry_run: bo
|
|
|
117
117
|
if in_section and re.match(
|
|
118
118
|
r'^version\s*=\s*"' + re.escape(current_version) + '"', stripped
|
|
119
119
|
):
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
new_line = line.replace(f'"{current_version}"', f'"{new_version}"')
|
|
121
|
+
# A `--set` to the version that's already current (e.g. a retried
|
|
122
|
+
# release re-running against an already-bumped commit) makes the
|
|
123
|
+
# regex match but shouldn't count as a change — only the text
|
|
124
|
+
# actually differing does.
|
|
125
|
+
if new_line != line:
|
|
126
|
+
changed = True
|
|
127
|
+
line = new_line
|
|
122
128
|
|
|
123
129
|
updated_lines.append(line)
|
|
124
130
|
|
|
@@ -236,6 +242,15 @@ def update_changelog(new_version: str, message: str = "", dry_run: bool = False)
|
|
|
236
242
|
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n"
|
|
237
243
|
)
|
|
238
244
|
|
|
245
|
+
content = CHANGELOG_PATH.read_text() if CHANGELOG_PATH.exists() else ""
|
|
246
|
+
|
|
247
|
+
# A retried release (e.g. re-running after tag-release failed partway)
|
|
248
|
+
# can call this again for a version that's already documented — don't
|
|
249
|
+
# insert a second copy of its section.
|
|
250
|
+
if f"## [{new_version}]" in content:
|
|
251
|
+
print(f"CHANGELOG.md already has an entry for version {new_version}, skipping")
|
|
252
|
+
return False
|
|
253
|
+
|
|
239
254
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
240
255
|
|
|
241
256
|
if message:
|
|
@@ -252,8 +267,6 @@ def update_changelog(new_version: str, message: str = "", dry_run: bool = False)
|
|
|
252
267
|
print(f"Would add new entry to CHANGELOG.md for version {new_version}")
|
|
253
268
|
return True
|
|
254
269
|
|
|
255
|
-
content = CHANGELOG_PATH.read_text()
|
|
256
|
-
|
|
257
270
|
# Insert after [Unreleased] section or at top after header
|
|
258
271
|
if "## [Unreleased]" in content:
|
|
259
272
|
# Find [Unreleased] section and insert new version after it
|
|
@@ -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.13.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 13,
|
|
21
|
+
__version__ = version = '2.13.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 13, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -536,7 +536,7 @@ class TaskTreeApp(App):
|
|
|
536
536
|
self.config.save()
|
|
537
537
|
except OSError as e:
|
|
538
538
|
self.push_screen(
|
|
539
|
-
SetupModal(error_message=f"Could not set up directories: {e}"),
|
|
539
|
+
SetupModal(error_message=escape(f"Could not set up directories: {e}")),
|
|
540
540
|
handle_setup,
|
|
541
541
|
)
|
|
542
542
|
return
|
|
@@ -628,6 +628,11 @@ class TaskTreeApp(App):
|
|
|
628
628
|
if self._mutation_in_flight():
|
|
629
629
|
self.notify("Another task operation is still running", severity="warning")
|
|
630
630
|
return False
|
|
631
|
+
# A periodic scan already in flight would otherwise apply its
|
|
632
|
+
# pre-mutation snapshot after this mutation finishes, clobbering the
|
|
633
|
+
# loading indicators set below (see the worker check in
|
|
634
|
+
# _apply_refreshed_tasks, its belt-and-suspenders counterpart).
|
|
635
|
+
self.workers.cancel_group(self, "auto_refresh")
|
|
631
636
|
for widget_id in loading_widget_ids:
|
|
632
637
|
self.query_one(f"#{widget_id}").loading = True
|
|
633
638
|
return True
|
|
@@ -642,12 +647,19 @@ class TaskTreeApp(App):
|
|
|
642
647
|
statuses: dict[str, str] = {}
|
|
643
648
|
for task in task_list.tasks:
|
|
644
649
|
status_file = task.path / ".claude_status"
|
|
645
|
-
if status_file.exists():
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
650
|
+
if not status_file.exists():
|
|
651
|
+
continue
|
|
652
|
+
try:
|
|
653
|
+
data = json.loads(status_file.read_text())
|
|
654
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
655
|
+
# ValueError also catches json.JSONDecodeError (a subclass);
|
|
656
|
+
# UnicodeDecodeError covers a non-UTF-8 status file
|
|
657
|
+
continue
|
|
658
|
+
if not isinstance(data, dict):
|
|
659
|
+
# Valid JSON but not an object (e.g. "[]") - .get() below
|
|
660
|
+
# would raise AttributeError and kill the poll interval
|
|
661
|
+
continue
|
|
662
|
+
statuses[task.name] = data.get("status", "unknown")
|
|
651
663
|
|
|
652
664
|
if statuses != self._claude_statuses:
|
|
653
665
|
self._claude_statuses = statuses
|
|
@@ -880,7 +892,12 @@ class TaskTreeApp(App):
|
|
|
880
892
|
entirely to avoid selection/status-panel flicker. A snapshot from a
|
|
881
893
|
scan that was superseded while this call sat in the queue is dropped.
|
|
882
894
|
"""
|
|
883
|
-
|
|
895
|
+
# `worker` is only passed by the background auto_refresh scan. Skip
|
|
896
|
+
# its apply if it was cancelled, or if a mutation started after the
|
|
897
|
+
# scan's git calls finished but before this callback reached the
|
|
898
|
+
# main thread - either way this is a pre-mutation snapshot that
|
|
899
|
+
# must not clobber the mutation's own loading state and reload.
|
|
900
|
+
if worker is not None and (worker.is_cancelled or self._mutation_in_flight()):
|
|
884
901
|
return
|
|
885
902
|
try:
|
|
886
903
|
task_list = self.query_one("#task-list", TaskList)
|
|
@@ -896,7 +913,6 @@ class TaskTreeApp(App):
|
|
|
896
913
|
fingerprint = self._tasks_fingerprint(tasks)
|
|
897
914
|
if not force_ui and fingerprint == self._last_tasks_fingerprint:
|
|
898
915
|
return
|
|
899
|
-
self._last_tasks_fingerprint = fingerprint
|
|
900
916
|
self._update_header_stats(tasks)
|
|
901
917
|
|
|
902
918
|
# Prune forge entries for worktrees that no longer exist — a deleted
|
|
@@ -927,6 +943,7 @@ class TaskTreeApp(App):
|
|
|
927
943
|
status_panel.clear_status()
|
|
928
944
|
self._set_counter("task-panel-counter", "")
|
|
929
945
|
self._set_counter("worktree-panel-counter", "")
|
|
946
|
+
self._last_tasks_fingerprint = fingerprint
|
|
930
947
|
return
|
|
931
948
|
|
|
932
949
|
# The worktree list is (re)loaded by the TaskHighlighted handler
|
|
@@ -939,8 +956,13 @@ class TaskTreeApp(App):
|
|
|
939
956
|
self.current_task = t
|
|
940
957
|
break
|
|
941
958
|
self._push_badges_to_worktree_list()
|
|
959
|
+
self._last_tasks_fingerprint = fingerprint
|
|
942
960
|
except Exception as e:
|
|
943
|
-
# A worker error would exit the app; refresh glitches are not
|
|
961
|
+
# A worker error would exit the app; refresh glitches are not
|
|
962
|
+
# fatal. Deliberately leave _last_tasks_fingerprint at its prior
|
|
963
|
+
# value: the UI never actually reflected this data, so a later
|
|
964
|
+
# refresh with the same (still-unapplied) snapshot must retry
|
|
965
|
+
# instead of being skipped as a no-op.
|
|
944
966
|
self.log.error(f"Failed to apply refreshed tasks: {e}")
|
|
945
967
|
|
|
946
968
|
def _run_external_command(
|
|
@@ -1288,6 +1310,8 @@ class TaskTreeApp(App):
|
|
|
1288
1310
|
def _check_task_safety_worker(self, task: Task) -> None:
|
|
1289
1311
|
"""Run task safety checks in a background thread."""
|
|
1290
1312
|
safety_report = self.task_manager.check_task_safety(task)
|
|
1313
|
+
if get_current_worker().is_cancelled:
|
|
1314
|
+
return
|
|
1291
1315
|
self.call_from_thread(self._show_delete_task_dialog, task, safety_report)
|
|
1292
1316
|
|
|
1293
1317
|
def _show_delete_task_dialog(self, task: Task, safety_report: TaskSafetyReport) -> None:
|
|
@@ -1365,11 +1389,17 @@ class TaskTreeApp(App):
|
|
|
1365
1389
|
except Exception as e:
|
|
1366
1390
|
# A failed archive must not block deletion, but it must be
|
|
1367
1391
|
# loud — the archive is the safety net for the work the
|
|
1368
|
-
# delete below is about to destroy
|
|
1392
|
+
# delete below is about to destroy (ArchiveIncompleteError's
|
|
1393
|
+
# message names the partial archive, if one was written)
|
|
1369
1394
|
detail = f"Archive failed for '{task.name}': {type(e).__name__}: {e}"
|
|
1370
1395
|
self.call_from_thread(self.notify, escape(detail), severity="warning", timeout=10)
|
|
1371
1396
|
self.call_from_thread(self._log_activity, detail, MessageLevel.ERROR, task.name)
|
|
1372
1397
|
self.task_manager.finish_task(task)
|
|
1398
|
+
except ValueError as e:
|
|
1399
|
+
# finish_task raises ValueError (nothing removed) when worktrees
|
|
1400
|
+
# on disk no longer match task.worktrees - the task changed
|
|
1401
|
+
# during the confirm dialog. Its message is already clear.
|
|
1402
|
+
error = str(e)
|
|
1373
1403
|
except Exception as e:
|
|
1374
1404
|
error = f"{type(e).__name__}: {e}"
|
|
1375
1405
|
self.call_from_thread(self._apply_finish_task_result, task.name, force, error, archive_path)
|
|
@@ -1385,6 +1415,14 @@ class TaskTreeApp(App):
|
|
|
1385
1415
|
if error:
|
|
1386
1416
|
self.notify(escape(f"Failed to delete task: {error}"), severity="error")
|
|
1387
1417
|
self._log_activity(f"Failed to delete task: {error}", MessageLevel.ERROR, task_name)
|
|
1418
|
+
if archive_path is not None:
|
|
1419
|
+
# The archive succeeded before the delete itself failed - the
|
|
1420
|
+
# user's work is not lost, but they should know where it is
|
|
1421
|
+
self._log_activity(
|
|
1422
|
+
f"Archived diff to {archive_path} before the failed delete",
|
|
1423
|
+
MessageLevel.INFO,
|
|
1424
|
+
task_name,
|
|
1425
|
+
)
|
|
1388
1426
|
else:
|
|
1389
1427
|
self.notify(escape(f"Deleted task: {task_name}"))
|
|
1390
1428
|
suffix = " (force)" if force else ""
|
|
@@ -1420,6 +1458,8 @@ class TaskTreeApp(App):
|
|
|
1420
1458
|
"""Push all branches and re-run safety checks in a background thread."""
|
|
1421
1459
|
success_repos, failed_repos = self.task_manager.push_all_branches(task)
|
|
1422
1460
|
new_report = self.task_manager.check_task_safety(task)
|
|
1461
|
+
if get_current_worker().is_cancelled:
|
|
1462
|
+
return
|
|
1423
1463
|
self.call_from_thread(
|
|
1424
1464
|
self._apply_delete_push_results, task, success_repos, failed_repos, new_report
|
|
1425
1465
|
)
|
|
@@ -1479,6 +1519,8 @@ class TaskTreeApp(App):
|
|
|
1479
1519
|
def _check_worktree_safety_worker(self, task: Task, worktree: Worktree) -> None:
|
|
1480
1520
|
"""Run the safety check for a single worktree in a background thread."""
|
|
1481
1521
|
report = self.task_manager.check_task_safety(self._worktree_view(task, worktree))
|
|
1522
|
+
if get_current_worker().is_cancelled:
|
|
1523
|
+
return
|
|
1482
1524
|
self.call_from_thread(self._show_delete_worktree_dialog, task, worktree, report)
|
|
1483
1525
|
|
|
1484
1526
|
def _show_delete_worktree_dialog(
|
|
@@ -1529,6 +1571,8 @@ class TaskTreeApp(App):
|
|
|
1529
1571
|
view = self._worktree_view(task, worktree)
|
|
1530
1572
|
success, failed = self.task_manager.push_all_branches(view)
|
|
1531
1573
|
report = self.task_manager.check_task_safety(view)
|
|
1574
|
+
if get_current_worker().is_cancelled:
|
|
1575
|
+
return
|
|
1532
1576
|
self.call_from_thread(
|
|
1533
1577
|
self._apply_worktree_push_result, task, worktree, success, failed, report
|
|
1534
1578
|
)
|
|
@@ -1603,6 +1647,12 @@ class TaskTreeApp(App):
|
|
|
1603
1647
|
MessageLevel.ERROR,
|
|
1604
1648
|
task_name,
|
|
1605
1649
|
)
|
|
1650
|
+
if archive_path is not None:
|
|
1651
|
+
self._log_activity(
|
|
1652
|
+
f"Archived diff to {archive_path} before the failed delete",
|
|
1653
|
+
MessageLevel.INFO,
|
|
1654
|
+
task_name,
|
|
1655
|
+
)
|
|
1606
1656
|
else:
|
|
1607
1657
|
self.notify(escape(f"Deleted worktree: {worktree_name}"))
|
|
1608
1658
|
suffix = " (force)" if force else ""
|
|
@@ -1828,13 +1878,20 @@ class TaskTreeApp(App):
|
|
|
1828
1878
|
self._open_ghostty_tab(folder_path, command=f"{editor} .")
|
|
1829
1879
|
self.notify(escape(f"Opened {editor} in new tab"))
|
|
1830
1880
|
|
|
1831
|
-
def _prepare_claude_session(self,
|
|
1832
|
-
"""Refresh CLAUDE.md files, worktree settings, and hooks before launching Claude.
|
|
1833
|
-
|
|
1881
|
+
def _prepare_claude_session(self, task: Task) -> None:
|
|
1882
|
+
"""Refresh CLAUDE.md files, worktree settings, and hooks before launching Claude.
|
|
1883
|
+
|
|
1884
|
+
Must run off the UI thread: ensure_claude_md_files can shell out to
|
|
1885
|
+
git (symbolic-ref/show, each with a 10s timeout) once per worktree
|
|
1886
|
+
missing a CLAUDE.md. Every caller runs this inside a
|
|
1887
|
+
@work(thread=True) worker and passes the task explicitly rather than
|
|
1888
|
+
reading self.current_task, which is not safe to read from a thread.
|
|
1889
|
+
"""
|
|
1890
|
+
fresh_task = self.task_manager.get_task(task.name)
|
|
1834
1891
|
if fresh_task:
|
|
1835
1892
|
self.task_manager.ensure_claude_md_files(fresh_task)
|
|
1836
1893
|
self.task_manager.ensure_worktree_settings(fresh_task)
|
|
1837
|
-
ensure_claude_hooks(
|
|
1894
|
+
ensure_claude_hooks(task.path, self.config.claude_memory_dir)
|
|
1838
1895
|
|
|
1839
1896
|
def action_open_claude_resume(self) -> None:
|
|
1840
1897
|
"""Open Claude Code in a new Ghostty tab.
|
|
@@ -1846,13 +1903,36 @@ class TaskTreeApp(App):
|
|
|
1846
1903
|
self.notify("No task selected", severity="warning")
|
|
1847
1904
|
return
|
|
1848
1905
|
|
|
1849
|
-
|
|
1850
|
-
if not
|
|
1906
|
+
task = self.current_task
|
|
1907
|
+
if not task.path.exists():
|
|
1851
1908
|
self.notify("Task directory not found", severity="error")
|
|
1852
1909
|
return
|
|
1853
1910
|
|
|
1854
|
-
self.
|
|
1855
|
-
|
|
1911
|
+
self._open_claude_resume_worker(task)
|
|
1912
|
+
|
|
1913
|
+
@work(thread=True, group="claude_prep")
|
|
1914
|
+
def _open_claude_resume_worker(self, task: Task) -> None:
|
|
1915
|
+
"""Prepare the session off the UI thread, then open the Ghostty tab.
|
|
1916
|
+
|
|
1917
|
+
A prep failure is reported but does not block the launch - stale
|
|
1918
|
+
CLAUDE.md content is not worth failing the session over.
|
|
1919
|
+
"""
|
|
1920
|
+
try:
|
|
1921
|
+
self._prepare_claude_session(task)
|
|
1922
|
+
except Exception as e:
|
|
1923
|
+
self.call_from_thread(
|
|
1924
|
+
self.notify,
|
|
1925
|
+
escape(f"Claude session prep failed for '{task.name}': {type(e).__name__}: {e}"),
|
|
1926
|
+
severity="warning",
|
|
1927
|
+
)
|
|
1928
|
+
resume = has_claude_session(task.path)
|
|
1929
|
+
if get_current_worker().is_cancelled:
|
|
1930
|
+
return
|
|
1931
|
+
self.call_from_thread(self._open_claude_resume_tab, task.path, resume)
|
|
1932
|
+
|
|
1933
|
+
def _open_claude_resume_tab(self, task_path: Path, resume: bool) -> None:
|
|
1934
|
+
"""Open the Ghostty tab for a prepared Claude session (main thread)."""
|
|
1935
|
+
if resume:
|
|
1856
1936
|
self._open_ghostty_tab(task_path, command=f"{self.config.claude_path} -r")
|
|
1857
1937
|
self.notify("Opened Claude Code in new tab (resume)")
|
|
1858
1938
|
else:
|
|
@@ -1870,27 +1950,39 @@ class TaskTreeApp(App):
|
|
|
1870
1950
|
self.notify("Worktree directory not found", severity="error")
|
|
1871
1951
|
return
|
|
1872
1952
|
|
|
1873
|
-
self._prepare_claude_session(task.path)
|
|
1874
1953
|
session_name = f"{task.name}/{worktree.name}"
|
|
1875
1954
|
|
|
1876
1955
|
def handle_prompt(prompt: str | None) -> None:
|
|
1877
1956
|
if not prompt:
|
|
1878
1957
|
return
|
|
1879
1958
|
self.notify(escape(f"Dispatching agent in '{worktree.name}'…"))
|
|
1880
|
-
self._dispatch_agent_worker(
|
|
1959
|
+
self._dispatch_agent_worker(
|
|
1960
|
+
task, session_name, worktree.name, str(worktree.path), prompt
|
|
1961
|
+
)
|
|
1881
1962
|
|
|
1882
1963
|
self.push_screen(DispatchAgentModal(session_name), handle_prompt)
|
|
1883
1964
|
|
|
1884
1965
|
@work(thread=True, group="agent_dispatch")
|
|
1885
1966
|
def _dispatch_agent_worker(
|
|
1886
|
-
self, session_name: str, wt_name: str, wt_path: str, prompt: str
|
|
1967
|
+
self, task: Task, session_name: str, wt_name: str, wt_path: str, prompt: str
|
|
1887
1968
|
) -> None:
|
|
1888
|
-
"""
|
|
1969
|
+
"""Prepare the session, then run `claude --bg` in the worktree.
|
|
1889
1970
|
|
|
1890
1971
|
Deliberately not exclusive: dispatching agents to different worktrees
|
|
1891
1972
|
in parallel is legitimate. `--bg` detaches and returns promptly; the
|
|
1892
|
-
30s timeout only guards against a hung CLI.
|
|
1973
|
+
30s timeout only guards against a hung CLI. Session prep can shell
|
|
1974
|
+
out to git per worktree, so it belongs in this thread worker too -
|
|
1975
|
+
never on the UI thread. A prep failure is reported but does not
|
|
1976
|
+
block dispatch.
|
|
1893
1977
|
"""
|
|
1978
|
+
try:
|
|
1979
|
+
self._prepare_claude_session(task)
|
|
1980
|
+
except Exception as e:
|
|
1981
|
+
self.call_from_thread(
|
|
1982
|
+
self.notify,
|
|
1983
|
+
escape(f"Claude session prep failed for '{wt_name}': {type(e).__name__}: {e}"),
|
|
1984
|
+
severity="warning",
|
|
1985
|
+
)
|
|
1894
1986
|
try:
|
|
1895
1987
|
# "--" terminates option parsing: a prompt starting with "-"
|
|
1896
1988
|
# must reach claude as the prompt, not be eaten as CLI flags
|
|
@@ -1946,27 +2038,62 @@ class TaskTreeApp(App):
|
|
|
1946
2038
|
|
|
1947
2039
|
def action_open_claude_gui_code(self) -> None:
|
|
1948
2040
|
"""Open Claude desktop app on the Code page in the current task folder."""
|
|
1949
|
-
from urllib.parse import quote
|
|
1950
|
-
|
|
1951
2041
|
if not self.current_task:
|
|
1952
2042
|
self.notify("No task selected", severity="warning")
|
|
1953
2043
|
return
|
|
1954
2044
|
|
|
1955
|
-
|
|
1956
|
-
if not
|
|
2045
|
+
task = self.current_task
|
|
2046
|
+
if not task.path.exists():
|
|
1957
2047
|
self.notify("Task directory not found", severity="error")
|
|
1958
2048
|
return
|
|
1959
2049
|
|
|
1960
|
-
self.
|
|
1961
|
-
|
|
2050
|
+
self._open_claude_gui_code_worker(task)
|
|
2051
|
+
|
|
2052
|
+
@work(thread=True, group="claude_prep")
|
|
2053
|
+
def _open_claude_gui_code_worker(self, task: Task) -> None:
|
|
2054
|
+
"""Prepare the session, then open Claude desktop, off the UI thread."""
|
|
2055
|
+
from urllib.parse import quote
|
|
2056
|
+
|
|
2057
|
+
try:
|
|
2058
|
+
self._prepare_claude_session(task)
|
|
2059
|
+
except Exception as e:
|
|
2060
|
+
self.call_from_thread(
|
|
2061
|
+
self.notify,
|
|
2062
|
+
escape(f"Claude session prep failed for '{task.name}': {type(e).__name__}: {e}"),
|
|
2063
|
+
severity="warning",
|
|
2064
|
+
)
|
|
2065
|
+
|
|
2066
|
+
encoded_path = quote(str(task.path), safe="")
|
|
1962
2067
|
url = f"claude://code/new?folder={encoded_path}"
|
|
1963
2068
|
try:
|
|
1964
|
-
subprocess.run(["open", url],
|
|
1965
|
-
self.notify("Opened Claude desktop on Code page")
|
|
2069
|
+
result = subprocess.run(["open", url], capture_output=True, text=True)
|
|
1966
2070
|
except FileNotFoundError:
|
|
1967
|
-
self.
|
|
2071
|
+
self.call_from_thread(
|
|
2072
|
+
self.notify, "'open' command not found (macOS only)", severity="error"
|
|
2073
|
+
)
|
|
2074
|
+
return
|
|
1968
2075
|
except Exception as e:
|
|
1969
|
-
self.
|
|
2076
|
+
self.call_from_thread(
|
|
2077
|
+
self.notify, escape(f"Failed to open Claude desktop: {e}"), severity="error"
|
|
2078
|
+
)
|
|
2079
|
+
return
|
|
2080
|
+
|
|
2081
|
+
if get_current_worker().is_cancelled:
|
|
2082
|
+
return
|
|
2083
|
+
|
|
2084
|
+
if result.returncode != 0:
|
|
2085
|
+
detail = (result.stderr or result.stdout or "unknown error").strip()
|
|
2086
|
+
self.call_from_thread(
|
|
2087
|
+
self.notify, escape(f"Failed to open Claude desktop: {detail}"), severity="error"
|
|
2088
|
+
)
|
|
2089
|
+
self.call_from_thread(
|
|
2090
|
+
self._log_activity,
|
|
2091
|
+
f"Failed to open Claude desktop for '{task.name}': {detail}",
|
|
2092
|
+
MessageLevel.ERROR,
|
|
2093
|
+
task.name,
|
|
2094
|
+
)
|
|
2095
|
+
return
|
|
2096
|
+
self.call_from_thread(self.notify, "Opened Claude desktop on Code page")
|
|
1970
2097
|
|
|
1971
2098
|
def action_open_folder(self) -> None:
|
|
1972
2099
|
"""Open current folder in a new terminal tab."""
|
|
@@ -2088,6 +2215,12 @@ class TaskTreeApp(App):
|
|
|
2088
2215
|
if not self.current_task:
|
|
2089
2216
|
self.notify("No task selected", severity="warning")
|
|
2090
2217
|
return
|
|
2218
|
+
if self._mutation_in_flight():
|
|
2219
|
+
# A delete worker can be rmtree'ing these same worktrees right
|
|
2220
|
+
# now; pushing into that race would run git against a tree
|
|
2221
|
+
# that's disappearing under it.
|
|
2222
|
+
self.notify("Another task operation is still running", severity="warning")
|
|
2223
|
+
return
|
|
2091
2224
|
|
|
2092
2225
|
worktree_list = self.query_one("#worktree-list", WorktreeList)
|
|
2093
2226
|
worktree_list.loading = True
|
|
@@ -2108,6 +2241,9 @@ class TaskTreeApp(App):
|
|
|
2108
2241
|
if not self.current_task:
|
|
2109
2242
|
self.notify("No task selected", severity="warning")
|
|
2110
2243
|
return
|
|
2244
|
+
if self._mutation_in_flight():
|
|
2245
|
+
self.notify("Another task operation is still running", severity="warning")
|
|
2246
|
+
return
|
|
2111
2247
|
|
|
2112
2248
|
worktree_list = self.query_one("#worktree-list", WorktreeList)
|
|
2113
2249
|
worktree_list.loading = True
|