elliot-stack 1.0.29 → 1.0.33

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 (128) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -0
  3. package/bin/install.cjs +981 -950
  4. package/hooks/repo-search-nudge.js +32 -32
  5. package/package.json +1 -1
  6. package/skills/estack-active-learning-tutor/SKILL.md +339 -339
  7. package/skills/estack-better-title/SKILL.md +64 -64
  8. package/skills/estack-better-title/scripts/rename.sh +55 -55
  9. package/skills/estack-chris-voss/SKILL.md +80 -80
  10. package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
  11. package/skills/estack-chris-voss/references/voss-principles.md +210 -210
  12. package/skills/estack-customer-discovery/SKILL.md +60 -60
  13. package/skills/estack-flight-planner/SKILL.md +332 -332
  14. package/skills/estack-flight-planner/references/config_schema.md +156 -156
  15. package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
  16. package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
  17. package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
  18. package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
  19. package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
  20. package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
  21. package/skills/estack-github-issue-tracker/SKILL.md +322 -322
  22. package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
  23. package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
  24. package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
  25. package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
  26. package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
  27. package/skills/estack-leadership-coach/SKILL.md +235 -0
  28. package/skills/estack-leadership-coach/adding-references.md +280 -0
  29. package/skills/estack-leadership-coach/frameworks/delegation/flows/post-mortem.md +120 -0
  30. package/skills/estack-leadership-coach/frameworks/delegation/flows/pre-delegation.md +138 -0
  31. package/skills/estack-leadership-coach/frameworks/delegation/phases/1-intake.md +145 -0
  32. package/skills/estack-leadership-coach/frameworks/delegation/phases/2-trm-assessment.md +119 -0
  33. package/skills/estack-leadership-coach/frameworks/delegation/phases/3-enrollment.md +132 -0
  34. package/skills/estack-leadership-coach/frameworks/delegation/phases/4-build-brief.md +171 -0
  35. package/skills/estack-leadership-coach/frameworks/delegation/phases/5-monitoring.md +134 -0
  36. package/skills/estack-leadership-coach/frameworks/delegation/phases/6-reverse-delegation.md +118 -0
  37. package/skills/estack-leadership-coach/frameworks/delegation/phases/7-diagnose.md +200 -0
  38. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__deci-olafsen-ryan-2017-self-determination-theory-in-work-organizations.md +1881 -0
  39. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__gagne-deci-2005-self-determination-theory-and-work-motivation.md +2058 -0
  40. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__selfdeterminationtheory-org-theory-overview-page.md +61 -0
  41. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-3-key-insights-into-the-global-workplace-2024.md +57 -0
  42. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-managers-account-for-70-percent-of-variance-in-employee-engagement-2015.md +40 -0
  43. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-global-data-summary.md +73 -0
  44. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-report-landing.md +42 -0
  45. package/skills/estack-leadership-coach/references/.source-files/hormozi-leila_4-stages__leila-hormozi-the-art-of-delegation-blog-post.md +91 -0
  46. package/skills/estack-leadership-coach/references/.source-files/oncken-wass_monkeys-hbr-1974__oncken-wass-management-time-whos-got-the-monkey-hbr-classic-1974.md +969 -0
  47. package/skills/estack-leadership-coach/references/.source-files/sanchez_main-street-millionaire__codie-sanchez-afford-anything-podcast-ep-565-show-notes.md +89 -0
  48. package/skills/estack-leadership-coach/references/.source-files/sullivan_who-not-how__dan-sullivan-impact-filter-tool-and-guide-booklet.md +565 -0
  49. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-lewis-howes-school-of-greatness-ep-1231-show-notes.md +122 -0
  50. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-roger-dooley-cues-interview.md +194 -0
  51. package/skills/estack-leadership-coach/references/deci-ryan_self-determination-theory.md +166 -0
  52. package/skills/estack-leadership-coach/references/doerr_measure-what-matters.md +154 -0
  53. package/skills/estack-leadership-coach/references/ferriss_4hww.md +189 -0
  54. package/skills/estack-leadership-coach/references/gallup_engagement-research.md +105 -0
  55. package/skills/estack-leadership-coach/references/gerber_e-myth-revisited.md +118 -0
  56. package/skills/estack-leadership-coach/references/grove_high-output-management.md +95 -0
  57. package/skills/estack-leadership-coach/references/hormozi-alex_followthrough.md +152 -0
  58. package/skills/estack-leadership-coach/references/hormozi-leila_4-stages.md +146 -0
  59. package/skills/estack-leadership-coach/references/oncken-wass_monkeys-hbr-1974.md +128 -0
  60. package/skills/estack-leadership-coach/references/sanchez_main-street-millionaire.md +196 -0
  61. package/skills/estack-leadership-coach/references/sullivan_who-not-how.md +137 -0
  62. package/skills/estack-leadership-coach/references/van-edwards_cues.md +189 -0
  63. package/skills/estack-migrate-claude-session-history/SKILL.md +226 -0
  64. package/skills/estack-migrate-claude-session-history/references/path-encoding.md +55 -0
  65. package/skills/estack-migrate-claude-session-history/references/troubleshooting.md +96 -0
  66. package/skills/estack-migrate-claude-session-history/scripts/migrate-claude-history.js +1123 -0
  67. package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +48 -0
  68. package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +326 -0
  69. package/skills/estack-migrate-claude-session-history/scripts/validate-migration.py +493 -0
  70. package/skills/estack-pdf-to-md/SKILL.md +180 -0
  71. package/skills/estack-pdf-to-md/scripts/pdf_to_md.py +596 -0
  72. package/skills/estack-productivity-prioritization-coach/SKILL.md +124 -0
  73. package/skills/estack-productivity-prioritization-coach/sources/01-tony-robbins-rpm.md +39 -0
  74. package/skills/estack-productivity-prioritization-coach/sources/02-justin-sung-task-prioritization.md +34 -0
  75. package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
  76. package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
  77. package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
  78. package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
  79. package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
  80. package/skills/estack-read-claude-session-history/SKILL.md +204 -204
  81. package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
  82. package/skills/estack-read-claude-session-history/references/modes.md +423 -423
  83. package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
  84. package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
  85. package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
  86. package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
  87. package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
  88. package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
  89. package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
  90. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
  91. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
  92. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
  93. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
  94. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
  95. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
  96. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
  97. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
  98. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
  99. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
  100. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
  101. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
  102. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
  103. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
  104. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
  105. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
  106. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
  107. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
  108. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
  109. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
  110. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
  111. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
  112. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
  113. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
  114. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
  115. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
  116. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
  117. package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
  118. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
  119. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
  120. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
  121. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
  122. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
  123. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
  124. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
  125. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
  126. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
  127. package/skills/estack-repo-search/SKILL.md +65 -65
  128. package/skills/estack-vscode-file-recovery/SKILL.md +188 -0
@@ -1,234 +1,234 @@
1
- """Path resolution, project discovery, and time-spec parsing."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import re
7
- from datetime import datetime, timedelta
8
- from pathlib import Path
9
-
10
-
11
- CLAUDE_DIR = Path.home() / ".claude"
12
- DEFAULT_LIVE_PROJECTS = CLAUDE_DIR / "projects"
13
- DEFAULT_BACKUPS_DIR = Path.home() / ".claude-backups"
14
-
15
- KNOWN_ROOTS = {"live", "mirror", "snapshot-24h", "snapshot-1w", "snapshot-1mo"}
16
-
17
-
18
- def encode_cwd(cwd: str) -> str:
19
- """Convert an absolute path to the Claude project directory name.
20
-
21
- Replaces colons, backslashes, forward slashes, and whitespace with hyphens.
22
- Verified against the 34 real project dirs on this machine — no other chars
23
- appear in encoded names.
24
- """
25
- return re.sub(r"[:\\/\s]", "-", cwd)
26
-
27
-
28
- def decode_project_name(encoded: str) -> str:
29
- """Best-effort reverse for display.
30
-
31
- Strips the `C--Users-<user>-` drive/home prefix when present, replaces
32
- remaining hyphens with spaces, and joins path-like segments with " > ".
33
-
34
- Falls back to the raw encoded name if the heuristic fails. Display only —
35
- never use this to look up a real directory.
36
- """
37
- if not encoded:
38
- return encoded
39
-
40
- # Strip leading drive prefix `C--Users-<name>-`
41
- m = re.match(r"^([A-Z])--Users-([^-]+)-(.+)$", encoded)
42
- if m:
43
- remainder = m.group(3)
44
- else:
45
- remainder = encoded
46
-
47
- # Heuristic: every run of single hyphens is a path separator. The encoder
48
- # mapped one `-` per separator char, so a single `-` in the original path
49
- # is impossible to recover. We split on single `-` between word characters
50
- # and treat the result as path segments. Multiple consecutive hyphens
51
- # indicate the original had spaces+hyphens fused together — collapse to one.
52
- # In practice this gives readable output like "Other Claude Code > Personal Brand Project".
53
- cleaned = re.sub(r"-{2,}", "-", remainder)
54
- # Words are likely separated by hyphens; segments by capitalized starts.
55
- # Simple approach: just replace hyphens with spaces.
56
- return cleaned.replace("-", " ").strip() or encoded
57
-
58
-
59
- def current_session_id() -> str | None:
60
- """Return the current Claude Code session UUID from CLAUDE_SESSION_ID env var.
61
-
62
- Returns None when called outside a Claude Code session.
63
- """
64
- val = os.environ.get("CLAUDE_SESSION_ID", "").strip()
65
- return val or None
66
-
67
-
68
- def resolve_root(name: str | None) -> Path:
69
- """Resolve a root name to its absolute projects directory.
70
-
71
- - "live" (default, None) -> ~/.claude/projects
72
- - "mirror" -> ~/.claude-backups/mirror/projects
73
- - "snapshot-24h" -> ~/.claude-backups/snapshot-24h/projects
74
- - "snapshot-1w" / "snapshot-1mo" -> analogous
75
- - <absolute path> -> passes through unchanged
76
- """
77
- if not name or name == "live":
78
- return DEFAULT_LIVE_PROJECTS
79
- if name in KNOWN_ROOTS:
80
- return DEFAULT_BACKUPS_DIR / name / "projects"
81
- p = Path(name)
82
- if p.is_absolute():
83
- return p
84
- raise ValueError(
85
- f"Unknown root: {name!r}. Expected one of {sorted(KNOWN_ROOTS)} or an absolute path."
86
- )
87
-
88
-
89
- def find_project_dir(cwd: str, root: Path | None = None) -> Path:
90
- """Resolve a project directory under the given root.
91
-
92
- Tries exact encoded match first, falls back to case-insensitive substring.
93
- """
94
- if root is None:
95
- root = DEFAULT_LIVE_PROJECTS
96
- encoded = encode_cwd(cwd)
97
- candidate = root / encoded
98
- if candidate.exists():
99
- return candidate
100
- if root.exists():
101
- matches = [
102
- d for d in root.iterdir()
103
- if d.is_dir() and encoded.lower() in d.name.lower()
104
- ]
105
- if matches:
106
- return matches[0]
107
- raise FileNotFoundError(
108
- f"No project directory found for cwd: {cwd}\nExpected: {candidate}"
109
- )
110
-
111
-
112
- def list_projects(root: Path | None = None) -> list[Path]:
113
- """All encoded-cwd dirs under the given root."""
114
- if root is None:
115
- root = DEFAULT_LIVE_PROJECTS
116
- if not root.exists():
117
- return []
118
- return sorted([d for d in root.iterdir() if d.is_dir()], key=lambda d: d.name)
119
-
120
-
121
- def filter_projects(root: Path | None, name: str) -> list[Path]:
122
- """Project dirs whose encoded or decoded name contains `name` (case-insensitive).
123
-
124
- Matches against both forms so `--project "Keel Project"`, `--project
125
- Keel-Project`, and `--project keel` all hit the same directory.
126
- """
127
- q = name.strip().lower()
128
- q_encoded = q.replace(" ", "-")
129
- out = []
130
- for d in list_projects(root):
131
- dname = d.name.lower()
132
- decoded = decode_project_name(d.name).lower()
133
- if q in dname or q_encoded in dname or q in decoded:
134
- out.append(d)
135
- return out
136
-
137
-
138
- def list_transcripts(
139
- project_dir: Path,
140
- since: datetime | None = None,
141
- until: datetime | None = None,
142
- ) -> list[Path]:
143
- """Return .jsonl files in the project dir, newest first.
144
-
145
- Excludes subagent transcripts (files starting with `agent-`).
146
- """
147
- if not project_dir.exists():
148
- return []
149
- files = [f for f in project_dir.glob("*.jsonl") if not f.name.startswith("agent-")]
150
- # display_to_epoch (not .timestamp()) — naive bounds are in the display
151
- # timezone, which differs from local under a --tz override.
152
- from . import parser as _parser
153
- if since is not None:
154
- since_ts = _parser.display_to_epoch(since)
155
- files = [f for f in files if f.stat().st_mtime >= since_ts]
156
- if until is not None:
157
- until_ts = _parser.display_to_epoch(until)
158
- files = [f for f in files if f.stat().st_mtime <= until_ts]
159
- files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
160
- return files
161
-
162
-
163
- def list_subagents(session_file: Path) -> list[Path]:
164
- """Return subagent transcript files for a given parent session."""
165
- uuid = session_file.stem
166
- subagent_dir = session_file.parent / uuid / "subagents"
167
- if not subagent_dir.exists():
168
- return []
169
- return sorted(
170
- subagent_dir.glob("agent-*.jsonl"),
171
- key=lambda f: f.stat().st_mtime,
172
- reverse=True,
173
- )
174
-
175
-
176
- _RELATIVE_RE = re.compile(r"^(\d+)\s*(m|h|d|w|mo)$", re.IGNORECASE)
177
-
178
-
179
- def parse_timespec(s: str) -> datetime:
180
- """Parse a time spec into a naive datetime in the display timezone
181
- (system local time unless --tz overrides it).
182
-
183
- Accepts:
184
- - ISO date: "2026-05-01"
185
- - ISO datetime: "2026-05-01T14:30" or "2026-05-01 14:30"
186
- - Relative: "30m", "24h", "7d", "1w", "1mo"
187
- - Named: "today", "yesterday", "now"
188
- """
189
- if not s:
190
- raise ValueError("Empty time spec")
191
- s = s.strip()
192
- lower = s.lower()
193
- # "now" in the display timezone (== datetime.now() unless --tz is set),
194
- # so that named/relative specs stay consistent with displayed times.
195
- from . import parser as _parser
196
- now = _parser.now_display()
197
- if lower == "now":
198
- return now
199
- if lower == "today":
200
- return now.replace(hour=0, minute=0, second=0, microsecond=0)
201
- if lower == "yesterday":
202
- return (now - timedelta(days=1)).replace(
203
- hour=0, minute=0, second=0, microsecond=0
204
- )
205
- m = _RELATIVE_RE.match(s)
206
- if m:
207
- n = int(m.group(1))
208
- unit = m.group(2).lower()
209
- if unit == "m":
210
- return now - timedelta(minutes=n)
211
- if unit == "h":
212
- return now - timedelta(hours=n)
213
- if unit == "d":
214
- return now - timedelta(days=n)
215
- if unit == "w":
216
- return now - timedelta(weeks=n)
217
- if unit == "mo":
218
- return now - timedelta(days=30 * n)
219
- # ISO formats
220
- for fmt in (
221
- "%Y-%m-%dT%H:%M:%S",
222
- "%Y-%m-%dT%H:%M",
223
- "%Y-%m-%d %H:%M:%S",
224
- "%Y-%m-%d %H:%M",
225
- "%Y-%m-%d",
226
- ):
227
- try:
228
- return datetime.strptime(s, fmt)
229
- except ValueError:
230
- continue
231
- try:
232
- return datetime.fromisoformat(s)
233
- except ValueError as e:
234
- raise ValueError(f"Unrecognized time spec: {s!r}") from e
1
+ """Path resolution, project discovery, and time-spec parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+
10
+
11
+ CLAUDE_DIR = Path.home() / ".claude"
12
+ DEFAULT_LIVE_PROJECTS = CLAUDE_DIR / "projects"
13
+ DEFAULT_BACKUPS_DIR = Path.home() / ".claude-backups"
14
+
15
+ KNOWN_ROOTS = {"live", "mirror", "snapshot-24h", "snapshot-1w", "snapshot-1mo"}
16
+
17
+
18
+ def encode_cwd(cwd: str) -> str:
19
+ """Convert an absolute path to the Claude project directory name.
20
+
21
+ Replaces colons, backslashes, forward slashes, and whitespace with hyphens.
22
+ Verified against the 34 real project dirs on this machine — no other chars
23
+ appear in encoded names.
24
+ """
25
+ return re.sub(r"[:\\/\s]", "-", cwd)
26
+
27
+
28
+ def decode_project_name(encoded: str) -> str:
29
+ """Best-effort reverse for display.
30
+
31
+ Strips the `C--Users-<user>-` drive/home prefix when present, replaces
32
+ remaining hyphens with spaces, and joins path-like segments with " > ".
33
+
34
+ Falls back to the raw encoded name if the heuristic fails. Display only —
35
+ never use this to look up a real directory.
36
+ """
37
+ if not encoded:
38
+ return encoded
39
+
40
+ # Strip leading drive prefix `C--Users-<name>-`
41
+ m = re.match(r"^([A-Z])--Users-([^-]+)-(.+)$", encoded)
42
+ if m:
43
+ remainder = m.group(3)
44
+ else:
45
+ remainder = encoded
46
+
47
+ # Heuristic: every run of single hyphens is a path separator. The encoder
48
+ # mapped one `-` per separator char, so a single `-` in the original path
49
+ # is impossible to recover. We split on single `-` between word characters
50
+ # and treat the result as path segments. Multiple consecutive hyphens
51
+ # indicate the original had spaces+hyphens fused together — collapse to one.
52
+ # In practice this gives readable output like "Other Claude Code > Personal Brand Project".
53
+ cleaned = re.sub(r"-{2,}", "-", remainder)
54
+ # Words are likely separated by hyphens; segments by capitalized starts.
55
+ # Simple approach: just replace hyphens with spaces.
56
+ return cleaned.replace("-", " ").strip() or encoded
57
+
58
+
59
+ def current_session_id() -> str | None:
60
+ """Return the current Claude Code session UUID from CLAUDE_SESSION_ID env var.
61
+
62
+ Returns None when called outside a Claude Code session.
63
+ """
64
+ val = os.environ.get("CLAUDE_SESSION_ID", "").strip()
65
+ return val or None
66
+
67
+
68
+ def resolve_root(name: str | None) -> Path:
69
+ """Resolve a root name to its absolute projects directory.
70
+
71
+ - "live" (default, None) -> ~/.claude/projects
72
+ - "mirror" -> ~/.claude-backups/mirror/projects
73
+ - "snapshot-24h" -> ~/.claude-backups/snapshot-24h/projects
74
+ - "snapshot-1w" / "snapshot-1mo" -> analogous
75
+ - <absolute path> -> passes through unchanged
76
+ """
77
+ if not name or name == "live":
78
+ return DEFAULT_LIVE_PROJECTS
79
+ if name in KNOWN_ROOTS:
80
+ return DEFAULT_BACKUPS_DIR / name / "projects"
81
+ p = Path(name)
82
+ if p.is_absolute():
83
+ return p
84
+ raise ValueError(
85
+ f"Unknown root: {name!r}. Expected one of {sorted(KNOWN_ROOTS)} or an absolute path."
86
+ )
87
+
88
+
89
+ def find_project_dir(cwd: str, root: Path | None = None) -> Path:
90
+ """Resolve a project directory under the given root.
91
+
92
+ Tries exact encoded match first, falls back to case-insensitive substring.
93
+ """
94
+ if root is None:
95
+ root = DEFAULT_LIVE_PROJECTS
96
+ encoded = encode_cwd(cwd)
97
+ candidate = root / encoded
98
+ if candidate.exists():
99
+ return candidate
100
+ if root.exists():
101
+ matches = [
102
+ d for d in root.iterdir()
103
+ if d.is_dir() and encoded.lower() in d.name.lower()
104
+ ]
105
+ if matches:
106
+ return matches[0]
107
+ raise FileNotFoundError(
108
+ f"No project directory found for cwd: {cwd}\nExpected: {candidate}"
109
+ )
110
+
111
+
112
+ def list_projects(root: Path | None = None) -> list[Path]:
113
+ """All encoded-cwd dirs under the given root."""
114
+ if root is None:
115
+ root = DEFAULT_LIVE_PROJECTS
116
+ if not root.exists():
117
+ return []
118
+ return sorted([d for d in root.iterdir() if d.is_dir()], key=lambda d: d.name)
119
+
120
+
121
+ def filter_projects(root: Path | None, name: str) -> list[Path]:
122
+ """Project dirs whose encoded or decoded name contains `name` (case-insensitive).
123
+
124
+ Matches against both forms so `--project "Keel Project"`, `--project
125
+ Keel-Project`, and `--project keel` all hit the same directory.
126
+ """
127
+ q = name.strip().lower()
128
+ q_encoded = q.replace(" ", "-")
129
+ out = []
130
+ for d in list_projects(root):
131
+ dname = d.name.lower()
132
+ decoded = decode_project_name(d.name).lower()
133
+ if q in dname or q_encoded in dname or q in decoded:
134
+ out.append(d)
135
+ return out
136
+
137
+
138
+ def list_transcripts(
139
+ project_dir: Path,
140
+ since: datetime | None = None,
141
+ until: datetime | None = None,
142
+ ) -> list[Path]:
143
+ """Return .jsonl files in the project dir, newest first.
144
+
145
+ Excludes subagent transcripts (files starting with `agent-`).
146
+ """
147
+ if not project_dir.exists():
148
+ return []
149
+ files = [f for f in project_dir.glob("*.jsonl") if not f.name.startswith("agent-")]
150
+ # display_to_epoch (not .timestamp()) — naive bounds are in the display
151
+ # timezone, which differs from local under a --tz override.
152
+ from . import parser as _parser
153
+ if since is not None:
154
+ since_ts = _parser.display_to_epoch(since)
155
+ files = [f for f in files if f.stat().st_mtime >= since_ts]
156
+ if until is not None:
157
+ until_ts = _parser.display_to_epoch(until)
158
+ files = [f for f in files if f.stat().st_mtime <= until_ts]
159
+ files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
160
+ return files
161
+
162
+
163
+ def list_subagents(session_file: Path) -> list[Path]:
164
+ """Return subagent transcript files for a given parent session."""
165
+ uuid = session_file.stem
166
+ subagent_dir = session_file.parent / uuid / "subagents"
167
+ if not subagent_dir.exists():
168
+ return []
169
+ return sorted(
170
+ subagent_dir.glob("agent-*.jsonl"),
171
+ key=lambda f: f.stat().st_mtime,
172
+ reverse=True,
173
+ )
174
+
175
+
176
+ _RELATIVE_RE = re.compile(r"^(\d+)\s*(m|h|d|w|mo)$", re.IGNORECASE)
177
+
178
+
179
+ def parse_timespec(s: str) -> datetime:
180
+ """Parse a time spec into a naive datetime in the display timezone
181
+ (system local time unless --tz overrides it).
182
+
183
+ Accepts:
184
+ - ISO date: "2026-05-01"
185
+ - ISO datetime: "2026-05-01T14:30" or "2026-05-01 14:30"
186
+ - Relative: "30m", "24h", "7d", "1w", "1mo"
187
+ - Named: "today", "yesterday", "now"
188
+ """
189
+ if not s:
190
+ raise ValueError("Empty time spec")
191
+ s = s.strip()
192
+ lower = s.lower()
193
+ # "now" in the display timezone (== datetime.now() unless --tz is set),
194
+ # so that named/relative specs stay consistent with displayed times.
195
+ from . import parser as _parser
196
+ now = _parser.now_display()
197
+ if lower == "now":
198
+ return now
199
+ if lower == "today":
200
+ return now.replace(hour=0, minute=0, second=0, microsecond=0)
201
+ if lower == "yesterday":
202
+ return (now - timedelta(days=1)).replace(
203
+ hour=0, minute=0, second=0, microsecond=0
204
+ )
205
+ m = _RELATIVE_RE.match(s)
206
+ if m:
207
+ n = int(m.group(1))
208
+ unit = m.group(2).lower()
209
+ if unit == "m":
210
+ return now - timedelta(minutes=n)
211
+ if unit == "h":
212
+ return now - timedelta(hours=n)
213
+ if unit == "d":
214
+ return now - timedelta(days=n)
215
+ if unit == "w":
216
+ return now - timedelta(weeks=n)
217
+ if unit == "mo":
218
+ return now - timedelta(days=30 * n)
219
+ # ISO formats
220
+ for fmt in (
221
+ "%Y-%m-%dT%H:%M:%S",
222
+ "%Y-%m-%dT%H:%M",
223
+ "%Y-%m-%d %H:%M:%S",
224
+ "%Y-%m-%d %H:%M",
225
+ "%Y-%m-%d",
226
+ ):
227
+ try:
228
+ return datetime.strptime(s, fmt)
229
+ except ValueError:
230
+ continue
231
+ try:
232
+ return datetime.fromisoformat(s)
233
+ except ValueError as e:
234
+ raise ValueError(f"Unrecognized time spec: {s!r}") from e