tasktree-manager 2.7.2__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.
Files changed (64) hide show
  1. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.github/workflows/release.yml +4 -2
  2. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/CHANGELOG.md +9 -0
  3. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/PKG-INFO +1 -1
  4. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/docs/configuration.md +12 -0
  5. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/docs/user-guide.md +1 -0
  6. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/app.py +5 -0
  9. tasktree_manager-2.8.0/tasktree_manager/services/claude_hooks.py +139 -0
  10. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/services/config.py +10 -0
  11. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/services/task_manager.py +24 -0
  12. tasktree_manager-2.8.0/tests/test_claude_hooks.py +207 -0
  13. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_config.py +10 -0
  14. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_task_manager.py +49 -0
  15. tasktree_manager-2.7.2/tasktree_manager/services/claude_hooks.py +0 -71
  16. tasktree_manager-2.7.2/tests/test_claude_hooks.py +0 -106
  17. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  18. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  19. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  20. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  21. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  22. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  23. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  24. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  25. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  26. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  27. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  28. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.github/workflows/ci.yml +0 -0
  29. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/.gitignore +0 -0
  30. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/CONTRIBUTING.md +0 -0
  31. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/LICENSE +0 -0
  32. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/OPENSPEC.md +0 -0
  33. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/README.md +0 -0
  34. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/ROADMAP.md +0 -0
  35. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/docs/claude-code-integration-spec.md +0 -0
  36. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/docs/installation.md +0 -0
  37. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/docs/troubleshooting.md +0 -0
  38. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/mise.toml +0 -0
  39. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/scripts/bump_version.py +0 -0
  40. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/__init__.py +0 -0
  41. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/commands.py +0 -0
  42. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/services/__init__.py +0 -0
  43. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/services/git_ops.py +0 -0
  44. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/services/models.py +0 -0
  45. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/themes.py +0 -0
  46. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/widgets/__init__.py +0 -0
  47. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/widgets/app_header.py +0 -0
  48. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/widgets/create_modal.py +0 -0
  49. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  50. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  51. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/widgets/status_panel.py +0 -0
  52. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/widgets/task_list.py +0 -0
  53. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  54. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/__init__.py +0 -0
  55. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/conftest.py +0 -0
  56. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_app.py +0 -0
  57. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_commands.py +0 -0
  58. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_git_ops.py +0 -0
  59. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_integration_git.py +0 -0
  60. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_memory.py +0 -0
  61. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_messages_panel.py +0 -0
  62. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_modals.py +0 -0
  63. {tasktree_manager-2.7.2 → tasktree_manager-2.8.0}/tests/test_setup_modal.py +0 -0
  64. {tasktree_manager-2.7.2 → 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,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.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
+
18
27
  ## [2.7.2] - 2026-07-13
19
28
 
20
29
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree-manager
3
- Version: 2.7.2
3
+ Version: 2.8.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -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.7.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.7.2'
22
- __version_tuple__ = version_tuple = (2, 7, 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