tasktree-manager 2.12.1__tar.gz → 2.13.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/CHANGELOG.md +6 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/PKG-INFO +1 -1
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/README.md +1 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/configuration.md +12 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/user-guide.md +17 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/pyproject.toml +1 -1
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/app.py +50 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/claude_hooks.py +49 -6
- tasktree_manager-2.13.0/tasktree_manager/services/claude_sessions.py +236 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/config.py +9 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/status_panel.py +67 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_app.py +62 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_claude_hooks.py +72 -0
- tasktree_manager-2.13.0/tests/test_claude_sessions.py +248 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_config.py +31 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_status_panel.py +96 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.gitignore +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/LICENSE +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/OPENSPEC.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/installation.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/mise.toml +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/cli.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/agent_sessions.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/models.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/task_manager.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_cli.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_forge.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_task_manager.py +0 -0
- {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_worktree_badges.py +0 -0
|
@@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
15
15
|
- Git hooks support
|
|
16
16
|
- Custom task metadata
|
|
17
17
|
|
|
18
|
+
## [2.13.0] - 2026-09-11
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **tui**: Show Claude session recap in the Info panel
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
## [2.12.1] - 2026-09-03
|
|
19
25
|
|
|
20
26
|
### Changed
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
- 🔧 **Flexible Configuration** - TOML config file, custom keybindings, environment variables
|
|
21
21
|
- 🤖 **Headless CLI** - Create, list, inspect, finish, and delete tasks from scripts and AI agents without opening the TUI
|
|
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
|
+
- ✻ **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
|
|
23
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
|
|
24
25
|
- 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) to an archive before deleting
|
|
25
26
|
|
|
@@ -180,9 +180,21 @@ claude_memory_dir = "~/.claude/tasktree-memory"
|
|
|
180
180
|
# worktree of a repo survives worktree deletion and is shared with future
|
|
181
181
|
# worktrees of that repo and with sessions in the main checkout. The settings
|
|
182
182
|
# file is added to the repo's .git/info/exclude so worktrees stay clean.
|
|
183
|
+
# The directory name follows the current Claude CLI rule (every character
|
|
184
|
+
# outside [A-Za-z0-9] becomes "-"); memory saved by older tasktree versions
|
|
185
|
+
# under the previous name (which kept "_") is moved once, automatically.
|
|
183
186
|
# Default: true
|
|
184
187
|
claude_repo_memory = true
|
|
185
188
|
|
|
189
|
+
# Claude session recap in the Info panel. When a task is highlighted, the
|
|
190
|
+
# bottom of the panel shows its latest Claude Code session: title, last turn
|
|
191
|
+
# duration ("Baked for 45s · done 5:27 PM"), and the idle recap Claude writes
|
|
192
|
+
# a few minutes after a turn ("※ recap: ..."), or the last prompt when no
|
|
193
|
+
# recap exists yet. Read from the session transcript under ~/.claude/projects
|
|
194
|
+
# (honours CLAUDE_CONFIG_DIR); no CLI call.
|
|
195
|
+
# Default: true
|
|
196
|
+
claude_recap = true
|
|
197
|
+
|
|
186
198
|
# Path to glab executable (GitLab CLI, used for MR/CI status)
|
|
187
199
|
# Default: "glab" (searches PATH)
|
|
188
200
|
glab_path = "glab"
|
|
@@ -219,6 +219,23 @@ Shows detailed git status for the selected worktree:
|
|
|
219
219
|
- **Sync status**: Ahead/behind counts
|
|
220
220
|
- **File changes**: Modified, staged, and untracked files with git status codes
|
|
221
221
|
|
|
222
|
+
While the task list is focused, the panel shows the task summary instead (every
|
|
223
|
+
repo with its changes or `clean`), followed by a **Claude session recap** when the
|
|
224
|
+
task has a Claude Code session:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
─ claude ─────────────────
|
|
228
|
+
access-m2
|
|
229
|
+
✻ Baked for 45s · done 5:27 PM · busy
|
|
230
|
+
※ recap: Fulfilling ACCESS2-7180, read-only M2 access: gitops RBAC and
|
|
231
|
+
terraform edits sit unstaged. Next, decide whether to hand-create the group.
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- **Title**: the session name (`/rename`), else Claude's own title, else the session slug
|
|
235
|
+
- **Turn line**: duration of the last turn and when it finished; `busy`/`idle` mirrors the task's Claude status indicator
|
|
236
|
+
- **Recap**: the summary Claude writes a few minutes after a turn when the session idles. Until one exists, the dim `last prompt:` line shows what was asked last
|
|
237
|
+
- Nothing is shown for tasks without a session. The block refreshes as you move the cursor and every 5 seconds while a transcript changes. Disable with `claude_recap = false` under `[tools]` in `config.toml`
|
|
238
|
+
|
|
222
239
|
**Git status codes:**
|
|
223
240
|
- `M` - Modified
|
|
224
241
|
- `A` - Added (staged)
|
|
@@ -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.13.0"
|
|
75
75
|
tag_format = "v{version}"
|
|
76
76
|
branch = "main"
|
|
77
77
|
commit_message = "chore(release): {version} [skip ci]"
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.13.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 13, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -20,6 +20,7 @@ from . import __version__
|
|
|
20
20
|
from .commands import TaskTreeCommands
|
|
21
21
|
from .services.agent_sessions import list_agent_sessions, map_sessions_to_worktrees
|
|
22
22
|
from .services.claude_hooks import ensure_claude_hooks, has_claude_session
|
|
23
|
+
from .services.claude_sessions import ClaudeRecap, read_claude_recap, transcript_key
|
|
23
24
|
from .services.config import Config, ConfigError
|
|
24
25
|
from .services.forge import Forge, ForgeStatus, get_forge_status
|
|
25
26
|
from .services.git_ops import GitOps
|
|
@@ -245,6 +246,9 @@ class TaskTreeApp(App):
|
|
|
245
246
|
self._preserved_worktree_name: str | None = None
|
|
246
247
|
# Claude session statuses: task_name -> status string
|
|
247
248
|
self._claude_statuses: dict[str, str] = {}
|
|
249
|
+
# Transcript identity (path, mtime, size) behind the recap shown in
|
|
250
|
+
# the Info panel; None until the highlighted task has been scanned
|
|
251
|
+
self._recap_key: tuple[Path, int, int] | None = None
|
|
248
252
|
# Agent session states from `claude agents --json`: worktree path -> state
|
|
249
253
|
self._agent_sessions: dict[str, str] = {}
|
|
250
254
|
# Forge MR/CI statuses: worktree path -> ForgeStatus
|
|
@@ -648,6 +652,50 @@ class TaskTreeApp(App):
|
|
|
648
652
|
if statuses != self._claude_statuses:
|
|
649
653
|
self._claude_statuses = statuses
|
|
650
654
|
task_list.refresh_claude_indicators(statuses)
|
|
655
|
+
if self.current_task:
|
|
656
|
+
try:
|
|
657
|
+
status_panel = self.query_one("#status-display", StatusPanel)
|
|
658
|
+
except Exception:
|
|
659
|
+
return
|
|
660
|
+
status_panel.set_claude_live_status(statuses.get(self.current_task.name))
|
|
661
|
+
|
|
662
|
+
# Same tick keeps the recap fresh: a stat per poll, a read only on change
|
|
663
|
+
if self.config.claude_recap and self.current_task and task_list.has_focus:
|
|
664
|
+
self._update_claude_recap(self.current_task, self._recap_key)
|
|
665
|
+
|
|
666
|
+
@work(thread=True, exclusive=True, group="claude_recap_update")
|
|
667
|
+
def _update_claude_recap(self, task: Task, known_key: tuple[Path, int, int] | None) -> None:
|
|
668
|
+
"""Read the task's latest Claude transcript tail when it changed.
|
|
669
|
+
|
|
670
|
+
``known_key`` is passed in from the main thread so the worker never
|
|
671
|
+
reads mutable app state.
|
|
672
|
+
"""
|
|
673
|
+
key = transcript_key(task.path)
|
|
674
|
+
if key == known_key:
|
|
675
|
+
return
|
|
676
|
+
recap = read_claude_recap(key[0]) if key else None
|
|
677
|
+
if get_current_worker().is_cancelled:
|
|
678
|
+
return
|
|
679
|
+
self.call_from_thread(self._apply_claude_recap, task, key, recap)
|
|
680
|
+
|
|
681
|
+
def _apply_claude_recap(
|
|
682
|
+
self, task: Task, key: tuple[Path, int, int] | None, recap: ClaudeRecap | None
|
|
683
|
+
) -> None:
|
|
684
|
+
"""Push a fetched recap to the Info panel (main thread)."""
|
|
685
|
+
if not (self.current_task and self.current_task.name == task.name):
|
|
686
|
+
return
|
|
687
|
+
self._recap_key = key
|
|
688
|
+
try:
|
|
689
|
+
status_panel = self.query_one("#status-display", StatusPanel)
|
|
690
|
+
except Exception:
|
|
691
|
+
return
|
|
692
|
+
status_panel.set_claude_recap(task.name, recap, self._claude_statuses.get(task.name))
|
|
693
|
+
|
|
694
|
+
def _request_claude_recap(self, task: Task) -> None:
|
|
695
|
+
"""Start a fresh recap read for a newly shown task."""
|
|
696
|
+
self._recap_key = None
|
|
697
|
+
if self.config.claude_recap:
|
|
698
|
+
self._update_claude_recap(task, None)
|
|
651
699
|
|
|
652
700
|
def _poll_agent_sessions_tick(self) -> None:
|
|
653
701
|
"""Dispatch an agent-session poll worker (never subprocess on UI thread)."""
|
|
@@ -936,6 +984,7 @@ class TaskTreeApp(App):
|
|
|
936
984
|
self._set_info_title(self.current_task.name)
|
|
937
985
|
status_panel.set_loading(True)
|
|
938
986
|
self._update_task_summary(self.current_task)
|
|
987
|
+
self._request_claude_recap(self.current_task)
|
|
939
988
|
elif isinstance(event.widget, WorktreeList):
|
|
940
989
|
# Worktree list focused → show worktree details
|
|
941
990
|
if self.current_worktree:
|
|
@@ -983,6 +1032,7 @@ class TaskTreeApp(App):
|
|
|
983
1032
|
self._set_info_title(event.task.name)
|
|
984
1033
|
status_panel.set_loading(True)
|
|
985
1034
|
self._update_task_summary(event.task)
|
|
1035
|
+
self._request_claude_recap(event.task)
|
|
986
1036
|
else:
|
|
987
1037
|
worktree_list.clear_worktrees()
|
|
988
1038
|
status_panel.clear_status()
|
{tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/claude_hooks.py
RENAMED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
"""Claude Code
|
|
1
|
+
"""Claude Code integration: config/project-dir resolution, session discovery,
|
|
2
|
+
and the hook/settings writers for status monitoring and shared memory."""
|
|
2
3
|
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
6
|
+
import re
|
|
5
7
|
from datetime import datetime
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
@@ -22,20 +24,37 @@ def claude_config_dir() -> Path:
|
|
|
22
24
|
def _encode_project_path(folder: Path) -> str:
|
|
23
25
|
"""Encode a path the way Claude CLI names ~/.claude/projects/ entries.
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
Current CLIs replace every character outside [A-Za-z0-9] with "-".
|
|
26
28
|
"""
|
|
29
|
+
return re.sub(r"[^A-Za-z0-9]", "-", str(folder))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _encode_project_path_legacy(folder: Path) -> str:
|
|
33
|
+
"""Older CLIs replaced only "/" and "." — dirs they created keep "_" etc."""
|
|
27
34
|
return str(folder).replace("/", "-").replace(".", "-")
|
|
28
35
|
|
|
29
36
|
|
|
37
|
+
def project_dir_candidates(folder: Path) -> list[Path]:
|
|
38
|
+
"""Project dirs that may hold transcripts for a folder, current rule first.
|
|
39
|
+
|
|
40
|
+
A dir created by an older CLI for a path with "_" or spaces has a
|
|
41
|
+
different name than the current rule produces; checking both keeps
|
|
42
|
+
session resume and recaps working for it.
|
|
43
|
+
"""
|
|
44
|
+
base = claude_config_dir() / "projects"
|
|
45
|
+
names = dict.fromkeys((_encode_project_path(folder), _encode_project_path_legacy(folder)))
|
|
46
|
+
return [base / name for name in names]
|
|
47
|
+
|
|
48
|
+
|
|
30
49
|
def has_claude_session(folder: Path) -> bool:
|
|
31
50
|
"""Return True if Claude Code has a recorded session for the given folder.
|
|
32
51
|
|
|
33
52
|
Claude CLI stores transcripts at <config dir>/projects/<encoded-path>/*.jsonl.
|
|
34
53
|
"""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
return any(
|
|
55
|
+
project_dir.is_dir() and any(project_dir.glob("*.jsonl"))
|
|
56
|
+
for project_dir in project_dir_candidates(folder)
|
|
57
|
+
)
|
|
39
58
|
|
|
40
59
|
|
|
41
60
|
def repo_memory_dir(repo_path: Path) -> Path:
|
|
@@ -49,6 +68,29 @@ def repo_memory_dir(repo_path: Path) -> Path:
|
|
|
49
68
|
return claude_config_dir() / "projects" / _encode_project_path(repo_path) / "memory"
|
|
50
69
|
|
|
51
70
|
|
|
71
|
+
def migrate_legacy_memory_dir(repo_path: Path) -> bool:
|
|
72
|
+
"""Move memory saved under an old-rule project dir to the current one.
|
|
73
|
+
|
|
74
|
+
Worktree settings written before the encoder matched the current CLI
|
|
75
|
+
pointed repos with "_" (etc.) at a differently named dir; the CLI never
|
|
76
|
+
reads it for the main checkout. A one-time rename keeps that memory in
|
|
77
|
+
the shared pool. Skipped when the current dir already exists (nothing
|
|
78
|
+
can be merged safely). Returns True when a move happened.
|
|
79
|
+
"""
|
|
80
|
+
candidates = project_dir_candidates(repo_path)
|
|
81
|
+
if len(candidates) < 2:
|
|
82
|
+
return False
|
|
83
|
+
current, legacy = candidates[0] / "memory", candidates[1] / "memory"
|
|
84
|
+
if current.exists() or not legacy.is_dir():
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
current.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
legacy.rename(current)
|
|
89
|
+
except OSError:
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
52
94
|
def _make_hook(status: str, status_file: str) -> dict:
|
|
53
95
|
"""Create a single hook entry that writes status to the given file path."""
|
|
54
96
|
safe_path = status_file.replace("'", "'\\''")
|
|
@@ -199,6 +241,7 @@ def ensure_worktree_claude_settings(
|
|
|
199
241
|
|
|
200
242
|
# Merge, preserving user-defined hook groups (same policy as task hooks)
|
|
201
243
|
existing["hooks"] = _merge_hooks(existing.get("hooks"), _build_hooks_config(str(status_file)))
|
|
244
|
+
migrate_legacy_memory_dir(repo_path)
|
|
202
245
|
existing["autoMemoryDirectory"] = str(repo_memory_dir(repo_path))
|
|
203
246
|
|
|
204
247
|
settings_file.write_text(json.dumps(existing, indent=2) + "\n")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Read the recap of a task's latest Claude Code session from its transcript.
|
|
2
|
+
|
|
3
|
+
Claude Code appends to ``<config>/projects/<encoded cwd>/<session>.jsonl`` as
|
|
4
|
+
a session runs. The tail of that file carries what the terminal shows after
|
|
5
|
+
a turn: a ``system/turn_duration`` record ("Baked for 45s · done 5:27 PM")
|
|
6
|
+
and, when the session idles long enough, a ``system/away_summary`` record
|
|
7
|
+
(the "※ recap:" line). Title latches (``custom-title``, ``ai-title``,
|
|
8
|
+
``agent-name``) and ``last-prompt`` are rewritten near the end on every turn.
|
|
9
|
+
|
|
10
|
+
Only the tail is read, newest line first, so a multi-megabyte transcript
|
|
11
|
+
costs one bounded read regardless of its length.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, tzinfo
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .claude_hooks import project_dir_candidates
|
|
22
|
+
|
|
23
|
+
RECAP_SUFFIX = "(disable recaps in /config)"
|
|
24
|
+
TAIL_WINDOW = 1 << 20
|
|
25
|
+
TAIL_WINDOW_RETRY = 4 << 20
|
|
26
|
+
# Turns that span a laptop sleep report days of "duration"; anything above
|
|
27
|
+
# an hour is noise rather than work time
|
|
28
|
+
DURATION_CAP_MS = 3_600_000
|
|
29
|
+
LAST_PROMPT_MAX = 200
|
|
30
|
+
|
|
31
|
+
# Cheap substring gate so multi-hundred-KB tool-result lines are never parsed
|
|
32
|
+
_NEEDLES = (
|
|
33
|
+
b'"turn_duration"',
|
|
34
|
+
b'"away_summary"',
|
|
35
|
+
b'"custom-title"',
|
|
36
|
+
b'"ai-title"',
|
|
37
|
+
b'"agent-name"',
|
|
38
|
+
b'"last-prompt"',
|
|
39
|
+
)
|
|
40
|
+
_TITLE_KEYS = {
|
|
41
|
+
"custom-title": ("custom_title", "customTitle"),
|
|
42
|
+
"ai-title": ("ai_title", "aiTitle"),
|
|
43
|
+
"agent-name": ("agent_name", "agentName"),
|
|
44
|
+
"last-prompt": ("last_prompt", "lastPrompt"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ClaudeRecap:
|
|
50
|
+
"""What the latest Claude session in a folder last did."""
|
|
51
|
+
|
|
52
|
+
title: str
|
|
53
|
+
duration_ms: int | None
|
|
54
|
+
finished_at: datetime | None # tz-aware UTC
|
|
55
|
+
summary: str | None # away_summary content, marketing suffix stripped
|
|
56
|
+
last_prompt: str | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def transcript_key(folder: Path) -> tuple[Path, int, int] | None:
|
|
60
|
+
"""Return ``(path, mtime_ns, size)`` of the newest transcript for a folder.
|
|
61
|
+
|
|
62
|
+
One ``scandir`` per candidate project dir and no file reads, so callers
|
|
63
|
+
can poll it to detect changes cheaply.
|
|
64
|
+
"""
|
|
65
|
+
newest: tuple[int, int, Path] | None = None
|
|
66
|
+
for project_dir in project_dir_candidates(folder):
|
|
67
|
+
try:
|
|
68
|
+
entries = list(os.scandir(project_dir))
|
|
69
|
+
except OSError:
|
|
70
|
+
continue
|
|
71
|
+
for entry in entries:
|
|
72
|
+
try:
|
|
73
|
+
if not entry.name.endswith(".jsonl") or not entry.is_file():
|
|
74
|
+
continue
|
|
75
|
+
stat = entry.stat()
|
|
76
|
+
except OSError:
|
|
77
|
+
# Claude prunes old transcripts at startup; one can vanish
|
|
78
|
+
# between the scandir and the stat
|
|
79
|
+
continue
|
|
80
|
+
candidate = (stat.st_mtime_ns, stat.st_size, Path(entry.path))
|
|
81
|
+
if newest is None or candidate[0] > newest[0]:
|
|
82
|
+
newest = candidate
|
|
83
|
+
if newest is None:
|
|
84
|
+
return None
|
|
85
|
+
mtime_ns, size, path = newest
|
|
86
|
+
return path, mtime_ns, size
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_claude_recap(path: Path) -> ClaudeRecap | None:
|
|
90
|
+
"""Read the recap from one transcript (see ``transcript_key`` to pick it)."""
|
|
91
|
+
try:
|
|
92
|
+
lines, truncated = _tail_lines(path, TAIL_WINDOW)
|
|
93
|
+
found = _scan(lines)
|
|
94
|
+
if "duration_ms" not in found and truncated:
|
|
95
|
+
lines, _ = _tail_lines(path, TAIL_WINDOW_RETRY)
|
|
96
|
+
found = _scan(lines)
|
|
97
|
+
except OSError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
summary = found.get("summary")
|
|
101
|
+
last_prompt = found.get("last_prompt")
|
|
102
|
+
duration_ms = found.get("duration_ms")
|
|
103
|
+
if summary is None and last_prompt is None and duration_ms is None:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
title = (
|
|
107
|
+
found.get("custom_title")
|
|
108
|
+
or found.get("ai_title")
|
|
109
|
+
or found.get("agent_name")
|
|
110
|
+
or found.get("slug")
|
|
111
|
+
or path.stem[:8]
|
|
112
|
+
)
|
|
113
|
+
return ClaudeRecap(
|
|
114
|
+
title=title,
|
|
115
|
+
duration_ms=duration_ms,
|
|
116
|
+
finished_at=_parse_timestamp(found.get("finished_at")),
|
|
117
|
+
summary=summary,
|
|
118
|
+
last_prompt=last_prompt,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def format_duration(ms: int) -> str:
|
|
123
|
+
"""Render a turn duration the way Claude's terminal does: 45s, 3m 12s."""
|
|
124
|
+
if ms > DURATION_CAP_MS:
|
|
125
|
+
return ">1h"
|
|
126
|
+
seconds = max(0, ms // 1000)
|
|
127
|
+
if seconds < 60:
|
|
128
|
+
return f"{seconds}s"
|
|
129
|
+
return f"{seconds // 60}m {seconds % 60}s"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_clock(dt: datetime, tz: tzinfo | None = None) -> str:
|
|
133
|
+
"""Render a wall-clock time as 5:27 PM in ``tz`` (local time by default)."""
|
|
134
|
+
# %-I is not portable (Windows); strip the pad by hand instead
|
|
135
|
+
return dt.astimezone(tz).strftime("%I:%M %p").lstrip("0")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def strip_recap_suffix(text: str) -> str:
|
|
139
|
+
"""Drop the "(disable recaps in /config)" hint Claude appends to recaps."""
|
|
140
|
+
return text.strip().removesuffix(RECAP_SUFFIX).rstrip()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _tail_lines(path: Path, window: int) -> tuple[list[bytes], bool]:
|
|
144
|
+
"""Return the last ``window`` bytes as whole lines, newest first.
|
|
145
|
+
|
|
146
|
+
The second element says whether the file extends beyond the window
|
|
147
|
+
(so a caller can retry with a bigger one).
|
|
148
|
+
"""
|
|
149
|
+
with path.open("rb") as fh:
|
|
150
|
+
fh.seek(0, os.SEEK_END)
|
|
151
|
+
offset = max(0, fh.tell() - window)
|
|
152
|
+
fh.seek(offset)
|
|
153
|
+
data = fh.read()
|
|
154
|
+
lines = data.split(b"\n")
|
|
155
|
+
if offset > 0:
|
|
156
|
+
# The first chunk is a partial line cut by the seek
|
|
157
|
+
lines = lines[1:]
|
|
158
|
+
# The final line may be one Claude is still writing; it fails to parse
|
|
159
|
+
# and is skipped like any other bad line
|
|
160
|
+
return [line for line in reversed(lines) if line.strip()], offset > 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _scan(lines: list[bytes]) -> dict:
|
|
164
|
+
"""Collect recap fields from transcript lines ordered newest first."""
|
|
165
|
+
found: dict = {}
|
|
166
|
+
turn_seen = False
|
|
167
|
+
for raw in lines:
|
|
168
|
+
if not any(needle in raw for needle in _NEEDLES):
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
record = json.loads(raw)
|
|
172
|
+
except ValueError:
|
|
173
|
+
continue
|
|
174
|
+
if not isinstance(record, dict):
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
rtype = record.get("type")
|
|
178
|
+
if rtype == "system":
|
|
179
|
+
subtype = record.get("subtype")
|
|
180
|
+
if subtype == "turn_duration":
|
|
181
|
+
if "duration_ms" not in found:
|
|
182
|
+
found["duration_ms"] = _as_int(record.get("durationMs"))
|
|
183
|
+
found["finished_at"] = _as_text(record.get("timestamp"))
|
|
184
|
+
turn_seen = True
|
|
185
|
+
elif subtype == "away_summary" and not turn_seen and "summary" not in found:
|
|
186
|
+
# A recap older than the latest turn describes a state that
|
|
187
|
+
# has since moved on; only accept one newer than the turn
|
|
188
|
+
content = _as_text(record.get("content"))
|
|
189
|
+
if content:
|
|
190
|
+
found["summary"] = strip_recap_suffix(content)
|
|
191
|
+
elif rtype in _TITLE_KEYS:
|
|
192
|
+
field, source = _TITLE_KEYS[rtype]
|
|
193
|
+
value = _as_text(record.get(source))
|
|
194
|
+
if value and field not in found:
|
|
195
|
+
found[field] = _shorten_prompt(value) if field == "last_prompt" else value
|
|
196
|
+
|
|
197
|
+
slug = _as_text(record.get("slug"))
|
|
198
|
+
if slug and "slug" not in found:
|
|
199
|
+
found["slug"] = slug
|
|
200
|
+
|
|
201
|
+
if all(key in found for key in ("duration_ms", "last_prompt", "custom_title")):
|
|
202
|
+
break
|
|
203
|
+
return found
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _as_int(value) -> int | None:
|
|
207
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _as_text(value) -> str | None:
|
|
211
|
+
"""Return transcript text made safe for the terminal, or None if empty.
|
|
212
|
+
|
|
213
|
+
Transcript strings are model- or user-authored: control characters
|
|
214
|
+
(ESC and friends) would reach the terminal verbatim through Rich, so
|
|
215
|
+
they are dropped and whitespace is folded to single spaces.
|
|
216
|
+
"""
|
|
217
|
+
if not isinstance(value, str):
|
|
218
|
+
return None
|
|
219
|
+
text = re.sub(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]", "", value)
|
|
220
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
221
|
+
return text or None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _shorten_prompt(text: str) -> str:
|
|
225
|
+
if len(text) > LAST_PROMPT_MAX:
|
|
226
|
+
text = text[: LAST_PROMPT_MAX - 1].rstrip() + "…"
|
|
227
|
+
return text
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_timestamp(value: str | None) -> datetime | None:
|
|
231
|
+
if not value:
|
|
232
|
+
return None
|
|
233
|
+
try:
|
|
234
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
235
|
+
except ValueError:
|
|
236
|
+
return None
|
|
@@ -124,6 +124,7 @@ class Config:
|
|
|
124
124
|
claude_path: str = "claude"
|
|
125
125
|
claude_memory_dir: str = "~/.claude/tasktree-memory"
|
|
126
126
|
claude_repo_memory: bool = True
|
|
127
|
+
claude_recap: bool = True
|
|
127
128
|
glab_path: str = "glab"
|
|
128
129
|
gh_path: str = "gh"
|
|
129
130
|
|
|
@@ -197,6 +198,7 @@ class Config:
|
|
|
197
198
|
claude_path = os.path.expanduser(str(tools_config.get("claude_path", "claude")))
|
|
198
199
|
claude_memory_dir = tools_config.get("claude_memory_dir", "~/.claude/tasktree-memory")
|
|
199
200
|
claude_repo_memory = bool(tools_config.get("claude_repo_memory", True))
|
|
201
|
+
claude_recap = bool(tools_config.get("claude_recap", True))
|
|
200
202
|
glab_path = os.path.expanduser(str(tools_config.get("glab_path", "glab")))
|
|
201
203
|
gh_path = os.path.expanduser(str(tools_config.get("gh_path", "gh")))
|
|
202
204
|
|
|
@@ -267,6 +269,7 @@ class Config:
|
|
|
267
269
|
claude_path=claude_path,
|
|
268
270
|
claude_memory_dir=claude_memory_dir,
|
|
269
271
|
claude_repo_memory=claude_repo_memory,
|
|
272
|
+
claude_recap=claude_recap,
|
|
270
273
|
glab_path=glab_path,
|
|
271
274
|
gh_path=gh_path,
|
|
272
275
|
forge_enabled=forge_enabled,
|
|
@@ -416,6 +419,12 @@ claude_memory_dir = "{self._toml_escape(self.claude_memory_dir)}"
|
|
|
416
419
|
# same repo and with the main checkout. Set to false to disable.
|
|
417
420
|
claude_repo_memory = {str(self.claude_repo_memory).lower()}
|
|
418
421
|
|
|
422
|
+
# Show a recap of the task's latest Claude Code session under the git
|
|
423
|
+
# summary in the Info panel: session title, last turn duration, and the
|
|
424
|
+
# idle recap (or the last prompt when no recap exists yet). Read from the
|
|
425
|
+
# session transcript; no CLI call. Set to false to disable.
|
|
426
|
+
claude_recap = {str(self.claude_recap).lower()}
|
|
427
|
+
|
|
419
428
|
# Path to glab executable (GitLab CLI, used for MR/CI status)
|
|
420
429
|
glab_path = "{self._toml_escape(self.glab_path)}"
|
|
421
430
|
|
{tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/status_panel.py
RENAMED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
from rich.text import Text
|
|
4
4
|
from textual.widgets import Static
|
|
5
5
|
|
|
6
|
+
from ..services.claude_sessions import ClaudeRecap, format_clock, format_duration
|
|
6
7
|
from ..services.models import GitStatus, Task, Worktree
|
|
7
8
|
|
|
9
|
+
# .claude_status values worth a suffix on the recap line
|
|
10
|
+
_LIVE_LABELS = {"running": ("busy", "yellow"), "waiting": ("idle", "green")}
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
def _style_for_code(status_code: str) -> str:
|
|
10
14
|
"""Pick a display style for a git XY status code."""
|
|
@@ -29,6 +33,37 @@ class StatusPanel(Static):
|
|
|
29
33
|
self._current_task: Task | None = None
|
|
30
34
|
self._task_statuses: dict[str, GitStatus] = {}
|
|
31
35
|
self._mode: str = "worktree" # "worktree" or "task"
|
|
36
|
+
self._loading: bool = False
|
|
37
|
+
# Latest Claude session recap and the task it belongs to
|
|
38
|
+
self._claude_recap: ClaudeRecap | None = None
|
|
39
|
+
self._recap_task_name: str | None = None
|
|
40
|
+
self._claude_live: str | None = None
|
|
41
|
+
|
|
42
|
+
def set_claude_recap(
|
|
43
|
+
self, task_name: str, recap: ClaudeRecap | None, live: str | None = None
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Store the Claude recap for a task; re-render if that task is shown.
|
|
46
|
+
|
|
47
|
+
A recap that lands while the git summary is still loading is kept
|
|
48
|
+
and drawn with the summary, so it never replaces "Loading...".
|
|
49
|
+
"""
|
|
50
|
+
self._claude_recap = recap
|
|
51
|
+
self._recap_task_name = task_name
|
|
52
|
+
self._claude_live = live
|
|
53
|
+
self._rerender_task_view()
|
|
54
|
+
|
|
55
|
+
def set_claude_live_status(self, live: str | None) -> None:
|
|
56
|
+
"""Update the busy/idle suffix on the recap line."""
|
|
57
|
+
if live != self._claude_live:
|
|
58
|
+
self._claude_live = live
|
|
59
|
+
self._rerender_task_view()
|
|
60
|
+
|
|
61
|
+
def _rerender_task_view(self) -> None:
|
|
62
|
+
"""Redraw the task summary if the shown task owns the stored recap."""
|
|
63
|
+
if self._mode != "task" or self._loading or self._current_task is None:
|
|
64
|
+
return
|
|
65
|
+
if self._current_task.name == self._recap_task_name:
|
|
66
|
+
self._update_display()
|
|
32
67
|
|
|
33
68
|
def update_status(self, worktree: Worktree | None, status: GitStatus | None) -> None:
|
|
34
69
|
"""Update the status display for a worktree."""
|
|
@@ -53,6 +88,7 @@ class StatusPanel(Static):
|
|
|
53
88
|
statuses: Optional dict of worktree_name -> GitStatus with file details
|
|
54
89
|
"""
|
|
55
90
|
self._mode = "task"
|
|
91
|
+
self._loading = False
|
|
56
92
|
self._current_task = task
|
|
57
93
|
self._task_statuses = statuses or {}
|
|
58
94
|
self._update_display()
|
|
@@ -89,8 +125,36 @@ class StatusPanel(Static):
|
|
|
89
125
|
|
|
90
126
|
text.append("\n")
|
|
91
127
|
|
|
128
|
+
if self._claude_recap is not None and self._recap_task_name == task.name:
|
|
129
|
+
self._append_claude_recap(text, self._claude_recap)
|
|
130
|
+
|
|
92
131
|
self.update(text)
|
|
93
132
|
|
|
133
|
+
def _append_claude_recap(self, text: Text, recap: ClaudeRecap) -> None:
|
|
134
|
+
"""Append the Claude session block: title, turn line, recap or prompt."""
|
|
135
|
+
text.append("─ claude " + "─" * 17 + "\n", style="dim")
|
|
136
|
+
text.append(f"{recap.title}\n", style="bold")
|
|
137
|
+
|
|
138
|
+
turn_parts = []
|
|
139
|
+
if recap.duration_ms is not None:
|
|
140
|
+
turn_parts.append(f"Baked for {format_duration(recap.duration_ms)}")
|
|
141
|
+
if recap.finished_at is not None:
|
|
142
|
+
turn_parts.append(f"done {format_clock(recap.finished_at)}")
|
|
143
|
+
live = _LIVE_LABELS.get(self._claude_live or "")
|
|
144
|
+
if turn_parts or live:
|
|
145
|
+
text.append("✻ ", style="magenta")
|
|
146
|
+
text.append(" · ".join(turn_parts), style="dim")
|
|
147
|
+
if live:
|
|
148
|
+
label, style = live
|
|
149
|
+
text.append(f"{' · ' if turn_parts else ''}{label}", style=style)
|
|
150
|
+
text.append("\n")
|
|
151
|
+
|
|
152
|
+
if recap.summary:
|
|
153
|
+
text.append("※ recap: ", style="cyan")
|
|
154
|
+
text.append(f"{recap.summary}\n")
|
|
155
|
+
elif recap.last_prompt:
|
|
156
|
+
text.append(f"last prompt: {recap.last_prompt}\n", style="dim")
|
|
157
|
+
|
|
94
158
|
def _render_worktree_status(self) -> None:
|
|
95
159
|
"""Render the worktree-specific status view."""
|
|
96
160
|
if not self._worktree_name or self._status is None:
|
|
@@ -149,6 +213,8 @@ class StatusPanel(Static):
|
|
|
149
213
|
self._status = None
|
|
150
214
|
self._current_task = None
|
|
151
215
|
self._mode = "worktree"
|
|
216
|
+
self._claude_recap = None
|
|
217
|
+
self._recap_task_name = None
|
|
152
218
|
self.update(Text("No worktree selected", style="dim"))
|
|
153
219
|
|
|
154
220
|
def set_loading(self, loading: bool = True) -> None:
|
|
@@ -157,6 +223,7 @@ class StatusPanel(Static):
|
|
|
157
223
|
Args:
|
|
158
224
|
loading: If True, show loading indicator. If False, restore display.
|
|
159
225
|
"""
|
|
226
|
+
self._loading = loading
|
|
160
227
|
if loading:
|
|
161
228
|
self.update(Text("Loading...", style="dim italic"))
|
|
162
229
|
else:
|