elliot-stack 1.0.39 → 1.0.40
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.
- package/package.json +1 -1
- package/skills/estack-migrate-claude-session-history/SKILL.md +3 -2
- package/skills/estack-migrate-claude-session-history/scripts/__pycache__/validate-migration.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/SKILL.md +27 -3
- package/skills/estack-read-claude-session-history/references/modes.md +52 -7
- package/skills/estack-read-claude-session-history/references/recipes.md +7 -1
- package/skills/estack-read-claude-session-history/scripts/__pycache__/read_transcript.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/parser.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/search.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/subagents.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/tools.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +140 -9
- package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +0 -48
- package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +0 -326
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +0 -40
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +0 -20
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +0 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +0 -9
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +0 -7
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +0 -8
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +0 -1
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +0 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +0 -6
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent/subagents/agent-sub1.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +0 -10
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +0 -56
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +0 -239
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +0 -201
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +0 -323
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +0 -204
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +0 -133
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +0 -94
- package/skills/estack-read-claude-session-history/scripts/tests/test_search_output.py +0 -161
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +0 -43
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +0 -179
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +0 -225
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +0 -80
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
"""Tests for lib.search."""
|
|
2
|
-
|
|
3
|
-
from datetime import datetime, timedelta
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
from lib import parser as PR
|
|
7
|
-
from lib import search as S
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_search_session_text(fixtures_dir):
|
|
11
|
-
matches = S.search_session(
|
|
12
|
-
fixtures_dir / "basic-session.jsonl", "Hello", role="both", in_channel="text"
|
|
13
|
-
)
|
|
14
|
-
assert len(matches) >= 1
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_search_session_assistant_only(fixtures_dir):
|
|
18
|
-
matches = S.search_session(
|
|
19
|
-
fixtures_dir / "basic-session.jsonl", "Hello",
|
|
20
|
-
role="assistant", in_channel="text",
|
|
21
|
-
)
|
|
22
|
-
# "Hello" is in the user message only
|
|
23
|
-
assert len(matches) == 0
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_search_session_user_only(fixtures_dir):
|
|
27
|
-
matches = S.search_session(
|
|
28
|
-
fixtures_dir / "basic-session.jsonl", "Hello",
|
|
29
|
-
role="user", in_channel="text",
|
|
30
|
-
)
|
|
31
|
-
assert len(matches) >= 1
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_search_in_tool_use(fixtures_dir):
|
|
35
|
-
matches = S.search_session(
|
|
36
|
-
fixtures_dir / "tool-zoo.jsonl", "ls -la",
|
|
37
|
-
role="both", in_channel="tool_use",
|
|
38
|
-
)
|
|
39
|
-
assert len(matches) >= 1
|
|
40
|
-
assert matches[0].where == "tool_use"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_search_in_thinking(fixtures_dir):
|
|
44
|
-
matches = S.search_session(
|
|
45
|
-
fixtures_dir / "with-thinking.jsonl", "step by step",
|
|
46
|
-
role="both", in_channel="thinking",
|
|
47
|
-
)
|
|
48
|
-
assert len(matches) >= 1
|
|
49
|
-
assert matches[0].where == "thinking"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_search_no_match(fixtures_dir):
|
|
53
|
-
matches = S.search_session(
|
|
54
|
-
fixtures_dir / "basic-session.jsonl", "this-string-not-present",
|
|
55
|
-
role="both", in_channel="text",
|
|
56
|
-
)
|
|
57
|
-
assert matches == []
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def test_search_with_time_filter(fixtures_dir):
|
|
61
|
-
# "Hello" appears in basic-session.jsonl at 2026-05-01T10:00:00Z
|
|
62
|
-
# Excluding that date should yield no matches
|
|
63
|
-
matches = S.search_session(
|
|
64
|
-
fixtures_dir / "basic-session.jsonl", "Hello",
|
|
65
|
-
role="both", in_channel="text",
|
|
66
|
-
since=datetime(2026, 6, 1),
|
|
67
|
-
)
|
|
68
|
-
assert matches == []
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_search_until_is_exclusive(fixtures_dir):
|
|
72
|
-
# The "Hello" user message is stamped 2026-05-01T10:00:00Z. Derive the bound
|
|
73
|
-
# from the parser (it converts to local naive time) so this is tz-independent.
|
|
74
|
-
# Half-open [since, until): until at that instant excludes it; one second later includes it.
|
|
75
|
-
at = PR._parse_timestamp("2026-05-01T10:00:00Z")
|
|
76
|
-
assert S.search_session(
|
|
77
|
-
fixtures_dir / "basic-session.jsonl", "Hello", role="both", until=at
|
|
78
|
-
) == []
|
|
79
|
-
# "Hello" matches only the user message at the boundary instant, so +1s yields exactly 1.
|
|
80
|
-
assert len(S.search_session(
|
|
81
|
-
fixtures_dir / "basic-session.jsonl", "Hello", role="both",
|
|
82
|
-
until=at + timedelta(seconds=1)
|
|
83
|
-
)) == 1
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_search_project(fixtures_dir, tmp_path):
|
|
87
|
-
# Copy 2 fixtures into a fake project dir and search
|
|
88
|
-
import shutil
|
|
89
|
-
pd = tmp_path / "fake-proj"
|
|
90
|
-
pd.mkdir()
|
|
91
|
-
shutil.copy(fixtures_dir / "basic-session.jsonl", pd / "session-a.jsonl")
|
|
92
|
-
shutil.copy(fixtures_dir / "tool-zoo.jsonl", pd / "session-b.jsonl")
|
|
93
|
-
matches = list(S.search_project(pd, "Hello", progress=False))
|
|
94
|
-
assert len(matches) >= 1
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
"""Tests for cross-scope search output budgeting: summary-by-default, --full,
|
|
2
|
-
the char-budget degrade, and TTY-gated progress."""
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import shutil
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
import read_transcript as RT
|
|
14
|
-
from lib.search import Match
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _run_cli(cli_path, *args):
|
|
18
|
-
env = dict(os.environ)
|
|
19
|
-
env["PYTHONIOENCODING"] = "utf-8"
|
|
20
|
-
return subprocess.run(
|
|
21
|
-
[sys.executable, str(cli_path), *args],
|
|
22
|
-
capture_output=True, text=True, encoding="utf-8", env=env,
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# A realistic epoch base so _fmt_mtime renders sane dates if output is printed.
|
|
27
|
-
_BASE_MTIME = 1_700_000_000
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _mk(stem, project, mtime, window, role="assistant", where="text"):
|
|
31
|
-
sp = Path("/root") / project / f"{stem}.jsonl"
|
|
32
|
-
return Match(session_path=sp, mtime=mtime, role=role, where=where,
|
|
33
|
-
timestamp=None, window_text=window)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@pytest.fixture
|
|
37
|
-
def wide_root(fixtures_dir, tmp_path):
|
|
38
|
-
"""A fake projects root with two projects, each holding a session."""
|
|
39
|
-
root = tmp_path / "projects"
|
|
40
|
-
keel = root / "C--Users-x-Keel-Project"
|
|
41
|
-
other = root / "C--Users-x-Other-Claude-Code"
|
|
42
|
-
keel.mkdir(parents=True)
|
|
43
|
-
other.mkdir(parents=True)
|
|
44
|
-
shutil.copy(fixtures_dir / "basic-session.jsonl", keel / "keelsession.jsonl")
|
|
45
|
-
shutil.copy(fixtures_dir / "basic-session.jsonl", other / "othersession.jsonl")
|
|
46
|
-
return root
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# ── unit: renderers ──────────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
def test_one_line_collapses_and_truncates():
|
|
52
|
-
assert RT._one_line("a\n b c", 100) == "a b c"
|
|
53
|
-
assert RT._one_line("x" * 200, 10) == "x" * 10 + "…"
|
|
54
|
-
assert RT._one_line("", 10) == ""
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_summary_counts_all_hits_and_orders_newest_first():
|
|
58
|
-
matches = [
|
|
59
|
-
_mk("aaaaaaaa11", "C--Users-x-Proj", _BASE_MTIME, "hello world"),
|
|
60
|
-
_mk("aaaaaaaa11", "C--Users-x-Proj", _BASE_MTIME, "hello again"),
|
|
61
|
-
_mk("bbbbbbbb22", "C--Users-x-Proj", _BASE_MTIME + 100, "hello there"),
|
|
62
|
-
]
|
|
63
|
-
out = RT._render_search_summary("hello", matches)
|
|
64
|
-
assert '"hello": 3 matches across 2 sessions' in out
|
|
65
|
-
assert "2 hits" in out # the aaaa session has 2
|
|
66
|
-
# bbbb (BASE+100, newer) sorts before aaaa (BASE, older) — newest first
|
|
67
|
-
assert out.index("bbbbbbbb") < out.index("aaaaaaaa")
|
|
68
|
-
assert "--- Match #" not in out # no full windows in summary
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_summary_caps_sessions_but_counts_all(monkeypatch):
|
|
72
|
-
monkeypatch.setattr(RT, "SEARCH_SUMMARY_SESSION_CAP", 2)
|
|
73
|
-
matches = [_mk(f"sess{i:04d}xx", "C--Users-x-Proj", _BASE_MTIME + i, "hit") for i in range(5)]
|
|
74
|
-
out = RT._render_search_summary("hit", matches)
|
|
75
|
-
assert "5 matches across 5 sessions" in out # header counts everything
|
|
76
|
-
assert "3 more session" in out # 5 total - 2 shown
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_search_summary_json_has_no_windows():
|
|
80
|
-
matches = [_mk("aaaaaaaa11", "C--Users-x-My-Proj", _BASE_MTIME, "hello world snippet")]
|
|
81
|
-
js = RT._search_summary_json(matches)
|
|
82
|
-
assert js[0]["uuid"] == "aaaaaaaa11"
|
|
83
|
-
assert js[0]["hits"] == 1
|
|
84
|
-
assert "window" not in js[0]
|
|
85
|
-
assert js[0]["first_snippet"] == "hello world snippet"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def test_single_file_full_degrades_to_summary_when_oversized(monkeypatch, fixtures_dir):
|
|
89
|
-
# Even a single-file search degrades if its full windows exceed the budget.
|
|
90
|
-
monkeypatch.setattr(RT, "SEARCH_CHAR_BUDGET", 10)
|
|
91
|
-
out = RT.mode_search_v2(
|
|
92
|
-
root=Path("."), cwd=None, all_projects=False,
|
|
93
|
-
file_path=fixtures_dir / "basic-session.jsonl",
|
|
94
|
-
query="Hello", role="both", in_channel="text",
|
|
95
|
-
since=None, until=None, fmt="text",
|
|
96
|
-
)
|
|
97
|
-
assert out.startswith("[note: full output")
|
|
98
|
-
assert '"Hello":' in out # summary header is appended after the note
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def test_wide_full_degrades_to_summary_when_oversized(monkeypatch, wide_root):
|
|
102
|
-
# The advertised path: wide scope + --full, oversized, degrades to summary.
|
|
103
|
-
monkeypatch.setattr(RT, "SEARCH_CHAR_BUDGET", 10)
|
|
104
|
-
out = RT.mode_search_v2(
|
|
105
|
-
root=wide_root, cwd=None, all_projects=True, file_path=None,
|
|
106
|
-
query="Hello", role="both", in_channel="text",
|
|
107
|
-
since=None, until=None, fmt="text", full=True,
|
|
108
|
-
)
|
|
109
|
-
assert out.startswith("[note: full output")
|
|
110
|
-
assert "matches across" in out # summary header appended
|
|
111
|
-
assert "--- Match #" not in out # degraded — no windows survive
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
# ── CLI: end-to-end ──────────────────────────────────────────────────────────
|
|
115
|
-
|
|
116
|
-
def test_wide_search_summarizes_by_default(cli_path, wide_root):
|
|
117
|
-
r = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
118
|
-
"--query", "Hello", "--all-projects")
|
|
119
|
-
assert r.returncode == 0
|
|
120
|
-
assert '"Hello":' in r.stdout and "matches across" in r.stdout # real summary header
|
|
121
|
-
assert "--- Match #" not in r.stdout # summary, not windows
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_wide_search_full_shows_windows(cli_path, wide_root):
|
|
125
|
-
r = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
126
|
-
"--query", "Hello", "--all-projects", "--full")
|
|
127
|
-
assert r.returncode == 0
|
|
128
|
-
assert "--- Match #" in r.stdout # full windows present
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def test_progress_suppressed_when_stderr_not_a_tty(cli_path, wide_root):
|
|
132
|
-
# stderr is captured (not a TTY) — the Searching i/N progress must not appear.
|
|
133
|
-
r = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
134
|
-
"--query", "Hello", "--all-projects")
|
|
135
|
-
assert "Searching" not in r.stderr
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def test_cwd_search_matches_user_messages(cli_path, wide_root):
|
|
139
|
-
# "Hello" lives only in the user message of basic-session.jsonl. A --cwd
|
|
140
|
-
# search now routes through mode_search_v2 (role=both), so it must be found —
|
|
141
|
-
# the old assistant-only --cwd path would have missed it.
|
|
142
|
-
found = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
143
|
-
"--query", "Hello", "--cwd", "Keel")
|
|
144
|
-
assert found.returncode == 0
|
|
145
|
-
assert '"Hello":' in found.stdout and "across" in found.stdout
|
|
146
|
-
assert "keelsess" in found.stdout
|
|
147
|
-
|
|
148
|
-
# Restricting to assistant role excludes the user-only hit.
|
|
149
|
-
asst = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
150
|
-
"--query", "Hello", "--cwd", "Keel", "--role", "assistant")
|
|
151
|
-
assert asst.returncode == 0
|
|
152
|
-
assert "No matches" in asst.stdout
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def test_json_role_filter_excludes_user_only_match(cli_path, wide_root):
|
|
156
|
-
# JSON path honors --role too: assistant-only over a user-only term → empty list.
|
|
157
|
-
r = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
158
|
-
"--query", "Hello", "--cwd", "Keel",
|
|
159
|
-
"--role", "assistant", "--format", "json")
|
|
160
|
-
assert r.returncode == 0
|
|
161
|
-
assert json.loads(r.stdout) == []
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""Tests for lib.subagents."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from lib import subagents as SA
|
|
6
|
-
from lib import paths as P
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_load_meta_hit(fixtures_dir):
|
|
10
|
-
agent_file = fixtures_dir / "subagent-parent" / "subagents" / "agent-xyz123.jsonl"
|
|
11
|
-
meta = SA.load_meta(agent_file)
|
|
12
|
-
assert meta["agentType"] == "Explore"
|
|
13
|
-
assert "bug" in meta["description"].lower()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_load_meta_miss(fixtures_dir):
|
|
17
|
-
agent_file = fixtures_dir / "subagent-no-meta" / "subagents" / "agent-aaa.jsonl"
|
|
18
|
-
meta = SA.load_meta(agent_file)
|
|
19
|
-
assert meta["agentType"] == "unknown"
|
|
20
|
-
assert meta["description"] == ""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_agent_finals(fixtures_dir):
|
|
24
|
-
parent = fixtures_dir / "subagent-parent.jsonl"
|
|
25
|
-
finals = SA.agent_finals(parent)
|
|
26
|
-
assert len(finals) == 1
|
|
27
|
-
agent_id, meta, text = finals[0]
|
|
28
|
-
assert agent_id == "agent-xyz123"
|
|
29
|
-
assert meta["agentType"] == "Explore"
|
|
30
|
-
assert "Found it" in text
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_agent_finals_no_subagents(fixtures_dir):
|
|
34
|
-
parent = fixtures_dir / "basic-session.jsonl"
|
|
35
|
-
finals = SA.agent_finals(parent)
|
|
36
|
-
assert finals == []
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_list_subagents(fixtures_dir):
|
|
40
|
-
parent = fixtures_dir / "subagent-parent.jsonl"
|
|
41
|
-
subs = P.list_subagents(parent)
|
|
42
|
-
assert len(subs) == 1
|
|
43
|
-
assert subs[0].stem == "agent-xyz123"
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
"""Tests for the timeline mode (build/render/gap parsing + CLI)."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
import shutil
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
from datetime import datetime, timedelta
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
import read_transcript as RT
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _run_cli(cli_path, *args, env_overrides=None):
|
|
17
|
-
env = dict(os.environ)
|
|
18
|
-
env["PYTHONIOENCODING"] = "utf-8"
|
|
19
|
-
if env_overrides:
|
|
20
|
-
env.update(env_overrides)
|
|
21
|
-
return subprocess.run(
|
|
22
|
-
[sys.executable, str(cli_path), *args],
|
|
23
|
-
capture_output=True,
|
|
24
|
-
text=True,
|
|
25
|
-
encoding="utf-8",
|
|
26
|
-
env=env,
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@pytest.fixture
|
|
31
|
-
def fake_root(fixtures_dir, tmp_path):
|
|
32
|
-
root = tmp_path / "projects"
|
|
33
|
-
proj = root / "C--fake-proj"
|
|
34
|
-
proj.mkdir(parents=True)
|
|
35
|
-
shutil.copy(fixtures_dir / "timeline-day-test.jsonl", proj / "abc12345.jsonl")
|
|
36
|
-
return root
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
# ── unit: gap + duration helpers ─────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
def test_parse_gap_default():
|
|
42
|
-
assert RT._parse_gap(None) == 15
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_parse_gap_minutes():
|
|
46
|
-
assert RT._parse_gap("20m") == 20
|
|
47
|
-
assert RT._parse_gap("20") == 20
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_parse_gap_hours():
|
|
51
|
-
assert RT._parse_gap("1h") == 60
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def test_parse_gap_invalid():
|
|
55
|
-
with pytest.raises(ValueError):
|
|
56
|
-
RT._parse_gap("soon")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_fmt_dur():
|
|
60
|
-
assert RT._fmt_dur(timedelta(minutes=8)) == "8m"
|
|
61
|
-
assert RT._fmt_dur(timedelta(minutes=72)) == "1h12m"
|
|
62
|
-
assert RT._fmt_dur(timedelta(seconds=30)) == "<1m"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# ── unit: block grouping ─────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
def test_build_timeline_blocks(fake_root):
|
|
68
|
-
from lib import parser as PR
|
|
69
|
-
PR.set_timezone("UTC")
|
|
70
|
-
try:
|
|
71
|
-
data = RT.build_timeline(
|
|
72
|
-
[fake_root / "C--fake-proj"],
|
|
73
|
-
since=datetime(2026, 5, 1),
|
|
74
|
-
until=datetime(2026, 5, 2),
|
|
75
|
-
gap_minutes=15,
|
|
76
|
-
current_uuid=None,
|
|
77
|
-
)
|
|
78
|
-
finally:
|
|
79
|
-
PR.set_timezone(None)
|
|
80
|
-
blocks = data["blocks"]
|
|
81
|
-
assert len(blocks) == 2
|
|
82
|
-
assert blocks[0]["start"] == datetime(2026, 5, 1, 10, 0)
|
|
83
|
-
assert blocks[0]["end"] == datetime(2026, 5, 1, 10, 8)
|
|
84
|
-
assert blocks[1]["start"] == datetime(2026, 5, 1, 12, 0)
|
|
85
|
-
assert blocks[1]["end"] == datetime(2026, 5, 1, 12, 2)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def test_build_timeline_wide_gap_merges(fake_root):
|
|
89
|
-
from lib import parser as PR
|
|
90
|
-
PR.set_timezone("UTC")
|
|
91
|
-
try:
|
|
92
|
-
data = RT.build_timeline(
|
|
93
|
-
[fake_root / "C--fake-proj"],
|
|
94
|
-
since=datetime(2026, 5, 1),
|
|
95
|
-
until=datetime(2026, 5, 2),
|
|
96
|
-
gap_minutes=180, # 3h gap threshold swallows the 1h52m idle
|
|
97
|
-
current_uuid=None,
|
|
98
|
-
)
|
|
99
|
-
finally:
|
|
100
|
-
PR.set_timezone(None)
|
|
101
|
-
assert len(data["blocks"]) == 1
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# ── CLI ──────────────────────────────────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
def test_timeline_cli_text(cli_path, fake_root):
|
|
107
|
-
r = _run_cli(
|
|
108
|
-
cli_path, "--root", str(fake_root), "--tz", "UTC",
|
|
109
|
-
"--mode", "timeline", "--date", "2026-05-01",
|
|
110
|
-
)
|
|
111
|
-
assert r.returncode == 0
|
|
112
|
-
assert "10:00" in r.stdout
|
|
113
|
-
assert "12:02" in r.stdout
|
|
114
|
-
assert "idle" in r.stdout
|
|
115
|
-
assert "2 block(s)" in r.stdout
|
|
116
|
-
# Timeline makes no attention claim — that's engagement mode's job.
|
|
117
|
-
assert "active" not in r.stdout
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def test_timeline_cli_json(cli_path, fake_root):
|
|
121
|
-
r = _run_cli(
|
|
122
|
-
cli_path, "--root", str(fake_root), "--tz", "UTC",
|
|
123
|
-
"--mode", "timeline", "--date", "2026-05-01", "--format", "json",
|
|
124
|
-
)
|
|
125
|
-
assert r.returncode == 0
|
|
126
|
-
data = json.loads(r.stdout)
|
|
127
|
-
assert data["totals"]["blocks"] == 2
|
|
128
|
-
assert data["totals"]["sessions"] == 1
|
|
129
|
-
assert data["totals"]["span_minutes"] == 122 # 10:00 → 12:02
|
|
130
|
-
assert "active_minutes" not in data["totals"]
|
|
131
|
-
assert data["blocks"][0]["start"].endswith("10:00:00")
|
|
132
|
-
assert data["blocks"][0]["sessions"][0]["uuid"] == "abc12345"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def test_timeline_cli_empty_range(cli_path, fake_root):
|
|
136
|
-
r = _run_cli(
|
|
137
|
-
cli_path, "--root", str(fake_root), "--tz", "UTC",
|
|
138
|
-
"--mode", "timeline", "--date", "2020-01-01",
|
|
139
|
-
)
|
|
140
|
-
assert r.returncode == 0
|
|
141
|
-
assert "no activity" in r.stdout
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def test_timeline_cli_project_filter(cli_path, fake_root):
|
|
145
|
-
r = _run_cli(
|
|
146
|
-
cli_path, "--root", str(fake_root), "--tz", "UTC",
|
|
147
|
-
"--mode", "timeline", "--date", "2026-05-01", "--project", "fake",
|
|
148
|
-
)
|
|
149
|
-
assert r.returncode == 0
|
|
150
|
-
assert "10:00" in r.stdout
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def test_timeline_cli_exclude_current(cli_path, fake_root):
|
|
154
|
-
r = _run_cli(
|
|
155
|
-
cli_path, "--root", str(fake_root), "--tz", "UTC",
|
|
156
|
-
"--mode", "timeline", "--date", "2026-05-01", "--exclude-current",
|
|
157
|
-
env_overrides={"CLAUDE_SESSION_ID": "abc12345"},
|
|
158
|
-
)
|
|
159
|
-
assert r.returncode == 0
|
|
160
|
-
assert "no activity" in r.stdout
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def test_journal_cli_exclude_current(cli_path, fake_root):
|
|
164
|
-
r = _run_cli(
|
|
165
|
-
cli_path, "--root", str(fake_root),
|
|
166
|
-
"--mode", "journal", "--since", "2020-01-01", "--all-projects",
|
|
167
|
-
"--exclude-current",
|
|
168
|
-
env_overrides={"CLAUDE_SESSION_ID": "abc12345"},
|
|
169
|
-
)
|
|
170
|
-
assert r.returncode == 0
|
|
171
|
-
assert "abc12345" not in r.stdout
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def test_timeline_cli_project_no_match(cli_path, fake_root):
|
|
175
|
-
r = _run_cli(
|
|
176
|
-
cli_path, "--root", str(fake_root), "--tz", "UTC",
|
|
177
|
-
"--mode", "timeline", "--date", "2026-05-01", "--project", "zzz-nope",
|
|
178
|
-
)
|
|
179
|
-
assert r.returncode == 1
|