tasktree-manager 2.16.0__tar.gz → 2.17.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 (81) hide show
  1. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.github/workflows/ci.yml +8 -2
  2. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/CHANGELOG.md +12 -0
  3. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/PKG-INFO +1 -1
  4. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/README.md +12 -2
  5. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/docs/configuration.md +3 -1
  6. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/docs/user-guide.md +16 -1
  7. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/pyproject.toml +1 -1
  8. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/_version.py +2 -2
  9. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/app.py +13 -6
  10. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/cli.py +188 -4
  11. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/claude_sessions.py +82 -9
  12. tasktree_manager-2.17.0/tasktree_manager/services/epic_log.py +105 -0
  13. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/models.py +64 -17
  14. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/task_manager.py +244 -49
  15. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/widgets/task_list.py +5 -2
  16. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/conftest.py +7 -1
  17. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_epic_claude_md.py +2 -2
  18. tasktree_manager-2.17.0/tests/test_epic_log.py +438 -0
  19. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_epic_tui.py +25 -0
  20. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_epics.py +78 -1
  21. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_safety_fixes.py +4 -1
  22. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  23. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  24. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  25. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  26. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  27. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  28. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  29. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  30. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  31. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  32. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  33. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.github/workflows/release.yml +0 -0
  34. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/.gitignore +0 -0
  35. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/CONTRIBUTING.md +0 -0
  36. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/LICENSE +0 -0
  37. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/ROADMAP.md +0 -0
  38. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/docs/claude-code-integration-spec.md +0 -0
  39. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/docs/installation.md +0 -0
  40. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/docs/troubleshooting.md +0 -0
  41. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/mise.toml +0 -0
  42. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/scripts/bump_version.py +0 -0
  43. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/__init__.py +0 -0
  44. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/commands.py +0 -0
  45. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/__init__.py +0 -0
  46. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/agent_sessions.py +0 -0
  47. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/claude_hooks.py +0 -0
  48. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/config.py +0 -0
  49. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/forge.py +0 -0
  50. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/services/git_ops.py +0 -0
  51. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/themes.py +0 -0
  52. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/widgets/__init__.py +0 -0
  53. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/widgets/app_header.py +0 -0
  54. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/widgets/create_modal.py +0 -0
  55. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  56. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  57. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/widgets/status_panel.py +0 -0
  58. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  59. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/__init__.py +0 -0
  60. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_agent_sessions.py +0 -0
  61. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_agent_sessions_decode.py +0 -0
  62. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_app.py +0 -0
  63. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_app_review_fixes.py +0 -0
  64. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_bump_version.py +0 -0
  65. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_claude_hooks.py +0 -0
  66. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_claude_sessions.py +0 -0
  67. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_cli.py +0 -0
  68. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_commands.py +0 -0
  69. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_config.py +0 -0
  70. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_forge.py +0 -0
  71. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_git_ops.py +0 -0
  72. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_integration_git.py +0 -0
  73. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_list_selection.py +0 -0
  74. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_medium_fixes.py +0 -0
  75. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_memory.py +0 -0
  76. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_messages_panel.py +0 -0
  77. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_modals.py +0 -0
  78. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_setup_modal.py +0 -0
  79. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_status_panel.py +0 -0
  80. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_task_manager.py +0 -0
  81. {tasktree_manager-2.16.0 → tasktree_manager-2.17.0}/tests/test_worktree_badges.py +0 -0
@@ -31,12 +31,18 @@ jobs:
31
31
  run: ruff format --check tasktree_manager tests
32
32
 
33
33
  test:
34
- name: Test (Python ${{ matrix.python-version }})
35
- runs-on: ubuntu-latest
34
+ # Linux jobs keep the names branch protection requires
35
+ name: ${{ matrix.os == 'macos-latest' && format('Test (macOS, Python {0})', matrix.python-version) || format('Test (Python {0})', matrix.python-version) }}
36
+ runs-on: ${{ matrix.os }}
36
37
  strategy:
37
38
  fail-fast: false
38
39
  matrix:
40
+ os: [ubuntu-latest]
39
41
  python-version: ["3.10", "3.11", "3.12", "3.13"]
42
+ include:
43
+ # Case-insensitive APFS: runs the case-variant epic tests Linux skips
44
+ - os: macos-latest
45
+ python-version: "3.13"
40
46
 
41
47
  steps:
42
48
  - name: Checkout code
@@ -15,6 +15,18 @@ 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.17.0] - 2026-10-20
19
+
20
+ ### Added
21
+ - **epic**: Add an epic log for notes shared across task sessions (#13)
22
+
23
+
24
+ ## [2.16.1] - 2026-10-19
25
+
26
+ ### Fixed
27
+ - **epic**: One membership rule and safer marker writes (#12)
28
+
29
+
18
30
  ## [2.16.0] - 2026-10-19
19
31
 
20
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.16.0
3
+ Version: 2.17.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -22,7 +22,7 @@
22
22
  - 🛰️ **Agent Dashboard** - Live Claude Code session state per worktree (from `claude agents`), plus one-key background-agent dispatch — the multi-repo agent view Claude Code lacks natively
23
23
  - ✻ **Session Recap** - The Info panel shows each task's latest Claude Code session: title, last turn duration, and Claude's own idle recap of what was done and what comes next — read straight from the transcript, no CLI call
24
24
  - 🔀 **Forge-Aware Safety** - MR/PR and CI state via `glab`/`gh`; squash- and rebase-merged branches count as merged instead of blocking deletion forever
25
- - 🗂️ **Epics** - Group related tasks under an epic: a foldable row with rolled-up dirty and Claude state, `E` to attach tasks
25
+ - 🗂️ **Epics** - Group related tasks under an epic: a foldable row with rolled-up dirty and Claude state, `E` to attach tasks, and an epic log where the Claude sessions of one epic share notes
26
26
  - 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) and the task-folder files outside the worktrees (plans, ADRs, notes) to an archive before deleting
27
27
 
28
28
  ## 📊 Status Indicators
@@ -100,10 +100,20 @@ tasktree-manager epic add DIC-2466-pcaps-epic DIC-2862-pcaps-s3 # attach or mov
100
100
  tasktree-manager epic remove DIC-2862-pcaps-s3 # detach
101
101
  tasktree-manager epic mark DIC-2466-pcaps-epic # turn an existing task into an epic; unmark reverts
102
102
  tasktree-manager epic list # epics and their tasks; also --json
103
+ tasktree-manager epic note "chose layout A for the S3 keys" # add a note to this task's file in the epic log
104
+ tasktree-manager epic log # notes and latest session recap of every task in the epic
105
+ tasktree-manager epic log DIC-2862-pcaps-s3 --json # one task (or a whole epic by its name), as JSON
106
+ tasktree-manager epic sync # refresh the CLAUDE.md epic block after an upgrade
103
107
  ```
104
108
 
105
109
  Each task's `CLAUDE.md` gets a managed block naming its epic and how to find sibling
106
- tasks; it follows every epic change and leaves the rest of the file alone. Epics don't
110
+ tasks; it follows every epic change and leaves the rest of the file alone. The block
111
+ also tells the session about the epic log: one notes file per task in `epic-log/` in the
112
+ epic folder, written with `epic note` and read with `epic log`, which adds the recap of each
113
+ task's latest Claude session (in the task folder or one of its worktrees). Inside a task
114
+ folder, `epic note` writes only that task's notes and `epic log` reads only its epic;
115
+ `epic note -` reads the text from stdin. Notes of a task that left the epic stay readable.
116
+ Epics don't
107
117
  nest. `list --json` and `status --json` carry `kind` (`task` or `epic`) and
108
118
  `epic` (the parent epic or `null`). `delete` and `finish` refuse an epic that still has
109
119
  tasks, even with `--force`.
@@ -611,7 +611,8 @@ All 27 customizable keybindings with descriptions:
611
611
  | `add_repo` | `a` | Add repository to current task | `add_repo = "ctrl+a"` |
612
612
  | `delete_task` | `d` | Delete/finish current task | `delete_task = "delete"` |
613
613
  | `rename_task` | `R` | Rename task display label (alias only) | `rename_task = "ctrl+r"` |
614
- | `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+e"` |
614
+ | `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+shift+e"` |
615
+ | `delete_worktree` | `D` | Remove the selected worktree (the task stays) | `delete_worktree = "ctrl+d"` |
615
616
 
616
617
  ### Git Operations
617
618
 
@@ -632,6 +633,7 @@ All 27 customizable keybindings with descriptions:
632
633
  | `open_claude_resume` | `c` | Open Claude CLI (resume if session exists, else new) | `open_claude_resume = "ctrl+c"` |
633
634
  | `open_claude_gui_code`| `C` | Open Claude desktop on Code page in task folder | `open_claude_gui_code = "ctrl+shift+c"` |
634
635
  | `toggle_messages` | `m` | Show/hide activity messages panel | `toggle_messages = "ctrl+m"` |
636
+ | `dispatch_agent` | `b` | Dispatch a background Claude agent into the worktree | `dispatch_agent = "ctrl+b"` |
635
637
 
636
638
  ### Navigation
637
639
 
@@ -89,7 +89,22 @@ names their epic, so nothing moves on disk.
89
89
  that names the epic folder and how to find sibling tasks, so a Claude session knows
90
90
  the bigger picture. It updates on every epic change; text outside the markers is
91
91
  never touched.
92
- - From scripts: `tasktree-manager epic create/add/remove/mark/unmark/list`.
92
+ - The epic log lets the sessions of one epic share what they learned. A session
93
+ records decisions, interfaces and open questions with
94
+ `tasktree-manager epic note "..."`; they go to `epic-log/<task>.md` in the epic
95
+ folder. `tasktree-manager epic log` shows every task's notes and the recap of its
96
+ latest Claude session, so a session can check what its siblings did before asking.
97
+ The `CLAUDE.md` block tells each session about both commands, and that notes are
98
+ information from other sessions, not instructions.
99
+ - A session writes only its own task's notes. From a terminal outside any task,
100
+ `epic note --task NAME` writes for a task and marks the entry as written outside it.
101
+ - Notes stay in the epic after their task is finished, moved or deleted; `epic log`
102
+ lists them as "no longer in the epic". A note is at most 16 KB, and `epic log` shows
103
+ the newest 64 KB of each task's notes.
104
+ - After upgrading tasktree-manager, run `tasktree-manager epic sync` to refresh the
105
+ block in existing tasks (the TUI also refreshes it when it starts a Claude session).
106
+ Folders without a `CLAUDE.md` yet are counted and skipped.
107
+ - From scripts: `tasktree-manager epic create/add/remove/mark/unmark/list/note/log/sync`.
93
108
 
94
109
  ### Worktrees
95
110
 
@@ -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.16.0"
74
+ version = "2.17.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.16.0'
22
- __version_tuple__ = version_tuple = (2, 16, 0)
21
+ __version__ = version = '2.17.0'
22
+ __version_tuple__ = version_tuple = (2, 17, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -366,6 +366,9 @@ class TaskTreeApp(App):
366
366
  (kb.get("delete_worktree", "D"), "delete_worktree", "Del WT"),
367
367
  ]
368
368
 
369
+ # Every key a configured action uses ("s,space" binds two keys)
370
+ claimed_keys = {key.strip() for keys in kb.values() for key in keys.split(",")}
371
+
369
372
  yield AppHeader()
370
373
  with Horizontal(id="main-container"):
371
374
  # Left column: Tasks (top) and Worktrees (bottom)
@@ -382,7 +385,7 @@ class TaskTreeApp(App):
382
385
  id="task-list",
383
386
  context_bindings=task_panel_bindings,
384
387
  # A fold key bound to any action in config stays that action
385
- fold_keys=tuple(k for k in ("enter", "space") if k not in kb.values()),
388
+ fold_keys=tuple(k for k in ("enter", "space") if k not in claimed_keys),
386
389
  )
387
390
  with Vertical(id="worktree-panel"):
388
391
  with Horizontal(classes="panel-title-bar"):
@@ -1302,7 +1305,8 @@ class TaskTreeApp(App):
1302
1305
  initial_base_branch=self.config.default_base_branch,
1303
1306
  title=f"Clone Task: {self.current_task.name}",
1304
1307
  epics=self._epic_choices(),
1305
- initial_epic=self._epic_of(self.current_task),
1308
+ # A clone joins the source task's epic; a clone of an epic joins none
1309
+ initial_epic=self.query_one("#task-list", TaskList).epic_of(self.current_task.name),
1306
1310
  ),
1307
1311
  handle_result,
1308
1312
  )
@@ -1432,7 +1436,7 @@ class TaskTreeApp(App):
1432
1436
  return
1433
1437
  try:
1434
1438
  self.task_manager.set_task_display_name(task, new_name)
1435
- except OSError as e:
1439
+ except (OSError, ValueError) as e:
1436
1440
  self.notify(escape(f"Rename failed: {e}"), severity="error")
1437
1441
  return
1438
1442
  label = task.display_label
@@ -1452,6 +1456,7 @@ class TaskTreeApp(App):
1452
1456
  self.notify("Epics don't nest: an epic can't join another epic", severity="warning")
1453
1457
  return
1454
1458
  epics = [(label, name) for label, name in self._epic_choices() if name != task.name]
1459
+ parent = self.query_one("#task-list", TaskList).epic_of(task.name)
1455
1460
 
1456
1461
  def handle_choice(choice: tuple[str, str | None] | None) -> None:
1457
1462
  if choice is None:
@@ -1469,8 +1474,8 @@ class TaskTreeApp(App):
1469
1474
  EpicPickerModal(
1470
1475
  task.display_label,
1471
1476
  epics,
1472
- self.query_one("#task-list", TaskList).epic_of(task.name),
1473
- task.epic_name is None,
1477
+ parent,
1478
+ parent is None,
1474
1479
  ),
1475
1480
  handle_choice,
1476
1481
  )
@@ -2084,7 +2089,9 @@ class TaskTreeApp(App):
2084
2089
  """
2085
2090
  fresh_task = self.task_manager.get_task(task.name)
2086
2091
  if fresh_task:
2087
- self.task_manager.ensure_claude_md_files(fresh_task)
2092
+ problem = self.task_manager.ensure_claude_md_files(fresh_task)
2093
+ if problem:
2094
+ self.call_from_thread(self.notify, escape(problem), severity="warning")
2088
2095
  self.task_manager.ensure_worktree_settings(fresh_task)
2089
2096
  ensure_claude_hooks(task.path, self.config.claude_memory_dir)
2090
2097
 
@@ -8,17 +8,37 @@ errors on stderr, exit code 0 on success and 1 on failure.
8
8
 
9
9
  import argparse
10
10
  import json
11
+ import os
12
+ import re
11
13
  import shutil
12
14
  import sys
15
+ from datetime import timezone
13
16
  from pathlib import Path
14
17
 
15
18
  from . import __version__
16
19
  from .services import forge
17
20
  from .services.config import Config, ConfigError
21
+ from .services.epic_log import (
22
+ EPIC_ROLE,
23
+ FORMER_ROLE,
24
+ EpicLogEntry,
25
+ read_epic_log,
26
+ resolve_log_target,
27
+ )
18
28
  from .services.git_ops import GitOps
19
- from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task
29
+ from .services.models import (
30
+ EPIC_KIND,
31
+ TASK_KIND,
32
+ ArchiveResult,
33
+ Task,
34
+ parent_epic_name,
35
+ same_folder,
36
+ )
20
37
  from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
21
38
 
39
+ # C0/C1 controls except tab; notes are split on newlines before this runs
40
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f]")
41
+
22
42
 
23
43
  def build_parser() -> argparse.ArgumentParser:
24
44
  """Build the argument parser for the headless CLI."""
@@ -78,6 +98,31 @@ def build_parser() -> argparse.ArgumentParser:
78
98
  epic_list.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
79
99
  epic_list.set_defaults(func=cmd_epic_list)
80
100
 
101
+ epic_note = epic_sub.add_parser("note", help="Add a note to this task's file in the epic log")
102
+ epic_note.add_argument("text", help='Note text ("-" reads it from stdin)')
103
+ epic_note.add_argument(
104
+ "--task", default=None, help="Task that writes the note (default: inferred from $PWD)"
105
+ )
106
+ epic_note.set_defaults(func=cmd_epic_note)
107
+
108
+ epic_log = epic_sub.add_parser(
109
+ "log", help="Show the notes and latest session recap of tasks in an epic"
110
+ )
111
+ epic_log.add_argument(
112
+ "task",
113
+ nargs="?",
114
+ default=None,
115
+ help="One task of the epic (default: the epic of $PWD and all its tasks)",
116
+ )
117
+ epic_log.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
118
+ epic_log.set_defaults(func=cmd_epic_log)
119
+
120
+ epic_sync = epic_sub.add_parser(
121
+ "sync", help="Refresh the epic block in the CLAUDE.md of epics and their tasks"
122
+ )
123
+ epic_sync.add_argument("name", nargs="?", default=None, help="Epic name (default: all)")
124
+ epic_sync.set_defaults(func=cmd_epic_sync)
125
+
81
126
  list_parser = sub.add_parser("list", help="List tasks with their repos and dirty state")
82
127
  list_parser.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
83
128
  list_parser.set_defaults(func=cmd_list)
@@ -334,6 +379,142 @@ def cmd_epic_list(manager: TaskManager, config: Config, args: argparse.Namespace
334
379
  return 0
335
380
 
336
381
 
382
+ def cmd_epic_note(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
383
+ """Append a note to the task's file in its epic log.
384
+
385
+ Inside a task folder a session writes only its own notes; from outside
386
+ any task, `--task` writes and the heading records that.
387
+ """
388
+ caller = _find_task_by_cwd(manager)
389
+ source = None
390
+ if args.task:
391
+ task = manager.get_task(args.task)
392
+ if task is None:
393
+ return _error(f"no such task: {args.task}")
394
+ if caller is None:
395
+ source = "written outside the task folder"
396
+ elif not same_folder(caller.path, task.path):
397
+ return _error(f"inside task {caller.name}: notes go to its own file, not {task.name}")
398
+ elif caller is None:
399
+ return _error("not inside a task directory; pass --task")
400
+ else:
401
+ task = caller
402
+ text = sys.stdin.read() if args.text == "-" else args.text
403
+ path = manager.append_epic_note(task, text, source=source)
404
+ print(f"Noted in {path}")
405
+ return 0
406
+
407
+
408
+ def cmd_epic_log(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
409
+ """Show notes and the latest session recap of one task or a whole epic."""
410
+ epic, only = resolve_log_target(manager, _find_task_by_cwd(manager), args.task)
411
+ entries = read_epic_log(manager, epic, only)
412
+ for entry in entries:
413
+ if entry.problem:
414
+ print(f"warning: {entry.task}: {entry.problem}", file=sys.stderr)
415
+ if args.as_json:
416
+ print(json.dumps([_epic_log_json(entry) for entry in entries], indent=2))
417
+ return 0
418
+ for index, entry in enumerate(entries):
419
+ if index:
420
+ print()
421
+ _print_epic_log_entry(entry)
422
+ return 0
423
+
424
+
425
+ def _epic_log_json(entry: EpicLogEntry) -> dict:
426
+ session = None
427
+ if entry.session is not None:
428
+ recap = entry.recap
429
+ session = {
430
+ "id": entry.session.session_id,
431
+ "cwd": str(entry.session.cwd),
432
+ "title": recap.title if recap else None,
433
+ "finished_at": recap.finished_at.isoformat() if recap and recap.finished_at else None,
434
+ "summary": recap.summary if recap else None,
435
+ # The typed prompt only stands in for a missing recap, as in the TUI
436
+ "last_prompt": recap.last_prompt if recap and not recap.summary else None,
437
+ }
438
+ return {
439
+ "task": entry.task,
440
+ "role": entry.role,
441
+ "notes_path": str(entry.notes_path),
442
+ "notes": entry.notes,
443
+ "notes_truncated": entry.notes_truncated,
444
+ "session": session,
445
+ "problem": entry.problem,
446
+ }
447
+
448
+
449
+ def _print_epic_log_entry(entry: EpicLogEntry) -> None:
450
+ role = {EPIC_ROLE: " (epic)", FORMER_ROLE: " (no longer in the epic)"}.get(entry.role, "")
451
+ print(f"== {entry.task}{role} ==")
452
+ recap = entry.recap
453
+ if entry.role == FORMER_ROLE:
454
+ pass
455
+ elif recap is None:
456
+ print("Last session: none recorded")
457
+ else:
458
+ when = "time unknown"
459
+ if recap.finished_at:
460
+ when = recap.finished_at.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
461
+ print(f"Last session: {recap.title} ({when})")
462
+ if recap.summary:
463
+ print(f" Recap: {recap.summary}")
464
+ elif recap.last_prompt:
465
+ print(f" Last prompt: {recap.last_prompt}")
466
+ if not entry.notes:
467
+ print("Notes: none yet")
468
+ return
469
+ cut = ", older notes cut" if entry.notes_truncated else ""
470
+ print(f"Notes ({entry.notes_path}{cut}):")
471
+ # Indented, so a note cannot forge a "== task ==" line; controls dropped
472
+ for line in _safe_lines(entry.notes.rstrip()):
473
+ print(f" {line}" if line else "")
474
+
475
+
476
+ def _safe_lines(text: str) -> list[str]:
477
+ return [_CONTROL_CHARS.sub("", line) for line in text.split("\n")]
478
+
479
+
480
+ def cmd_epic_sync(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
481
+ """Refresh the CLAUDE.md epic block of epics and their tasks."""
482
+ if args.name:
483
+ epic = manager.get_task(args.name)
484
+ if epic is None:
485
+ return _error(f"no such epic: {args.name}")
486
+ if manager.epic_of(epic) != epic.name:
487
+ return _error(f"{epic.name} is not an epic")
488
+ epics = [epic.name]
489
+ else:
490
+ epics = [task.name for task in manager.list_tasks() if manager.epic_of(task) == task.name]
491
+ synced = missing = failed = 0
492
+ for name in epics:
493
+ for member in [name, *manager.epic_child_names(name)]:
494
+ try:
495
+ task = manager.get_task(member)
496
+ except (OSError, ValueError) as e:
497
+ print(f"warning: {member}: {e}", file=sys.stderr)
498
+ failed += 1
499
+ continue
500
+ if task is None:
501
+ continue
502
+ if not (task.path / "CLAUDE.md").is_file():
503
+ missing += 1
504
+ continue
505
+ problem = manager.sync_epic_block(task)
506
+ if problem:
507
+ print(f"warning: {problem}", file=sys.stderr)
508
+ failed += 1
509
+ else:
510
+ synced += 1
511
+ print(
512
+ f"Synced {synced} folder(s); {missing} without CLAUDE.md "
513
+ f"(written when a Claude session starts there from the TUI); {failed} failed"
514
+ )
515
+ return 1 if failed else 0
516
+
517
+
337
518
  def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> bool:
338
519
  """Print an error and return True if `task` is an epic that still has tasks.
339
520
 
@@ -354,6 +535,8 @@ def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> b
354
535
  def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
355
536
  """List tasks with their repos and dirty state."""
356
537
  tasks = manager.list_tasks()
538
+ # One TASKS_DIR listing for every parent_epic_name lookup below
539
+ listing = set(os.listdir(config.tasks_dir)) if config.tasks_dir.is_dir() else set()
357
540
  dirty: dict[str, bool] = {}
358
541
  errored: dict[str, bool] = {}
359
542
  for task in tasks:
@@ -369,7 +552,7 @@ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) ->
369
552
  "name": task.name,
370
553
  "display_name": task.display_name,
371
554
  "kind": task.kind,
372
- "epic": task.epic_name,
555
+ "epic": parent_epic_name(task, listing),
373
556
  "path": str(task.path),
374
557
  "repos": [wt.name for wt in task.worktrees],
375
558
  "dirty": dirty[task.name],
@@ -542,7 +725,8 @@ def cmd_add_repo(manager: TaskManager, config: Config, args: argparse.Namespace)
542
725
  def _find_task_by_cwd(manager: TaskManager) -> Task | None:
543
726
  """Find the task whose directory contains the current working directory."""
544
727
  cwd = Path.cwd().resolve()
545
- for task in manager.list_tasks():
728
+ # A symlink alias resolves to the same folder: the real folder wins
729
+ for task in sorted(manager.list_tasks(), key=lambda task: task.path.is_symlink()):
546
730
  task_path = task.path.resolve()
547
731
  if cwd == task_path or cwd.is_relative_to(task_path):
548
732
  return task
@@ -635,7 +819,7 @@ def cmd_status(manager: TaskManager, config: Config, args: argparse.Namespace) -
635
819
  payload = {
636
820
  "task": task.name,
637
821
  "kind": task.kind,
638
- "epic": task.epic_name,
822
+ "epic": parent_epic_name(task),
639
823
  "path": str(task.path),
640
824
  "claude": claude,
641
825
  "worktrees": [
@@ -14,6 +14,7 @@ costs one bounded read regardless of its length.
14
14
  import json
15
15
  import os
16
16
  import re
17
+ from collections.abc import Iterable
17
18
  from dataclasses import dataclass
18
19
  from datetime import datetime, tzinfo
19
20
  from pathlib import Path
@@ -23,6 +24,8 @@ from .claude_hooks import project_dir_candidates
23
24
  RECAP_SUFFIX = "(disable recaps in /config)"
24
25
  TAIL_WINDOW = 1 << 20
25
26
  TAIL_WINDOW_RETRY = 4 << 20
27
+ # Most a session-folder lookup reads from the start of a transcript
28
+ HEAD_BUDGET = 16 << 20
26
29
  # Turns that span a laptop sleep report days of "duration"; anything above
27
30
  # an hour is noise rather than work time
28
31
  DURATION_CAP_MS = 3_600_000
@@ -56,13 +59,22 @@ class ClaudeRecap:
56
59
  last_prompt: str | None
57
60
 
58
61
 
59
- def transcript_key(folder: Path) -> tuple[Path, int, int] | None:
60
- """Return ``(path, mtime_ns, size)`` of the newest transcript for a folder.
62
+ @dataclass(frozen=True)
63
+ class SessionRef:
64
+ """A transcript and the folder its session was started in."""
61
65
 
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
+ path: Path
67
+ cwd: Path
68
+ mtime_ns: int
69
+
70
+ @property
71
+ def session_id(self) -> str:
72
+ return self.path.stem
73
+
74
+
75
+ def _transcripts(folder: Path) -> list[tuple[int, int, Path]]:
76
+ """``(mtime_ns, size, path)`` of every transcript in a folder's project dirs."""
77
+ found = []
66
78
  for project_dir in project_dir_candidates(folder):
67
79
  try:
68
80
  entries = list(os.scandir(project_dir))
@@ -77,15 +89,76 @@ def transcript_key(folder: Path) -> tuple[Path, int, int] | None:
77
89
  # Claude prunes old transcripts at startup; one can vanish
78
90
  # between the scandir and the stat
79
91
  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
92
+ found.append((stat.st_mtime_ns, stat.st_size, Path(entry.path)))
93
+ return found
94
+
95
+
96
+ def transcript_key(folder: Path) -> tuple[Path, int, int] | None:
97
+ """Return ``(path, mtime_ns, size)`` of the newest transcript for a folder.
98
+
99
+ One ``scandir`` per candidate project dir and no file reads, so callers
100
+ can poll it to detect changes cheaply.
101
+ """
102
+ newest = max(_transcripts(folder), key=lambda found: found[0], default=None)
83
103
  if newest is None:
84
104
  return None
85
105
  mtime_ns, size, path = newest
86
106
  return path, mtime_ns, size
87
107
 
88
108
 
109
+ def transcript_cwd(path: Path) -> Path | None:
110
+ """The folder a session started in: the first ``cwd`` in its transcript.
111
+
112
+ Whole records are read, since ``cwd`` follows the message body; a first
113
+ prompt bigger than HEAD_BUDGET leaves the cwd unknown.
114
+ """
115
+ try:
116
+ with open(path, "rb") as f:
117
+ budget = HEAD_BUDGET
118
+ while budget > 0:
119
+ line = f.readline(budget)
120
+ if not line:
121
+ return None
122
+ budget -= len(line)
123
+ if b'"cwd"' not in line:
124
+ continue
125
+ try:
126
+ record = json.loads(line)
127
+ except ValueError:
128
+ continue
129
+ cwd = record.get("cwd") if isinstance(record, dict) else None
130
+ if isinstance(cwd, str) and cwd:
131
+ return Path(cwd)
132
+ except OSError:
133
+ return None
134
+ return None
135
+
136
+
137
+ def latest_session(folders: Iterable[Path]) -> SessionRef | None:
138
+ """The newest session started in one of `folders` (a task and its worktrees).
139
+
140
+ Project dir names fold every non-alphanumeric character, so `X/1` and `X-1`
141
+ share one dir; a transcript counts only when its recorded cwd is the folder.
142
+ """
143
+ candidates = [
144
+ (mtime_ns, str(path), path, folder)
145
+ for folder in folders
146
+ for mtime_ns, _size, path in _transcripts(folder)
147
+ ]
148
+ for mtime_ns, _key, path, folder in sorted(candidates, reverse=True):
149
+ cwd = transcript_cwd(path)
150
+ if cwd is not None and _same_dir(cwd, folder):
151
+ return SessionRef(path=path, cwd=folder, mtime_ns=mtime_ns)
152
+ return None
153
+
154
+
155
+ def _same_dir(a: Path, b: Path) -> bool:
156
+ try:
157
+ return os.path.samefile(a, b)
158
+ except OSError:
159
+ return os.path.realpath(a) == os.path.realpath(b)
160
+
161
+
89
162
  def read_claude_recap(path: Path) -> ClaudeRecap | None:
90
163
  """Read the recap from one transcript (see ``transcript_key`` to pick it)."""
91
164
  try: