tasktree-manager 2.13.2__tar.gz → 2.13.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.gitignore +0 -1
  2. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/CHANGELOG.md +9 -0
  3. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/CONTRIBUTING.md +7 -10
  4. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/PKG-INFO +1 -1
  5. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/README.md +23 -16
  6. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/configuration.md +2 -2
  7. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/troubleshooting.md +1 -1
  8. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/user-guide.md +6 -0
  9. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/pyproject.toml +1 -1
  10. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/_version.py +2 -2
  11. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/app.py +59 -28
  12. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/cli.py +63 -21
  13. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/config.py +19 -11
  14. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/models.py +16 -0
  15. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/task_manager.py +295 -53
  16. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_app_review_fixes.py +97 -2
  17. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_cli.py +88 -0
  18. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_medium_fixes.py +1 -1
  19. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_task_manager.py +386 -24
  20. tasktree_manager-2.13.2/OPENSPEC.md +0 -457
  21. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/SKILL.md +0 -0
  22. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/README.md +0 -0
  23. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  24. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  25. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  26. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  27. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/references/layouts.md +0 -0
  28. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  29. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/references/styling.md +0 -0
  30. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/references/widgets.md +0 -0
  31. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.github/templates/CHANGELOG.md.j2 +0 -0
  32. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.github/workflows/ci.yml +0 -0
  33. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.github/workflows/release.yml +0 -0
  34. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/LICENSE +0 -0
  35. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/ROADMAP.md +0 -0
  36. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/claude-code-integration-spec.md +0 -0
  37. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/installation.md +0 -0
  38. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/mise.toml +0 -0
  39. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/scripts/bump_version.py +0 -0
  40. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/__init__.py +0 -0
  41. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/commands.py +0 -0
  42. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/__init__.py +0 -0
  43. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/agent_sessions.py +0 -0
  44. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/claude_hooks.py +0 -0
  45. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/claude_sessions.py +0 -0
  46. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/forge.py +0 -0
  47. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/git_ops.py +0 -0
  48. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/themes.py +0 -0
  49. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/__init__.py +0 -0
  50. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/app_header.py +0 -0
  51. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/create_modal.py +0 -0
  52. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/messages_panel.py +0 -0
  53. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/setup_modal.py +0 -0
  54. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/status_panel.py +0 -0
  55. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/task_list.py +0 -0
  56. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/worktree_list.py +0 -0
  57. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/__init__.py +0 -0
  58. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/conftest.py +0 -0
  59. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_agent_sessions.py +0 -0
  60. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_agent_sessions_decode.py +0 -0
  61. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_app.py +0 -0
  62. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_bump_version.py +0 -0
  63. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_claude_hooks.py +0 -0
  64. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_claude_sessions.py +0 -0
  65. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_commands.py +0 -0
  66. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_config.py +0 -0
  67. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_forge.py +0 -0
  68. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_git_ops.py +0 -0
  69. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_integration_git.py +0 -0
  70. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_list_selection.py +0 -0
  71. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_memory.py +0 -0
  72. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_messages_panel.py +0 -0
  73. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_modals.py +0 -0
  74. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_safety_fixes.py +0 -0
  75. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_setup_modal.py +0 -0
  76. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_status_panel.py +0 -0
  77. {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_worktree_badges.py +0 -0
@@ -53,7 +53,6 @@ tasktree_manager/_version.py
53
53
 
54
54
  # Documentation (tracked separately)
55
55
  PRD.md
56
- specs/
57
56
  CLAUDE.md
58
57
 
59
58
  # uv project-mode lockfile — tooling installs via `uv pip`/pip from pyproject.toml,
@@ -15,6 +15,15 @@ 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.3] - 2026-10-16
19
+
20
+ ### Changed
21
+ - Remove unused specs and refresh the README roadmap
22
+
23
+ ### Fixed
24
+ - **archive**: Keep task-folder files when a task is finished
25
+
26
+
18
27
  ## [2.13.2] - 2026-09-28
19
28
 
20
29
  ### Changed
@@ -18,7 +18,7 @@ Be respectful, inclusive, and constructive. We're all here to build something us
18
18
 
19
19
  1. **Fork and clone**
20
20
  ```bash
21
- git clone https://github.com/yourusername/tasktree-manager.git
21
+ git clone https://github.com/ruslanfialkovskii/tasktree-manager.git
22
22
  cd tasktree-manager
23
23
  ```
24
24
 
@@ -149,7 +149,7 @@ def create_task(name: str, repos: list[str]) -> Task:
149
149
  - Add docstrings to all public functions/classes
150
150
  - Use Google-style docstrings
151
151
  - Update README.md for user-facing changes
152
- - Update OPENSPEC.md for architecture changes
152
+ - Update the Architecture section of CLAUDE.md for architecture changes
153
153
 
154
154
  Example docstring:
155
155
  ```python
@@ -206,18 +206,15 @@ tasktree-manager/
206
206
  │ ├── themes/ # Color themes
207
207
  │ └── app.py # Main app
208
208
  ├── tests/ # Test suite
209
- ├── specs/ # Technical specs
210
209
  └── docs/ # Documentation
211
210
  ```
212
211
 
213
212
  ### Adding New Features
214
213
 
215
- 1. **Check specs**: Review existing specs in `specs/`
216
- 2. **Create spec**: Write a new spec (SPEC-XXX) if needed
217
- 3. **Write tests**: Add tests first (TDD approach)
218
- 4. **Implement**: Write the code
219
- 5. **Document**: Update README/specs
220
- 6. **PR**: Submit for review
214
+ 1. **Write tests**: Add tests first (TDD approach)
215
+ 2. **Implement**: Write the code
216
+ 3. **Document**: Update README.md and `docs/`
217
+ 4. **PR**: Submit for review
221
218
 
222
219
  ## Submitting Issues
223
220
 
@@ -260,7 +257,7 @@ Include:
260
257
 
261
258
  ## Questions?
262
259
 
263
- - Open a [Discussion](https://github.com/yourusername/tasktree-manager/discussions)
260
+ - Open a [Discussion](https://github.com/ruslanfialkovskii/tasktree-manager/discussions)
264
261
  - Ask in Pull Request
265
262
  - Check existing issues
266
263
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.13.2
3
+ Version: 2.13.3
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
7
- [![Tests](https://img.shields.io/badge/tests-228%20passed-green.svg)](./tests/)
7
+ [![Tests](https://img.shields.io/badge/tests-pytest-green.svg)](./tests/)
8
8
 
9
9
  **tasktree-manager** is a terminal user interface (TUI) application for managing development tasks that span multiple git repositories. Create tasks, organize worktrees, and track status across your entire codebase—all from a beautiful, keyboard-driven interface inspired by [lazygit](https://github.com/jesseduffield/lazygit).
10
10
 
@@ -22,7 +22,7 @@
22
22
  - 🛰️ **Agent Dashboard** - Live Claude Code session state per worktree (from `claude agents`), plus one-key background-agent dispatch — the multi-repo agent view Claude Code lacks natively
23
23
  - ✻ **Session Recap** - The Info panel shows each task's latest Claude Code session: title, last turn duration, and Claude's own idle recap of what was done and what comes next — read straight from the transcript, no CLI call
24
24
  - 🔀 **Forge-Aware Safety** - MR/PR and CI state via `glab`/`gh`; squash- and rebase-merged branches count as merged instead of blocking deletion forever
25
- - 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) to an archive before deleting
25
+ - 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) and the task-folder files outside the worktrees (plans, ADRs, notes) to an archive before deleting
26
26
 
27
27
  ## 📊 Status Indicators
28
28
 
@@ -82,15 +82,23 @@ tasktree-manager list --json # tasks with repos and state: clean / dirty / e
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
85
- tasktree-manager finish DIC-1901-argocd-tls # safety sweep -> archive diff -> delete; --push / --no-archive / --force
86
- tasktree-manager delete DIC-1901-argocd-tls # archive diff -> delete; refuses on unfinished work; --force overrides
85
+ tasktree-manager finish DIC-1901-argocd-tls # safety sweep -> archive diff + task files -> delete; --push / --no-archive / --force
86
+ tasktree-manager delete DIC-1901-argocd-tls # archive diff + task files -> delete; refuses on unfinished work; --force overrides
87
87
  ```
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
91
  with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
92
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.
93
+ the partial archive; the TUI warns and deletes anyway. Files in the task folder outside the
94
+ worktrees are copied to `<task>-<timestamp>.files/` next to the patch:
95
+
96
+ - Skipped silently: hidden files, symlinks, `node_modules`/`vendor`/`.terraform` folders and
97
+ an unedited generated `CLAUDE.md`.
98
+ - Left out with a warning: key material (`*.pem`, `*.key`, `id_rsa`, kubeconfigs,
99
+ `*.tfstate`, `credentials*.json`), files over 10 MB and files past 100 MB in total.
100
+ - A file that cannot be read or copied stops `delete`/`finish` before anything is removed,
101
+ unless `--force`; the TUI warns and deletes anyway.
94
102
  `status --oneline` emits a compact `TASK repoA✓ repoB●2↑1` summary that slots straight into
95
103
  a Claude Code statusline or shell prompt.
96
104
 
@@ -147,7 +155,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup and guidel
147
155
 
148
156
  **Quick Start:**
149
157
  ```bash
150
- git clone https://github.com/yourusername/tasktree-manager.git
158
+ git clone https://github.com/ruslanfialkovskii/tasktree-manager.git
151
159
  cd tasktree-manager
152
160
  mise install
153
161
  mise run install
@@ -197,14 +205,13 @@ For comprehensive troubleshooting, see the [Troubleshooting Guide](docs/troubles
197
205
 
198
206
  Recently shipped: agent dashboard (per-worktree Claude session badges + background dispatch),
199
207
  forge-aware merged detection (glab/gh, squash/rebase merges), MR/CI status badges,
200
- guided `finish` flow with diff archiving, `status` subcommand for scripts and statuslines.
208
+ guided `finish` flow that archives the diff and the task-folder files, `status` subcommand
209
+ for scripts and statuslines, Claude session recap in the Info panel.
201
210
 
202
- Upcoming:
203
- - **v0.3.0** - Search/filter, task templates
204
- - **v0.4.0** - JIRA status column, git hooks, automation
205
- - **v1.0.0** - Stable release
211
+ Next: epics, a layer that groups related tasks (for example a Jira epic and its child
212
+ tasks) in the task list.
206
213
 
207
- See [ROADMAP.md](ROADMAP.md) and [CHANGELOG.md](CHANGELOG.md) for details.
214
+ See [CHANGELOG.md](CHANGELOG.md) for the release history.
208
215
 
209
216
  ## 📄 License
210
217
 
@@ -218,11 +225,11 @@ MIT License - see [LICENSE](./LICENSE) for details.
218
225
 
219
226
  ## 💬 Support & Community
220
227
 
221
- - 🐛 [Bug Reports](https://github.com/yourusername/tasktree-manager/issues)
222
- - 💡 [Feature Requests](https://github.com/yourusername/tasktree-manager/issues)
223
- - 💬 [Discussions](https://github.com/yourusername/tasktree-manager/discussions)
228
+ - 🐛 [Bug Reports](https://github.com/ruslanfialkovskii/tasktree-manager/issues)
229
+ - 💡 [Feature Requests](https://github.com/ruslanfialkovskii/tasktree-manager/issues)
230
+ - 💬 [Discussions](https://github.com/ruslanfialkovskii/tasktree-manager/discussions)
224
231
  - 📖 [Documentation](docs/)
225
232
 
226
233
  ---
227
234
 
228
- **Made with ❤️ by developers, for developers** | [⭐ Star this repo](https://github.com/yourusername/tasktree-manager)
235
+ **Made with ❤️ by developers, for developers** | [⭐ Star this repo](https://github.com/ruslanfialkovskii/tasktree-manager)
@@ -86,7 +86,7 @@ repos_dir = "/Users/username/repos"
86
86
  # Created automatically if it doesn't exist
87
87
  tasks_dir = "/Users/username/tasks"
88
88
 
89
- # Directory for finished-task diff archives
89
+ # Directory for finished-task archives (diffs and task-folder files)
90
90
  # Relative paths resolve under tasks_dir (never the current directory)
91
91
  # Default: "" (uses <tasks_dir>/.archive)
92
92
  archive_dir = ""
@@ -305,7 +305,7 @@ blocklist = [
305
305
  |------------|------|-----------|---------------------|------------------------------------------------|
306
306
  | `repos_dir` | path | `~/repos` | `REPOS_DIR` | Directory containing your git repositories |
307
307
  | `tasks_dir` | path | `~/tasks` | `TASKS_DIR` | Directory where task worktrees will be created |
308
- | `archive_dir` | path | `""` (`<tasks_dir>/.archive`) | - | Directory for finished-task diff archives |
308
+ | `archive_dir` | path | `""` (`<tasks_dir>/.archive`) | - | Directory for finished-task archives (diffs and task-folder files) |
309
309
 
310
310
  ### Details
311
311
 
@@ -921,7 +921,7 @@ Yes! Each task creates its own worktree and branch:
921
921
 
922
922
  - Worktrees are removed from `TASKS_DIR`
923
923
  - The local task branch is deleted from the main repository (`git branch -D`);
924
- the remaining diff is archived under `archive_dir` first
924
+ the remaining diff and the task-folder files are archived under `archive_dir` first
925
925
  - Remote branches remain on GitHub/GitLab (if pushed)
926
926
 
927
927
  **Manual cleanup (only for branches created outside tasktree):**
@@ -687,6 +687,12 @@ d (delete task)
687
687
  - ✅ The task's remaining diff (committed-but-unmerged + uncommitted work) is archived to
688
688
  `archive_dir` (default `<tasks_dir>/.archive/<task>-<timestamp>.patch`) before anything
689
689
  is removed — the messages panel (`m`) shows the archive path
690
+ - ✅ Files in the task folder outside the worktrees (`PLAN.md`, ADRs, notes, scripts) are
691
+ copied to `<task>-<timestamp>.files/` next to the patch. Hidden files, symlinks,
692
+ `node_modules`/`vendor`/`.terraform` folders and an unedited generated `CLAUDE.md` are
693
+ skipped silently. Key material (`*.pem`, `*.key`, `id_rsa`, kubeconfigs, `*.tfstate`,
694
+ `credentials*.json`), files over 10 MB, files past 100 MB in total and files that cannot
695
+ be read or copied are listed in the messages panel
690
696
  - ✅ Worktrees removed from `TASKS_DIR`
691
697
  - ✅ Branches remain on remote (if pushed)
692
698
  - ✅ Original repos in `REPOS_DIR` untouched
@@ -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.2"
74
+ version = "2.13.3"
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.13.2'
22
- __version_tuple__ = version_tuple = (2, 13, 2)
21
+ __version__ = version = '2.13.3'
22
+ __version_tuple__ = version_tuple = (2, 13, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -24,8 +24,8 @@ from .services.claude_sessions import ClaudeRecap, read_claude_recap, transcript
24
24
  from .services.config import Config, ConfigError
25
25
  from .services.forge import Forge, ForgeStatus, get_forge_status
26
26
  from .services.git_ops import GitOps
27
- from .services.models import GitStatus, Task, TaskSafetyReport, Worktree
28
- from .services.task_manager import TaskManager
27
+ from .services.models import ArchiveResult, GitStatus, Task, TaskSafetyReport, Worktree
28
+ from .services.task_manager import ARCHIVE_NOTES_SHOWN, TaskManager
29
29
  from .themes import ALL_THEMES, THEME_CYCLE
30
30
  from .widgets.app_header import AppHeader
31
31
  from .widgets.create_modal import (
@@ -1378,14 +1378,15 @@ class TaskTreeApp(App):
1378
1378
 
1379
1379
  Removing worktrees runs git per repo and rmtree over trees that can
1380
1380
  be huge (node_modules etc.) - never block the UI thread on it.
1381
- The task's remaining diff (committed-but-unmerged + uncommitted) is
1382
- archived first — finish_task rmtrees the task directory.
1381
+ The task's remaining diff (committed-but-unmerged + uncommitted) and
1382
+ its task-folder files are archived first — finish_task rmtrees the
1383
+ task directory.
1383
1384
  """
1384
1385
  error: str | None = None
1385
- archive_path: Path | None = None
1386
+ archive: ArchiveResult | None = None
1386
1387
  try:
1387
1388
  try:
1388
- archive_path = self.task_manager.archive_task(task)
1389
+ archive = self.task_manager.archive_task(task)
1389
1390
  except Exception as e:
1390
1391
  # A failed archive must not block deletion, but it must be
1391
1392
  # loud — the archive is the safety net for the work the
@@ -1394,6 +1395,8 @@ class TaskTreeApp(App):
1394
1395
  detail = f"Archive failed for '{task.name}': {type(e).__name__}: {e}"
1395
1396
  self.call_from_thread(self.notify, escape(detail), severity="warning", timeout=10)
1396
1397
  self.call_from_thread(self._log_activity, detail, MessageLevel.ERROR, task.name)
1398
+ # Still report the files that were saved and the ones left out
1399
+ archive = getattr(e, "result", None)
1397
1400
  self.task_manager.finish_task(task)
1398
1401
  except ValueError as e:
1399
1402
  # finish_task raises ValueError (nothing removed) when worktrees
@@ -1402,37 +1405,66 @@ class TaskTreeApp(App):
1402
1405
  error = str(e)
1403
1406
  except Exception as e:
1404
1407
  error = f"{type(e).__name__}: {e}"
1405
- self.call_from_thread(self._apply_finish_task_result, task.name, force, error, archive_path)
1408
+ self.call_from_thread(self._apply_finish_task_result, task.name, force, error, archive)
1406
1409
 
1407
1410
  def _apply_finish_task_result(
1408
1411
  self,
1409
1412
  task_name: str,
1410
1413
  force: bool,
1411
1414
  error: str | None,
1412
- archive_path: Path | None = None,
1415
+ archive: ArchiveResult | None = None,
1413
1416
  ) -> None:
1414
1417
  """Report the delete outcome and reload (runs on the main thread)."""
1415
1418
  if error:
1416
1419
  self.notify(escape(f"Failed to delete task: {error}"), severity="error")
1417
1420
  self._log_activity(f"Failed to delete task: {error}", MessageLevel.ERROR, task_name)
1418
- if archive_path is not None:
1421
+ if archive is not None:
1419
1422
  # The archive succeeded before the delete itself failed - the
1420
1423
  # 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
- )
1424
+ self._log_archive(task_name, archive, " before the failed delete")
1426
1425
  else:
1427
1426
  self.notify(escape(f"Deleted task: {task_name}"))
1428
1427
  suffix = " (force)" if force else ""
1429
1428
  self._log_activity(
1430
1429
  f"Task '{task_name}' deleted{suffix}", MessageLevel.SUCCESS, task_name
1431
1430
  )
1432
- if archive_path is not None:
1433
- self._log_activity(f"Archived diff to {archive_path}", MessageLevel.INFO, task_name)
1431
+ if archive is not None:
1432
+ self._log_archive(task_name, archive)
1434
1433
  self._load_tasks()
1435
1434
 
1435
+ def _log_archive(self, task_name: str, archive: ArchiveResult, context: str = "") -> None:
1436
+ """Log where a task's work was archived and warn about files left out.
1437
+
1438
+ The left-out notes come first and are capped, so the archive
1439
+ location stays among the latest entries of the bounded panel.
1440
+ """
1441
+ notes = [f"Not archived: {item}" for item in archive.skipped]
1442
+ notes += [f"Could not archive: {item}" for item in archive.failed]
1443
+ for note in notes[:ARCHIVE_NOTES_SHOWN]:
1444
+ self._log_activity(note, MessageLevel.WARNING, task_name)
1445
+ if len(notes) > ARCHIVE_NOTES_SHOWN:
1446
+ self._log_activity(
1447
+ f"... and {len(notes) - ARCHIVE_NOTES_SHOWN} more not archived",
1448
+ MessageLevel.WARNING,
1449
+ task_name,
1450
+ )
1451
+ if archive.patch is not None:
1452
+ self._log_activity(
1453
+ f"Archived diff to {archive.patch}{context}", MessageLevel.INFO, task_name
1454
+ )
1455
+ if archive.files_dir is not None:
1456
+ self._log_activity(
1457
+ f"Archived {len(archive.files)} task file(s) to {archive.files_dir}{context}",
1458
+ MessageLevel.INFO,
1459
+ task_name,
1460
+ )
1461
+ if notes:
1462
+ self.notify(
1463
+ f"{len(notes)} task file note(s) - see messages",
1464
+ severity="warning",
1465
+ timeout=10,
1466
+ )
1467
+
1436
1468
  def _handle_safe_delete_action(
1437
1469
  self, task: Task, safety_report: TaskSafetyReport, action: str | None
1438
1470
  ) -> None:
@@ -1615,10 +1647,13 @@ class TaskTreeApp(App):
1615
1647
  def _remove_worktree_worker(self, task: Task, worktree: Worktree, force: bool = False) -> None:
1616
1648
  """Remove a single worktree in a background thread (archive, git, rmtree)."""
1617
1649
  error: str | None = None
1618
- archive_path: Path | None = None
1650
+ archive: ArchiveResult | None = None
1619
1651
  try:
1620
1652
  try:
1621
- archive_path = self.task_manager.archive_task(self._worktree_view(task, worktree))
1653
+ # Diff only: the task folder and its files stay in place
1654
+ archive = self.task_manager.archive_task(
1655
+ self._worktree_view(task, worktree), include_files=False
1656
+ )
1622
1657
  except Exception as e:
1623
1658
  # Loud but non-blocking, same policy as task deletion
1624
1659
  detail = f"Archive failed for '{worktree.name}': {type(e).__name__}: {e}"
@@ -1628,7 +1663,7 @@ class TaskTreeApp(App):
1628
1663
  except Exception as e:
1629
1664
  error = f"{type(e).__name__}: {e}"
1630
1665
  self.call_from_thread(
1631
- self._apply_remove_worktree_result, task.name, worktree.name, force, error, archive_path
1666
+ self._apply_remove_worktree_result, task.name, worktree.name, force, error, archive
1632
1667
  )
1633
1668
 
1634
1669
  def _apply_remove_worktree_result(
@@ -1637,7 +1672,7 @@ class TaskTreeApp(App):
1637
1672
  worktree_name: str,
1638
1673
  force: bool,
1639
1674
  error: str | None,
1640
- archive_path: Path | None = None,
1675
+ archive: ArchiveResult | None = None,
1641
1676
  ) -> None:
1642
1677
  """Report the worktree removal outcome and reload (main thread)."""
1643
1678
  if error:
@@ -1647,12 +1682,8 @@ class TaskTreeApp(App):
1647
1682
  MessageLevel.ERROR,
1648
1683
  task_name,
1649
1684
  )
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
- )
1685
+ if archive is not None:
1686
+ self._log_archive(task_name, archive, " before the failed delete")
1656
1687
  else:
1657
1688
  self.notify(escape(f"Deleted worktree: {worktree_name}"))
1658
1689
  suffix = " (force)" if force else ""
@@ -1661,8 +1692,8 @@ class TaskTreeApp(App):
1661
1692
  MessageLevel.SUCCESS,
1662
1693
  task_name,
1663
1694
  )
1664
- if archive_path is not None:
1665
- self._log_activity(f"Archived diff to {archive_path}", MessageLevel.INFO, task_name)
1695
+ if archive is not None:
1696
+ self._log_archive(task_name, archive)
1666
1697
  self._load_tasks()
1667
1698
 
1668
1699
  def _open_lazygit_for_task(self, task: Task, safety_report: TaskSafetyReport) -> None:
@@ -16,8 +16,8 @@ from . import __version__
16
16
  from .services import forge
17
17
  from .services.config import Config, ConfigError
18
18
  from .services.git_ops import GitOps
19
- from .services.models import Task
20
- from .services.task_manager import TaskManager
19
+ from .services.models import ArchiveResult, Task
20
+ from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
21
21
 
22
22
 
23
23
  def build_parser() -> argparse.ArgumentParser:
@@ -55,7 +55,8 @@ def build_parser() -> argparse.ArgumentParser:
55
55
  delete.add_argument(
56
56
  "--force",
57
57
  action="store_true",
58
- help="Delete even with uncommitted, unpushed or unmerged work",
58
+ help="Delete even with uncommitted, unpushed or unmerged work, or task files "
59
+ "that could not be archived",
59
60
  )
60
61
  delete.set_defaults(func=cmd_delete)
61
62
 
@@ -66,7 +67,7 @@ def build_parser() -> argparse.ArgumentParser:
66
67
  add_repo.set_defaults(func=cmd_add_repo)
67
68
 
68
69
  finish = sub.add_parser(
69
- "finish", help="Finish a task: safety sweep, archive its diff, then delete"
70
+ "finish", help="Finish a task: safety sweep, archive its diff and files, then delete"
70
71
  )
71
72
  finish.add_argument("name", help="Task name")
72
73
  finish.add_argument("--push", action="store_true", help="Push unpushed branches first")
@@ -74,12 +75,13 @@ def build_parser() -> argparse.ArgumentParser:
74
75
  "--no-archive",
75
76
  action="store_true",
76
77
  dest="no_archive",
77
- help="Skip writing the diff archive",
78
+ help="Skip the archive: the diff and the task-folder files are deleted unsaved",
78
79
  )
79
80
  finish.add_argument(
80
81
  "--force",
81
82
  action="store_true",
82
- help="Finish even with unfinished work (the archive is still written)",
83
+ help="Finish even with unfinished work or task files that could not be archived "
84
+ "(the archive is still written)",
83
85
  )
84
86
  finish.set_defaults(func=cmd_finish)
85
87
 
@@ -201,6 +203,50 @@ def cmd_repos(manager: TaskManager, config: Config, args: argparse.Namespace) ->
201
203
  return 0
202
204
 
203
205
 
206
+ def _print_archive(archive: ArchiveResult | None) -> bool:
207
+ """Report where a task's work was archived; True if anything was saved."""
208
+ if archive is None:
209
+ return False
210
+ notes = [f"not archived: {item}" for item in archive.skipped]
211
+ notes += [f"could not archive: {item}" for item in archive.failed]
212
+ for note in notes[:ARCHIVE_NOTES_SHOWN]:
213
+ print(f"warning: {note}", file=sys.stderr)
214
+ if len(notes) > ARCHIVE_NOTES_SHOWN:
215
+ print(f"warning: ... and {len(notes) - ARCHIVE_NOTES_SHOWN} more", file=sys.stderr)
216
+ if archive.patch is not None:
217
+ print(f"Archived diff to {archive.patch}")
218
+ if archive.files_dir is not None:
219
+ print(f"Archived {len(archive.files)} task file(s) to {archive.files_dir}")
220
+ return archive.saved
221
+
222
+
223
+ def _archive_for_delete(
224
+ manager: TaskManager, task: Task, force: bool, verb: str, notes: list[str] | None = None
225
+ ) -> tuple[bool, bool]:
226
+ """Archive a task about to be deleted; returns (may delete, anything saved).
227
+
228
+ Any archive error stops the delete. Task files that could not be read or
229
+ copied stop it too unless --force: the task folder is their only copy.
230
+ """
231
+ try:
232
+ archive = manager.archive_task(task, notes=notes)
233
+ except ArchiveIncompleteError as e:
234
+ _print_archive(e.result)
235
+ _error(f"archive failed: {e}")
236
+ return False, False
237
+ except Exception as e:
238
+ _error(f"archive failed: {e}")
239
+ return False, False
240
+ saved = _print_archive(archive)
241
+ if archive is not None and archive.failed and not force:
242
+ _error(
243
+ f"{len(archive.failed)} task file(s) could not be archived; "
244
+ f"use --force to {verb} anyway"
245
+ )
246
+ return False, saved
247
+ return True, saved
248
+
249
+
204
250
  def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
205
251
  """Delete a task after a safety check (bypass with --force)."""
206
252
  task = manager.get_task(args.name)
@@ -215,14 +261,11 @@ def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -
215
261
  print("use --force to delete anyway", file=sys.stderr)
216
262
  return 1
217
263
 
218
- # Same safety net as the TUI: the remaining diff is archived before the
219
- # worktrees and branches are removed, --force included
220
- try:
221
- archive_path = manager.archive_task(task)
222
- except Exception as e:
223
- return _error(f"archive failed: {e}")
224
- if archive_path is not None:
225
- print(f"Archived diff to {archive_path}")
264
+ # Same safety net as the TUI: the remaining diff and the task files are
265
+ # archived before the worktrees and branches are removed, --force included
266
+ may_delete, _ = _archive_for_delete(manager, task, args.force, "delete")
267
+ if not may_delete:
268
+ return 1
226
269
  manager.finish_task(task)
227
270
  print(f"Deleted task {args.name}")
228
271
  return 0
@@ -262,13 +305,12 @@ def cmd_finish(manager: TaskManager, config: Config, args: argparse.Namespace) -
262
305
 
263
306
  if not args.no_archive:
264
307
  notes = [f"{issue.repo_name}: {issue.details}" for issue in report.merged_via_forge]
265
- try:
266
- archive_path = manager.archive_task(task, notes=notes or None)
267
- except Exception as e:
268
- return _error(f"archive failed: {e}")
269
- if archive_path is not None:
270
- print(f"Archived diff to {archive_path}")
271
- else:
308
+ may_delete, saved = _archive_for_delete(
309
+ manager, task, args.force, "finish", notes=notes or None
310
+ )
311
+ if not may_delete:
312
+ return 1
313
+ if not saved:
272
314
  print("Nothing to archive")
273
315
 
274
316
  manager.finish_task(task)
@@ -95,6 +95,19 @@ def _toml_table(config_data: dict, key: str) -> dict:
95
95
  return value
96
96
 
97
97
 
98
+ # Private keys and certificates: never symlinked into worktrees and never
99
+ # copied into finished-task archives
100
+ KEY_MATERIAL_PATTERNS: list[str] = [
101
+ "*.pem",
102
+ "*.key",
103
+ "*.p12",
104
+ "*.pfx",
105
+ "*.keystore",
106
+ "id_rsa",
107
+ "id_ecdsa",
108
+ "id_ed25519",
109
+ ]
110
+
98
111
  # Default patterns excluded when symlinking gitignored files into worktrees.
99
112
  # Caches/build artifacts are excluded as noise; key material is excluded so
100
113
  # private keys and certificates never spread beyond the main checkout
@@ -116,14 +129,7 @@ DEFAULT_SYMLINK_BLOCKLIST: list[str] = [
116
129
  ".nox",
117
130
  "*.so",
118
131
  "*.dylib",
119
- "*.pem",
120
- "*.key",
121
- "*.p12",
122
- "*.pfx",
123
- "*.keystore",
124
- "id_rsa",
125
- "id_ecdsa",
126
- "id_ed25519",
132
+ *KEY_MATERIAL_PATTERNS,
127
133
  ]
128
134
 
129
135
 
@@ -141,7 +147,8 @@ class Config:
141
147
  repos_dir: Path = field(default_factory=lambda: Path.home() / "repos")
142
148
  tasks_dir: Path = field(default_factory=lambda: Path.home() / "tasks")
143
149
  config_dir: Path = field(default_factory=lambda: Path.home() / ".config" / "tasktree-manager")
144
- # Archive directory for finished-task diffs ("" = <tasks_dir>/.archive)
150
+ # Archive directory for finished-task diffs and task-folder files
151
+ # ("" = <tasks_dir>/.archive)
145
152
  archive_dir: str = ""
146
153
 
147
154
  # UI settings
@@ -395,7 +402,8 @@ repos_dir = "{self._toml_escape(str(self._persisted("repos_dir")))}"
395
402
  # Directory for task worktrees
396
403
  tasks_dir = "{self._toml_escape(str(self._persisted("tasks_dir")))}"
397
404
 
398
- # Directory for finished-task diff archives ("" = <tasks_dir>/.archive)
405
+ # Directory for finished-task archives: diffs and task-folder files
406
+ # ("" = <tasks_dir>/.archive)
399
407
  archive_dir = "{self._toml_escape(self.archive_dir)}"
400
408
 
401
409
  # ============================================================================
@@ -544,7 +552,7 @@ cursor_up = "{self._toml_escape(self.keybindings.get("cursor_up", "k"))}"
544
552
  self.config_dir.mkdir(parents=True, exist_ok=True)
545
553
 
546
554
  def get_archive_dir(self) -> Path:
547
- """Get the archive directory for finished-task diffs.
555
+ """Get the archive directory for finished-task diffs and files.
548
556
 
549
557
  A relative path is anchored under tasks_dir, never $PWD — `finish`
550
558
  may run from inside a worktree, and a CWD-relative archive would be
@@ -126,6 +126,22 @@ class RepoIssue:
126
126
  mr_state: str | None = None # "open" | "merged" | "closed" | "none"
127
127
 
128
128
 
129
+ @dataclass(frozen=True)
130
+ class ArchiveResult:
131
+ """What archive_task saved before a task folder is deleted."""
132
+
133
+ patch: Path | None = None # combined diff of every worktree
134
+ files_dir: Path | None = None # copies of task-folder files outside worktrees
135
+ files: tuple[str, ...] = () # archived files, relative to the task folder
136
+ skipped: tuple[str, ...] = () # "name (reason)": left out by policy (caps, keys)
137
+ failed: tuple[str, ...] = () # "name (reason)": could not be read or copied
138
+
139
+ @property
140
+ def saved(self) -> bool:
141
+ """True when anything was written to the archive directory."""
142
+ return self.patch is not None or self.files_dir is not None
143
+
144
+
129
145
  @dataclass
130
146
  class TaskSafetyReport:
131
147
  """Report of safety issues for a task."""