tasktree-manager 2.7.1__tar.gz → 2.8.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.7.1 → tasktree_manager-2.8.0}/.github/workflows/release.yml +4 -2
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.gitignore +4 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/CHANGELOG.md +15 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/PKG-INFO +1 -1
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/docs/configuration.md +12 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/docs/user-guide.md +1 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/pyproject.toml +1 -1
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/app.py +5 -0
- tasktree_manager-2.8.0/tasktree_manager/services/claude_hooks.py +139 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/services/config.py +10 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/services/task_manager.py +24 -0
- tasktree_manager-2.8.0/tests/test_claude_hooks.py +207 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_config.py +10 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_task_manager.py +49 -0
- tasktree_manager-2.7.1/tasktree_manager/services/claude_hooks.py +0 -71
- tasktree_manager-2.7.1/tests/test_claude_hooks.py +0 -106
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/LICENSE +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/OPENSPEC.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/README.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/docs/installation.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/mise.toml +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/services/models.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tests/test_status_panel.py +0 -0
|
@@ -77,9 +77,11 @@ jobs:
|
|
|
77
77
|
release_type="${{ inputs.release_type || 'auto' }}"
|
|
78
78
|
|
|
79
79
|
if [ "$release_type" = "auto" ]; then
|
|
80
|
-
# Use semantic-release to determine bump type from commit history
|
|
80
|
+
# Use semantic-release to determine bump type from commit history.
|
|
81
|
+
# When no qualifying commits exist, `version --print` prints the
|
|
82
|
+
# CURRENT version — require an actual bump before releasing.
|
|
81
83
|
VERSION=$(semantic-release version --print 2>/dev/null || echo "")
|
|
82
|
-
if [ -n "$VERSION" ]; then
|
|
84
|
+
if [ -n "$VERSION" ] && [ "$VERSION" != "$current_version" ]; then
|
|
83
85
|
# Determine bump type by comparing versions
|
|
84
86
|
IFS='.' read -r cur_major cur_minor cur_patch <<< "$current_version"
|
|
85
87
|
IFS='.' read -r new_major new_minor new_patch <<< "$VERSION"
|
|
@@ -15,6 +15,21 @@ 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.8.0] - 2026-07-14
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **claude**: Per-repo Claude memory for worktree sessions
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Release only when semantic-release bumps the version
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## [2.7.2] - 2026-07-13
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- Ignore uv.lock
|
|
31
|
+
|
|
32
|
+
|
|
18
33
|
## [2.7.1] - 2026-07-13
|
|
19
34
|
|
|
20
35
|
### Changed
|
|
@@ -153,6 +153,18 @@ claude_path = "claude"
|
|
|
153
153
|
# Default: "~/.claude/tasktree-memory"
|
|
154
154
|
claude_memory_dir = "~/.claude/tasktree-memory"
|
|
155
155
|
|
|
156
|
+
# Per-repo Claude Code memory for worktree sessions.
|
|
157
|
+
# Sessions started inside a worktree would otherwise key their auto memory to
|
|
158
|
+
# the worktree path — memory that dies with the worktree. When enabled,
|
|
159
|
+
# tasktree-manager writes .claude/settings.local.json into each worktree
|
|
160
|
+
# pointing "autoMemoryDirectory" at the main repo's own memory directory
|
|
161
|
+
# (~/.claude/projects/<encoded-repo-path>/memory). What Claude learns in any
|
|
162
|
+
# worktree of a repo survives worktree deletion and is shared with future
|
|
163
|
+
# worktrees of that repo and with sessions in the main checkout. The settings
|
|
164
|
+
# file is added to the repo's .git/info/exclude so worktrees stay clean.
|
|
165
|
+
# Default: true
|
|
166
|
+
claude_repo_memory = true
|
|
167
|
+
|
|
156
168
|
# Preferred shell
|
|
157
169
|
# Leave empty to use $SHELL environment variable
|
|
158
170
|
# Default: "" (uses $SHELL, falls back to /bin/bash)
|
|
@@ -367,6 +367,7 @@ Useful when you're spinning up several similar tasks that share the same set of
|
|
|
367
367
|
- The CLI variant (`c`) generates a task-level `CLAUDE.md` with task/worktree context, copies each repo's own `CLAUDE.md` into worktrees whose branch predates it (never generating stub content), and installs hooks that report session status back to tasktree-manager.
|
|
368
368
|
- Session status (`⟳` running, `!` waiting, `✓` ended) appears next to the task name
|
|
369
369
|
- Both variants point the session's Claude Code auto-memory at a shared pool (`claude_memory_dir`, default `~/.claude/tasktree-memory`). Task folders are not git repos, so without this each task would get its own memory directory that is orphaned when the task is deleted; with the shared pool, what Claude learns in one task carries over to future tasks.
|
|
370
|
+
- Each worktree additionally gets its own `.claude/settings.local.json` pointing auto-memory at the **main repo's** memory directory (`claude_repo_memory`, enabled by default). Sessions started manually inside a worktree (`cd` + `claude`) would otherwise key their memory to the worktree path and lose it when the worktree is deleted; with this, what Claude learns about a repo persists across worktrees and is shared with sessions in the main checkout. The file is git-excluded so worktrees stay clean, and these sessions also report status back to the task indicator.
|
|
370
371
|
|
|
371
372
|
### General
|
|
372
373
|
|
|
@@ -70,7 +70,7 @@ version-file = "tasktree_manager/_version.py"
|
|
|
70
70
|
# Semantic Release Configuration
|
|
71
71
|
[tool.semantic_release]
|
|
72
72
|
version_toml = ["pyproject.toml:tool.semantic_release.version"]
|
|
73
|
-
version = "2.
|
|
73
|
+
version = "2.8.0"
|
|
74
74
|
tag_format = "v{version}"
|
|
75
75
|
branch = "main"
|
|
76
76
|
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.8.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 8, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -1379,6 +1379,7 @@ class TaskTreeApp(App):
|
|
|
1379
1379
|
fresh_task = self.task_manager.get_task(self.current_task.name)
|
|
1380
1380
|
if fresh_task:
|
|
1381
1381
|
self.task_manager.ensure_claude_md_files(fresh_task)
|
|
1382
|
+
self.task_manager.ensure_worktree_settings(fresh_task)
|
|
1382
1383
|
|
|
1383
1384
|
ensure_claude_hooks(task_path, self.config.claude_memory_dir)
|
|
1384
1385
|
if has_claude_session(task_path):
|
|
@@ -1401,6 +1402,10 @@ class TaskTreeApp(App):
|
|
|
1401
1402
|
self.notify("Task directory not found", severity="error")
|
|
1402
1403
|
return
|
|
1403
1404
|
|
|
1405
|
+
fresh_task = self.task_manager.get_task(self.current_task.name)
|
|
1406
|
+
if fresh_task:
|
|
1407
|
+
self.task_manager.ensure_worktree_settings(fresh_task)
|
|
1408
|
+
|
|
1404
1409
|
ensure_claude_hooks(task_path, self.config.claude_memory_dir)
|
|
1405
1410
|
encoded_path = quote(str(task_path), safe="")
|
|
1406
1411
|
url = f"claude://code/new?folder={encoded_path}"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Claude Code hook configuration for status monitoring."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _encode_project_path(folder: Path) -> str:
|
|
8
|
+
"""Encode a path the way Claude CLI names ~/.claude/projects/ entries.
|
|
9
|
+
|
|
10
|
+
The encoding replaces "/" and "." with "-".
|
|
11
|
+
"""
|
|
12
|
+
return str(folder).replace("/", "-").replace(".", "-")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def has_claude_session(folder: Path) -> bool:
|
|
16
|
+
"""Return True if Claude Code has a recorded session for the given folder.
|
|
17
|
+
|
|
18
|
+
Claude CLI stores transcripts at ~/.claude/projects/<encoded-path>/*.jsonl.
|
|
19
|
+
"""
|
|
20
|
+
project_dir = Path.home() / ".claude" / "projects" / _encode_project_path(folder)
|
|
21
|
+
if not project_dir.is_dir():
|
|
22
|
+
return False
|
|
23
|
+
return any(project_dir.glob("*.jsonl"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def repo_memory_dir(repo_path: Path) -> Path:
|
|
27
|
+
"""Return the main repo's own Claude auto-memory directory.
|
|
28
|
+
|
|
29
|
+
Sessions running in the main checkout use this directory by default,
|
|
30
|
+
so pointing worktree sessions here gives every worktree of a repo —
|
|
31
|
+
and the main checkout itself — one shared memory that outlives any
|
|
32
|
+
single worktree.
|
|
33
|
+
"""
|
|
34
|
+
return Path.home() / ".claude" / "projects" / _encode_project_path(repo_path) / "memory"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _make_hook(status: str, status_file: str) -> dict:
|
|
38
|
+
"""Create a single hook entry that writes status to the given file path."""
|
|
39
|
+
safe_path = status_file.replace("'", "'\\''")
|
|
40
|
+
return {
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": f'printf \'{{"status":"{status}","ts":%d}}\' $(date +%s) > \'{safe_path}\'',
|
|
43
|
+
"async": True,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_hooks_config(status_file: str) -> dict:
|
|
48
|
+
"""Build hooks config with absolute path to status file."""
|
|
49
|
+
return {
|
|
50
|
+
"SessionStart": [{"hooks": [_make_hook("running", status_file)]}],
|
|
51
|
+
"UserPromptSubmit": [{"hooks": [_make_hook("running", status_file)]}],
|
|
52
|
+
"Stop": [{"hooks": [_make_hook("waiting", status_file)]}],
|
|
53
|
+
"SessionEnd": [{"hooks": [_make_hook("ended", status_file)]}],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_settings(settings_file: Path) -> dict:
|
|
58
|
+
"""Read existing settings JSON, tolerating a missing or corrupt file."""
|
|
59
|
+
if settings_file.exists():
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(settings_file.read_text())
|
|
62
|
+
except (json.JSONDecodeError, OSError):
|
|
63
|
+
return {}
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ensure_claude_hooks(task_path: Path, memory_dir: str = "") -> None:
|
|
68
|
+
"""Create .claude/settings.local.json with status-reporting hooks.
|
|
69
|
+
|
|
70
|
+
Merges with existing settings if the file already exists, preserving
|
|
71
|
+
user's own configuration while adding/updating the status hooks.
|
|
72
|
+
|
|
73
|
+
Task folders are not git repositories, so Claude Code keys its auto
|
|
74
|
+
memory to the task path — memory that becomes orphaned when the task
|
|
75
|
+
is deleted. When memory_dir is set, autoMemoryDirectory redirects all
|
|
76
|
+
task sessions to one shared pool that persists across tasks.
|
|
77
|
+
"""
|
|
78
|
+
claude_dir = task_path / ".claude"
|
|
79
|
+
claude_dir.mkdir(exist_ok=True)
|
|
80
|
+
|
|
81
|
+
settings_file = claude_dir / "settings.local.json"
|
|
82
|
+
existing = _load_settings(settings_file)
|
|
83
|
+
|
|
84
|
+
# Merge hooks into existing settings
|
|
85
|
+
existing["hooks"] = _build_hooks_config(str(task_path / ".claude_status"))
|
|
86
|
+
|
|
87
|
+
if memory_dir:
|
|
88
|
+
existing["autoMemoryDirectory"] = str(Path(memory_dir).expanduser())
|
|
89
|
+
|
|
90
|
+
settings_file.write_text(json.dumps(existing, indent=2) + "\n")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _exclude_settings_from_git(repo_path: Path) -> None:
|
|
94
|
+
"""Hide .claude/settings.local.json from git status in the repo.
|
|
95
|
+
|
|
96
|
+
Appends the path to <repo>/.git/info/exclude, which worktrees share
|
|
97
|
+
with the main checkout, so the generated settings file never shows
|
|
98
|
+
up as an untracked change in any of them.
|
|
99
|
+
"""
|
|
100
|
+
git_dir = repo_path / ".git"
|
|
101
|
+
if not git_dir.is_dir():
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
entry = ".claude/settings.local.json"
|
|
105
|
+
exclude_file = git_dir / "info" / "exclude"
|
|
106
|
+
try:
|
|
107
|
+
existing = exclude_file.read_text().splitlines() if exclude_file.exists() else []
|
|
108
|
+
if entry in existing:
|
|
109
|
+
return
|
|
110
|
+
exclude_file.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
with exclude_file.open("a") as f:
|
|
112
|
+
f.write(entry + "\n")
|
|
113
|
+
except OSError:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ensure_worktree_claude_settings(
|
|
118
|
+
worktree_path: Path, repo_path: Path, status_file: Path
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Create .claude/settings.local.json inside a worktree.
|
|
121
|
+
|
|
122
|
+
Points autoMemoryDirectory at the main repo's own memory directory, so
|
|
123
|
+
memory saved while working in any worktree of a repo persists after the
|
|
124
|
+
worktree is deleted and is shared with future worktrees and with
|
|
125
|
+
sessions in the main checkout. Also installs the status-reporting hooks
|
|
126
|
+
(writing to the task's status file) so sessions started manually inside
|
|
127
|
+
a worktree light up the task indicator.
|
|
128
|
+
"""
|
|
129
|
+
claude_dir = worktree_path / ".claude"
|
|
130
|
+
claude_dir.mkdir(exist_ok=True)
|
|
131
|
+
|
|
132
|
+
settings_file = claude_dir / "settings.local.json"
|
|
133
|
+
existing = _load_settings(settings_file)
|
|
134
|
+
|
|
135
|
+
existing["hooks"] = _build_hooks_config(str(status_file))
|
|
136
|
+
existing["autoMemoryDirectory"] = str(repo_memory_dir(repo_path))
|
|
137
|
+
|
|
138
|
+
settings_file.write_text(json.dumps(existing, indent=2) + "\n")
|
|
139
|
+
_exclude_settings_from_git(repo_path)
|
|
@@ -95,6 +95,7 @@ class Config:
|
|
|
95
95
|
hunk_path: str = "hunk"
|
|
96
96
|
claude_path: str = "claude"
|
|
97
97
|
claude_memory_dir: str = "~/.claude/tasktree-memory"
|
|
98
|
+
claude_repo_memory: bool = True
|
|
98
99
|
shell: str = ""
|
|
99
100
|
|
|
100
101
|
# Keybindings (action -> key mapping)
|
|
@@ -151,6 +152,7 @@ class Config:
|
|
|
151
152
|
hunk_path = tools_config.get("hunk_path", "hunk")
|
|
152
153
|
claude_path = tools_config.get("claude_path", "claude")
|
|
153
154
|
claude_memory_dir = tools_config.get("claude_memory_dir", "~/.claude/tasktree-memory")
|
|
155
|
+
claude_repo_memory = bool(tools_config.get("claude_repo_memory", True))
|
|
154
156
|
shell = tools_config.get("shell", "")
|
|
155
157
|
|
|
156
158
|
# Keybindings - start with defaults and override with config
|
|
@@ -193,6 +195,7 @@ class Config:
|
|
|
193
195
|
hunk_path=hunk_path,
|
|
194
196
|
claude_path=claude_path,
|
|
195
197
|
claude_memory_dir=claude_memory_dir,
|
|
198
|
+
claude_repo_memory=claude_repo_memory,
|
|
196
199
|
shell=shell,
|
|
197
200
|
keybindings=keybindings,
|
|
198
201
|
symlink_blocklist=symlink_blocklist,
|
|
@@ -328,6 +331,13 @@ claude_path = "{self._toml_escape(self.claude_path)}"
|
|
|
328
331
|
# memory that is orphaned when the task is deleted. Set to "" to disable.
|
|
329
332
|
claude_memory_dir = "{self._toml_escape(self.claude_memory_dir)}"
|
|
330
333
|
|
|
334
|
+
# Per-repo Claude Code memory for worktree sessions. Writes
|
|
335
|
+
# .claude/settings.local.json into each worktree pointing its auto-memory
|
|
336
|
+
# at the main repo's memory directory, so memory saved in a worktree
|
|
337
|
+
# survives worktree deletion and is shared with future worktrees of the
|
|
338
|
+
# same repo and with the main checkout. Set to false to disable.
|
|
339
|
+
claude_repo_memory = {str(self.claude_repo_memory).lower()}
|
|
340
|
+
|
|
331
341
|
# Preferred shell (leave empty to use $SHELL)
|
|
332
342
|
shell = "{self._toml_escape(self.shell)}"
|
|
333
343
|
|
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
+
from .claude_hooks import ensure_worktree_claude_settings
|
|
11
12
|
from .config import Config
|
|
12
13
|
from .models import RepoIssue, Task, TaskSafetyReport, Worktree
|
|
13
14
|
|
|
@@ -182,6 +183,14 @@ class TaskManager:
|
|
|
182
183
|
# Create symlinks for gitignored files
|
|
183
184
|
self._create_gitignore_symlinks(repo_path, worktree_path)
|
|
184
185
|
|
|
186
|
+
# Point Claude auto-memory at the repo's own memory dir so it
|
|
187
|
+
# survives worktree deletion (sessions may start here manually,
|
|
188
|
+
# before any launch-time backfill runs)
|
|
189
|
+
if self.config.claude_repo_memory:
|
|
190
|
+
ensure_worktree_claude_settings(
|
|
191
|
+
worktree_path, repo_path, task.path / ".claude_status"
|
|
192
|
+
)
|
|
193
|
+
|
|
185
194
|
def _parse_gitignore(self, gitignore_path: Path) -> list[str]:
|
|
186
195
|
"""Parse .gitignore and return glob patterns for files (not directories).
|
|
187
196
|
|
|
@@ -456,6 +465,21 @@ class TaskManager:
|
|
|
456
465
|
|
|
457
466
|
return success_repos, failed_repos
|
|
458
467
|
|
|
468
|
+
def ensure_worktree_settings(self, task: Task) -> None:
|
|
469
|
+
"""Backfill per-worktree Claude settings for existing worktrees.
|
|
470
|
+
|
|
471
|
+
Covers worktrees created before repo memory support (or outside
|
|
472
|
+
tasktree). New worktrees get their settings at creation time.
|
|
473
|
+
"""
|
|
474
|
+
if not self.config.claude_repo_memory:
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
status_file = task.path / ".claude_status"
|
|
478
|
+
for worktree in task.worktrees:
|
|
479
|
+
repo_path = self.config.repos_dir / worktree.name
|
|
480
|
+
if repo_path.exists() and worktree.path.exists():
|
|
481
|
+
ensure_worktree_claude_settings(worktree.path, repo_path, status_file)
|
|
482
|
+
|
|
459
483
|
def ensure_claude_md_files(self, task: Task) -> None:
|
|
460
484
|
"""Create the task CLAUDE.md and backfill worktree ones from the repo.
|
|
461
485
|
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Tests for Claude Code hook and settings configuration."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tasktree_manager.services.claude_hooks import (
|
|
7
|
+
ensure_claude_hooks,
|
|
8
|
+
ensure_worktree_claude_settings,
|
|
9
|
+
has_claude_session,
|
|
10
|
+
repo_memory_dir,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestEnsureClaudeHooks:
|
|
15
|
+
"""Tests for ensure_claude_hooks."""
|
|
16
|
+
|
|
17
|
+
def test_creates_settings_with_hooks(self, tmp_path):
|
|
18
|
+
"""Test that settings.local.json is created with status hooks."""
|
|
19
|
+
ensure_claude_hooks(tmp_path)
|
|
20
|
+
|
|
21
|
+
settings_file = tmp_path / ".claude" / "settings.local.json"
|
|
22
|
+
assert settings_file.exists()
|
|
23
|
+
|
|
24
|
+
settings = json.loads(settings_file.read_text())
|
|
25
|
+
for event in ("SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"):
|
|
26
|
+
assert event in settings["hooks"]
|
|
27
|
+
assert str(tmp_path / ".claude_status") in json.dumps(settings["hooks"])
|
|
28
|
+
|
|
29
|
+
def test_no_memory_dir_by_default(self, tmp_path):
|
|
30
|
+
"""Test that autoMemoryDirectory is not written when memory_dir is empty."""
|
|
31
|
+
ensure_claude_hooks(tmp_path)
|
|
32
|
+
|
|
33
|
+
settings_file = tmp_path / ".claude" / "settings.local.json"
|
|
34
|
+
settings = json.loads(settings_file.read_text())
|
|
35
|
+
assert "autoMemoryDirectory" not in settings
|
|
36
|
+
|
|
37
|
+
def test_memory_dir_written_expanded(self, tmp_path):
|
|
38
|
+
"""Test that memory_dir is written as an expanded absolute path."""
|
|
39
|
+
ensure_claude_hooks(tmp_path, "~/.claude/tasktree-memory")
|
|
40
|
+
|
|
41
|
+
settings_file = tmp_path / ".claude" / "settings.local.json"
|
|
42
|
+
settings = json.loads(settings_file.read_text())
|
|
43
|
+
assert settings["autoMemoryDirectory"] == str(Path.home() / ".claude" / "tasktree-memory")
|
|
44
|
+
|
|
45
|
+
def test_preserves_existing_settings(self, tmp_path):
|
|
46
|
+
"""Test that unrelated existing settings keys survive a rewrite."""
|
|
47
|
+
claude_dir = tmp_path / ".claude"
|
|
48
|
+
claude_dir.mkdir()
|
|
49
|
+
settings_file = claude_dir / "settings.local.json"
|
|
50
|
+
settings_file.write_text(json.dumps({"permissions": {"allow": ["Bash(ls *)"]}}))
|
|
51
|
+
|
|
52
|
+
ensure_claude_hooks(tmp_path, "~/.claude/tasktree-memory")
|
|
53
|
+
|
|
54
|
+
settings = json.loads(settings_file.read_text())
|
|
55
|
+
assert settings["permissions"] == {"allow": ["Bash(ls *)"]}
|
|
56
|
+
assert "hooks" in settings
|
|
57
|
+
assert "autoMemoryDirectory" in settings
|
|
58
|
+
|
|
59
|
+
def test_empty_memory_dir_leaves_existing_value(self, tmp_path):
|
|
60
|
+
"""Test that an existing autoMemoryDirectory is kept when memory_dir is empty."""
|
|
61
|
+
claude_dir = tmp_path / ".claude"
|
|
62
|
+
claude_dir.mkdir()
|
|
63
|
+
settings_file = claude_dir / "settings.local.json"
|
|
64
|
+
settings_file.write_text(json.dumps({"autoMemoryDirectory": "/custom/memory"}))
|
|
65
|
+
|
|
66
|
+
ensure_claude_hooks(tmp_path, "")
|
|
67
|
+
|
|
68
|
+
settings = json.loads(settings_file.read_text())
|
|
69
|
+
assert settings["autoMemoryDirectory"] == "/custom/memory"
|
|
70
|
+
|
|
71
|
+
def test_memory_dir_overrides_existing_value(self, tmp_path):
|
|
72
|
+
"""Test that a configured memory_dir replaces a previously written one."""
|
|
73
|
+
ensure_claude_hooks(tmp_path, "/old/memory")
|
|
74
|
+
ensure_claude_hooks(tmp_path, "/new/memory")
|
|
75
|
+
|
|
76
|
+
settings_file = tmp_path / ".claude" / "settings.local.json"
|
|
77
|
+
settings = json.loads(settings_file.read_text())
|
|
78
|
+
assert settings["autoMemoryDirectory"] == "/new/memory"
|
|
79
|
+
|
|
80
|
+
def test_recovers_from_corrupt_settings(self, tmp_path):
|
|
81
|
+
"""Test that invalid JSON in existing settings is replaced, not fatal."""
|
|
82
|
+
claude_dir = tmp_path / ".claude"
|
|
83
|
+
claude_dir.mkdir()
|
|
84
|
+
settings_file = claude_dir / "settings.local.json"
|
|
85
|
+
settings_file.write_text("{not json")
|
|
86
|
+
|
|
87
|
+
ensure_claude_hooks(tmp_path, "~/.claude/tasktree-memory")
|
|
88
|
+
|
|
89
|
+
settings = json.loads(settings_file.read_text())
|
|
90
|
+
assert "hooks" in settings
|
|
91
|
+
assert "autoMemoryDirectory" in settings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestRepoMemoryDir:
|
|
95
|
+
"""Tests for repo_memory_dir."""
|
|
96
|
+
|
|
97
|
+
def test_encodes_repo_path(self, tmp_path, monkeypatch):
|
|
98
|
+
"""Test that the repo path is encoded like Claude project dirs."""
|
|
99
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
100
|
+
result = repo_memory_dir(Path("/repos/work.3"))
|
|
101
|
+
assert result == tmp_path / ".claude" / "projects" / "-repos-work-3" / "memory"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestEnsureWorktreeClaudeSettings:
|
|
105
|
+
"""Tests for ensure_worktree_claude_settings."""
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _make_repo_and_worktree(tmp_path):
|
|
109
|
+
repo_path = tmp_path / "repos" / "work3"
|
|
110
|
+
(repo_path / ".git" / "info").mkdir(parents=True)
|
|
111
|
+
worktree_path = tmp_path / "tasks" / "TASK-1" / "work3"
|
|
112
|
+
worktree_path.mkdir(parents=True)
|
|
113
|
+
status_file = tmp_path / "tasks" / "TASK-1" / ".claude_status"
|
|
114
|
+
return repo_path, worktree_path, status_file
|
|
115
|
+
|
|
116
|
+
def test_points_memory_at_repo(self, tmp_path, monkeypatch):
|
|
117
|
+
"""Test that autoMemoryDirectory targets the main repo's memory dir."""
|
|
118
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
119
|
+
repo_path, worktree_path, status_file = self._make_repo_and_worktree(tmp_path)
|
|
120
|
+
|
|
121
|
+
ensure_worktree_claude_settings(worktree_path, repo_path, status_file)
|
|
122
|
+
|
|
123
|
+
settings = json.loads((worktree_path / ".claude" / "settings.local.json").read_text())
|
|
124
|
+
assert settings["autoMemoryDirectory"] == str(repo_memory_dir(repo_path))
|
|
125
|
+
|
|
126
|
+
def test_writes_status_hooks(self, tmp_path, monkeypatch):
|
|
127
|
+
"""Test that status hooks write to the task's status file."""
|
|
128
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
129
|
+
repo_path, worktree_path, status_file = self._make_repo_and_worktree(tmp_path)
|
|
130
|
+
|
|
131
|
+
ensure_worktree_claude_settings(worktree_path, repo_path, status_file)
|
|
132
|
+
|
|
133
|
+
settings = json.loads((worktree_path / ".claude" / "settings.local.json").read_text())
|
|
134
|
+
for event in ("SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"):
|
|
135
|
+
assert event in settings["hooks"]
|
|
136
|
+
assert str(status_file) in json.dumps(settings["hooks"])
|
|
137
|
+
|
|
138
|
+
def test_preserves_existing_settings(self, tmp_path, monkeypatch):
|
|
139
|
+
"""Test that unrelated existing settings keys survive a rewrite."""
|
|
140
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
141
|
+
repo_path, worktree_path, status_file = self._make_repo_and_worktree(tmp_path)
|
|
142
|
+
claude_dir = worktree_path / ".claude"
|
|
143
|
+
claude_dir.mkdir()
|
|
144
|
+
settings_file = claude_dir / "settings.local.json"
|
|
145
|
+
settings_file.write_text(json.dumps({"permissions": {"allow": ["Bash(ls *)"]}}))
|
|
146
|
+
|
|
147
|
+
ensure_worktree_claude_settings(worktree_path, repo_path, status_file)
|
|
148
|
+
|
|
149
|
+
settings = json.loads(settings_file.read_text())
|
|
150
|
+
assert settings["permissions"] == {"allow": ["Bash(ls *)"]}
|
|
151
|
+
assert "autoMemoryDirectory" in settings
|
|
152
|
+
|
|
153
|
+
def test_excludes_settings_from_git(self, tmp_path, monkeypatch):
|
|
154
|
+
"""Test that the settings file is added to the repo's git exclude."""
|
|
155
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
156
|
+
repo_path, worktree_path, status_file = self._make_repo_and_worktree(tmp_path)
|
|
157
|
+
|
|
158
|
+
ensure_worktree_claude_settings(worktree_path, repo_path, status_file)
|
|
159
|
+
|
|
160
|
+
exclude = (repo_path / ".git" / "info" / "exclude").read_text()
|
|
161
|
+
assert ".claude/settings.local.json" in exclude
|
|
162
|
+
|
|
163
|
+
def test_exclude_entry_is_idempotent(self, tmp_path, monkeypatch):
|
|
164
|
+
"""Test that repeated calls do not duplicate the exclude entry."""
|
|
165
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
166
|
+
repo_path, worktree_path, status_file = self._make_repo_and_worktree(tmp_path)
|
|
167
|
+
|
|
168
|
+
ensure_worktree_claude_settings(worktree_path, repo_path, status_file)
|
|
169
|
+
ensure_worktree_claude_settings(worktree_path, repo_path, status_file)
|
|
170
|
+
|
|
171
|
+
lines = (repo_path / ".git" / "info" / "exclude").read_text().splitlines()
|
|
172
|
+
assert lines.count(".claude/settings.local.json") == 1
|
|
173
|
+
|
|
174
|
+
def test_git_file_instead_of_dir(self, tmp_path, monkeypatch):
|
|
175
|
+
"""Test that a .git file (linked worktree/submodule) is skipped safely."""
|
|
176
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
177
|
+
repo_path = tmp_path / "repos" / "work3"
|
|
178
|
+
repo_path.mkdir(parents=True)
|
|
179
|
+
(repo_path / ".git").write_text("gitdir: /elsewhere\n")
|
|
180
|
+
worktree_path = tmp_path / "tasks" / "TASK-1" / "work3"
|
|
181
|
+
worktree_path.mkdir(parents=True)
|
|
182
|
+
|
|
183
|
+
ensure_worktree_claude_settings(
|
|
184
|
+
worktree_path, repo_path, tmp_path / "tasks" / "TASK-1" / ".claude_status"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
assert (worktree_path / ".claude" / "settings.local.json").exists()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TestHasClaudeSession:
|
|
191
|
+
"""Tests for has_claude_session."""
|
|
192
|
+
|
|
193
|
+
def test_no_project_dir(self, tmp_path, monkeypatch):
|
|
194
|
+
"""Test False when no project directory exists."""
|
|
195
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
196
|
+
assert has_claude_session(Path("/some/task")) is False
|
|
197
|
+
|
|
198
|
+
def test_project_dir_with_transcript(self, tmp_path, monkeypatch):
|
|
199
|
+
"""Test True when the encoded project directory holds a transcript."""
|
|
200
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
201
|
+
task_path = Path("/some/task.dir")
|
|
202
|
+
encoded = "-some-task-dir"
|
|
203
|
+
project_dir = tmp_path / ".claude" / "projects" / encoded
|
|
204
|
+
project_dir.mkdir(parents=True)
|
|
205
|
+
(project_dir / "abc.jsonl").touch()
|
|
206
|
+
|
|
207
|
+
assert has_claude_session(task_path) is True
|
|
@@ -31,6 +31,16 @@ class TestConfig:
|
|
|
31
31
|
config = Config(claude_memory_dir="")
|
|
32
32
|
assert config.claude_memory_dir == ""
|
|
33
33
|
|
|
34
|
+
def test_claude_repo_memory_default(self):
|
|
35
|
+
"""Test that per-repo worktree memory is enabled by default."""
|
|
36
|
+
config = Config()
|
|
37
|
+
assert config.claude_repo_memory is True
|
|
38
|
+
|
|
39
|
+
def test_claude_repo_memory_custom(self):
|
|
40
|
+
"""Test disabling per-repo worktree memory."""
|
|
41
|
+
config = Config(claude_repo_memory=False)
|
|
42
|
+
assert config.claude_repo_memory is False
|
|
43
|
+
|
|
34
44
|
def test_load_from_environment(self, temp_dirs, monkeypatch):
|
|
35
45
|
"""Test loading config from environment variables."""
|
|
36
46
|
repos_dir, tasks_dir = temp_dirs
|
|
@@ -823,3 +823,52 @@ class TestEnsureClaudeMdFiles:
|
|
|
823
823
|
task_manager.ensure_claude_md_files(task)
|
|
824
824
|
|
|
825
825
|
assert worktree_md.read_text() == "# my notes\n"
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
class TestWorktreeClaudeSettings:
|
|
829
|
+
"""Tests for per-repo Claude memory settings in worktrees."""
|
|
830
|
+
|
|
831
|
+
def test_create_task_writes_worktree_settings(self, task_manager, sample_repo):
|
|
832
|
+
"""New worktrees get settings pointing memory at the repo's memory dir."""
|
|
833
|
+
import json
|
|
834
|
+
|
|
835
|
+
from tasktree_manager.services.claude_hooks import repo_memory_dir
|
|
836
|
+
|
|
837
|
+
repo_path, branch = sample_repo
|
|
838
|
+
task = task_manager.create_task("MEM-1", ["sample-repo"], branch)
|
|
839
|
+
|
|
840
|
+
settings_file = task.path / "sample-repo" / ".claude" / "settings.local.json"
|
|
841
|
+
assert settings_file.exists()
|
|
842
|
+
settings = json.loads(settings_file.read_text())
|
|
843
|
+
assert settings["autoMemoryDirectory"] == str(repo_memory_dir(repo_path))
|
|
844
|
+
assert str(task.path / ".claude_status") in json.dumps(settings["hooks"])
|
|
845
|
+
|
|
846
|
+
def test_worktree_stays_clean(self, task_manager, sample_repo):
|
|
847
|
+
"""The generated settings file must not show up as a dirty worktree."""
|
|
848
|
+
from tasktree_manager.services.git_ops import GitOps
|
|
849
|
+
|
|
850
|
+
_, branch = sample_repo
|
|
851
|
+
task = task_manager.create_task("MEM-2", ["sample-repo"], branch)
|
|
852
|
+
|
|
853
|
+
status = GitOps.get_status(task.worktrees[0])
|
|
854
|
+
assert status.is_dirty is False
|
|
855
|
+
|
|
856
|
+
def test_disabled_by_config(self, task_manager, sample_repo):
|
|
857
|
+
"""No settings file is written when claude_repo_memory is off."""
|
|
858
|
+
task_manager.config.claude_repo_memory = False
|
|
859
|
+
_, branch = sample_repo
|
|
860
|
+
task = task_manager.create_task("MEM-3", ["sample-repo"], branch)
|
|
861
|
+
|
|
862
|
+
assert not (task.path / "sample-repo" / ".claude").exists()
|
|
863
|
+
|
|
864
|
+
def test_ensure_worktree_settings_backfills(self, task_manager, sample_repo):
|
|
865
|
+
"""Existing worktrees without settings get them backfilled."""
|
|
866
|
+
task_manager.config.claude_repo_memory = False
|
|
867
|
+
_, branch = sample_repo
|
|
868
|
+
task = task_manager.create_task("MEM-4", ["sample-repo"], branch)
|
|
869
|
+
assert not (task.path / "sample-repo" / ".claude").exists()
|
|
870
|
+
|
|
871
|
+
task_manager.config.claude_repo_memory = True
|
|
872
|
+
task_manager.ensure_worktree_settings(task)
|
|
873
|
+
|
|
874
|
+
assert (task.path / "sample-repo" / ".claude" / "settings.local.json").exists()
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
"""Claude Code hook configuration for status monitoring."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def has_claude_session(folder: Path) -> bool:
|
|
8
|
-
"""Return True if Claude Code has a recorded session for the given folder.
|
|
9
|
-
|
|
10
|
-
Claude CLI stores transcripts at ~/.claude/projects/<encoded-path>/*.jsonl
|
|
11
|
-
where the encoding replaces "/" and "." with "-".
|
|
12
|
-
"""
|
|
13
|
-
encoded = str(folder).replace("/", "-").replace(".", "-")
|
|
14
|
-
project_dir = Path.home() / ".claude" / "projects" / encoded
|
|
15
|
-
if not project_dir.is_dir():
|
|
16
|
-
return False
|
|
17
|
-
return any(project_dir.glob("*.jsonl"))
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _make_hook(status: str, status_file: str) -> dict:
|
|
21
|
-
"""Create a single hook entry that writes status to the given file path."""
|
|
22
|
-
safe_path = status_file.replace("'", "'\\''")
|
|
23
|
-
return {
|
|
24
|
-
"type": "command",
|
|
25
|
-
"command": f'printf \'{{"status":"{status}","ts":%d}}\' $(date +%s) > \'{safe_path}\'',
|
|
26
|
-
"async": True,
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _build_hooks_config(task_path: Path) -> dict:
|
|
31
|
-
"""Build hooks config with absolute path to status file."""
|
|
32
|
-
status_file = str(task_path / ".claude_status")
|
|
33
|
-
return {
|
|
34
|
-
"SessionStart": [{"hooks": [_make_hook("running", status_file)]}],
|
|
35
|
-
"UserPromptSubmit": [{"hooks": [_make_hook("running", status_file)]}],
|
|
36
|
-
"Stop": [{"hooks": [_make_hook("waiting", status_file)]}],
|
|
37
|
-
"SessionEnd": [{"hooks": [_make_hook("ended", status_file)]}],
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def ensure_claude_hooks(task_path: Path, memory_dir: str = "") -> None:
|
|
42
|
-
"""Create .claude/settings.local.json with status-reporting hooks.
|
|
43
|
-
|
|
44
|
-
Merges with existing settings if the file already exists, preserving
|
|
45
|
-
user's own configuration while adding/updating the status hooks.
|
|
46
|
-
|
|
47
|
-
Task folders are not git repositories, so Claude Code keys its auto
|
|
48
|
-
memory to the task path — memory that becomes orphaned when the task
|
|
49
|
-
is deleted. When memory_dir is set, autoMemoryDirectory redirects all
|
|
50
|
-
task sessions to one shared pool that persists across tasks.
|
|
51
|
-
"""
|
|
52
|
-
claude_dir = task_path / ".claude"
|
|
53
|
-
claude_dir.mkdir(exist_ok=True)
|
|
54
|
-
|
|
55
|
-
settings_file = claude_dir / "settings.local.json"
|
|
56
|
-
|
|
57
|
-
if settings_file.exists():
|
|
58
|
-
try:
|
|
59
|
-
existing = json.loads(settings_file.read_text())
|
|
60
|
-
except (json.JSONDecodeError, OSError):
|
|
61
|
-
existing = {}
|
|
62
|
-
else:
|
|
63
|
-
existing = {}
|
|
64
|
-
|
|
65
|
-
# Merge hooks into existing settings
|
|
66
|
-
existing["hooks"] = _build_hooks_config(task_path)
|
|
67
|
-
|
|
68
|
-
if memory_dir:
|
|
69
|
-
existing["autoMemoryDirectory"] = str(Path(memory_dir).expanduser())
|
|
70
|
-
|
|
71
|
-
settings_file.write_text(json.dumps(existing, indent=2) + "\n")
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
"""Tests for Claude Code hook and settings configuration."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
from tasktree_manager.services.claude_hooks import ensure_claude_hooks, has_claude_session
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class TestEnsureClaudeHooks:
|
|
10
|
-
"""Tests for ensure_claude_hooks."""
|
|
11
|
-
|
|
12
|
-
def test_creates_settings_with_hooks(self, tmp_path):
|
|
13
|
-
"""Test that settings.local.json is created with status hooks."""
|
|
14
|
-
ensure_claude_hooks(tmp_path)
|
|
15
|
-
|
|
16
|
-
settings_file = tmp_path / ".claude" / "settings.local.json"
|
|
17
|
-
assert settings_file.exists()
|
|
18
|
-
|
|
19
|
-
settings = json.loads(settings_file.read_text())
|
|
20
|
-
for event in ("SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"):
|
|
21
|
-
assert event in settings["hooks"]
|
|
22
|
-
assert str(tmp_path / ".claude_status") in json.dumps(settings["hooks"])
|
|
23
|
-
|
|
24
|
-
def test_no_memory_dir_by_default(self, tmp_path):
|
|
25
|
-
"""Test that autoMemoryDirectory is not written when memory_dir is empty."""
|
|
26
|
-
ensure_claude_hooks(tmp_path)
|
|
27
|
-
|
|
28
|
-
settings_file = tmp_path / ".claude" / "settings.local.json"
|
|
29
|
-
settings = json.loads(settings_file.read_text())
|
|
30
|
-
assert "autoMemoryDirectory" not in settings
|
|
31
|
-
|
|
32
|
-
def test_memory_dir_written_expanded(self, tmp_path):
|
|
33
|
-
"""Test that memory_dir is written as an expanded absolute path."""
|
|
34
|
-
ensure_claude_hooks(tmp_path, "~/.claude/tasktree-memory")
|
|
35
|
-
|
|
36
|
-
settings_file = tmp_path / ".claude" / "settings.local.json"
|
|
37
|
-
settings = json.loads(settings_file.read_text())
|
|
38
|
-
assert settings["autoMemoryDirectory"] == str(Path.home() / ".claude" / "tasktree-memory")
|
|
39
|
-
|
|
40
|
-
def test_preserves_existing_settings(self, tmp_path):
|
|
41
|
-
"""Test that unrelated existing settings keys survive a rewrite."""
|
|
42
|
-
claude_dir = tmp_path / ".claude"
|
|
43
|
-
claude_dir.mkdir()
|
|
44
|
-
settings_file = claude_dir / "settings.local.json"
|
|
45
|
-
settings_file.write_text(json.dumps({"permissions": {"allow": ["Bash(ls *)"]}}))
|
|
46
|
-
|
|
47
|
-
ensure_claude_hooks(tmp_path, "~/.claude/tasktree-memory")
|
|
48
|
-
|
|
49
|
-
settings = json.loads(settings_file.read_text())
|
|
50
|
-
assert settings["permissions"] == {"allow": ["Bash(ls *)"]}
|
|
51
|
-
assert "hooks" in settings
|
|
52
|
-
assert "autoMemoryDirectory" in settings
|
|
53
|
-
|
|
54
|
-
def test_empty_memory_dir_leaves_existing_value(self, tmp_path):
|
|
55
|
-
"""Test that an existing autoMemoryDirectory is kept when memory_dir is empty."""
|
|
56
|
-
claude_dir = tmp_path / ".claude"
|
|
57
|
-
claude_dir.mkdir()
|
|
58
|
-
settings_file = claude_dir / "settings.local.json"
|
|
59
|
-
settings_file.write_text(json.dumps({"autoMemoryDirectory": "/custom/memory"}))
|
|
60
|
-
|
|
61
|
-
ensure_claude_hooks(tmp_path, "")
|
|
62
|
-
|
|
63
|
-
settings = json.loads(settings_file.read_text())
|
|
64
|
-
assert settings["autoMemoryDirectory"] == "/custom/memory"
|
|
65
|
-
|
|
66
|
-
def test_memory_dir_overrides_existing_value(self, tmp_path):
|
|
67
|
-
"""Test that a configured memory_dir replaces a previously written one."""
|
|
68
|
-
ensure_claude_hooks(tmp_path, "/old/memory")
|
|
69
|
-
ensure_claude_hooks(tmp_path, "/new/memory")
|
|
70
|
-
|
|
71
|
-
settings_file = tmp_path / ".claude" / "settings.local.json"
|
|
72
|
-
settings = json.loads(settings_file.read_text())
|
|
73
|
-
assert settings["autoMemoryDirectory"] == "/new/memory"
|
|
74
|
-
|
|
75
|
-
def test_recovers_from_corrupt_settings(self, tmp_path):
|
|
76
|
-
"""Test that invalid JSON in existing settings is replaced, not fatal."""
|
|
77
|
-
claude_dir = tmp_path / ".claude"
|
|
78
|
-
claude_dir.mkdir()
|
|
79
|
-
settings_file = claude_dir / "settings.local.json"
|
|
80
|
-
settings_file.write_text("{not json")
|
|
81
|
-
|
|
82
|
-
ensure_claude_hooks(tmp_path, "~/.claude/tasktree-memory")
|
|
83
|
-
|
|
84
|
-
settings = json.loads(settings_file.read_text())
|
|
85
|
-
assert "hooks" in settings
|
|
86
|
-
assert "autoMemoryDirectory" in settings
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
class TestHasClaudeSession:
|
|
90
|
-
"""Tests for has_claude_session."""
|
|
91
|
-
|
|
92
|
-
def test_no_project_dir(self, tmp_path, monkeypatch):
|
|
93
|
-
"""Test False when no project directory exists."""
|
|
94
|
-
monkeypatch.setenv("HOME", str(tmp_path))
|
|
95
|
-
assert has_claude_session(Path("/some/task")) is False
|
|
96
|
-
|
|
97
|
-
def test_project_dir_with_transcript(self, tmp_path, monkeypatch):
|
|
98
|
-
"""Test True when the encoded project directory holds a transcript."""
|
|
99
|
-
monkeypatch.setenv("HOME", str(tmp_path))
|
|
100
|
-
task_path = Path("/some/task.dir")
|
|
101
|
-
encoded = "-some-task-dir"
|
|
102
|
-
project_dir = tmp_path / ".claude" / "projects" / encoded
|
|
103
|
-
project_dir.mkdir(parents=True)
|
|
104
|
-
(project_dir / "abc.jsonl").touch()
|
|
105
|
-
|
|
106
|
-
assert has_claude_session(task_path) is True
|
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/README.md
RENAMED
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/dashboard_app.py
RENAMED
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/data_viewer.py
RENAMED
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/todo_app.py
RENAMED
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/worker_demo.py
RENAMED
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/layouts.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/styling.md
RENAMED
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/widgets.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.7.1 → tasktree_manager-2.8.0}/tasktree_manager/widgets/messages_panel.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|