Coverage for little_loops / session_log.py: 100%

47 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

1"""Session log linking for issue files. 

2 

3Links Claude Code JSONL session files to issue files by appending 

4session log entries with command name, timestamp, and file path. 

5""" 

6 

7from __future__ import annotations 

8 

9import re 

10from datetime import UTC, datetime 

11from pathlib import Path 

12 

13from little_loops.file_utils import atomic_write 

14from little_loops.user_messages import get_project_folder 

15 

16# Regex to isolate the ## Session Log section content 

17_SESSION_LOG_SECTION_RE = re.compile( 

18 r"^## Session Log\s*\n+(.*?)(?:\n##|\n---|\Z)", re.MULTILINE | re.DOTALL 

19) 

20# Regex to extract backtick-quoted /ll:* command names from session log entries 

21_COMMAND_RE = re.compile(r"`(/[\w:-]+)`") 

22 

23 

24def parse_session_log(content: str) -> list[str]: 

25 """Extract distinct /ll:* command names from the ## Session Log section. 

26 

27 Returns commands in first-seen order, deduplicated (preserves insertion order). 

28 

29 Args: 

30 content: Full text of an issue markdown file. 

31 

32 Returns: 

33 List of distinct command names (e.g. ["/ll:refine-issue", "/ll:ready-issue"]). 

34 """ 

35 matches = list(_SESSION_LOG_SECTION_RE.finditer(content)) 

36 if not matches: 

37 return [] 

38 cmds = _COMMAND_RE.findall(matches[-1].group(1)) 

39 # Deduplicate while preserving insertion order 

40 return list(dict.fromkeys(cmds)) 

41 

42 

43def count_session_commands(content: str) -> dict[str, int]: 

44 """Count occurrences of each /ll:* command in the ## Session Log section. 

45 

46 Unlike parse_session_log(), this does NOT deduplicate — each entry is counted. 

47 

48 Args: 

49 content: Full text of an issue markdown file. 

50 

51 Returns: 

52 Mapping of command name to occurrence count (e.g. {"/ll:refine-issue": 3}). 

53 """ 

54 matches = list(_SESSION_LOG_SECTION_RE.finditer(content)) 

55 if not matches: 

56 return {} 

57 counts: dict[str, int] = {} 

58 for cmd in _COMMAND_RE.findall(matches[-1].group(1)): 

59 counts[cmd] = counts.get(cmd, 0) + 1 

60 return counts 

61 

62 

63def get_current_session_jsonl(cwd: Path | None = None) -> Path | None: 

64 """Resolve the active Claude Code session's JSONL file path. 

65 

66 Finds the most recently modified .jsonl file in the project's 

67 Claude Code session directory, excluding agent session files. 

68 

69 Args: 

70 cwd: Working directory to map. If None, uses current directory. 

71 

72 Returns: 

73 Path to the most recent JSONL file, or None if not found. 

74 """ 

75 project_folder = get_project_folder(cwd) 

76 if project_folder is None: 

77 return None 

78 

79 jsonl_files = [f for f in project_folder.glob("*.jsonl") if not f.name.startswith("agent-")] 

80 if not jsonl_files: 

81 return None 

82 

83 return max(jsonl_files, key=lambda f: f.stat().st_mtime) 

84 

85 

86def append_session_log_entry( 

87 issue_path: Path, 

88 command: str, 

89 session_jsonl: Path | None = None, 

90) -> bool: 

91 """Append a session log entry to an issue file. 

92 

93 Creates or appends to the ``## Session Log`` section with command name, 

94 ISO timestamp, and absolute path to the session JSONL file. 

95 

96 Args: 

97 issue_path: Path to the issue markdown file. 

98 command: Command name (e.g., ``/ll:manage-issue``). 

99 session_jsonl: Path to session JSONL file. If None, auto-detected. 

100 

101 Returns: 

102 True if entry was appended, False if session could not be resolved. 

103 """ 

104 if session_jsonl is None: 

105 session_jsonl = get_current_session_jsonl() 

106 if session_jsonl is None: 

107 return False 

108 

109 timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") 

110 entry = f"- `{command}` - {timestamp} - `{session_jsonl}`" 

111 

112 content = issue_path.read_text() 

113 

114 if "## Session Log" in content: 

115 # Insert entry after the last ## Session Log header (real section, not a fake in code block) 

116 idx = content.rfind("## Session Log\n") 

117 insert_pos = idx + len("## Session Log\n") 

118 content = content[:insert_pos] + entry + "\n" + content[insert_pos:] 

119 else: 

120 # Add new section before --- Status footer if present, else at end 

121 if "\n---\n\n## Status" in content: 

122 content = content.replace( 

123 "\n---\n\n## Status", 

124 f"\n## Session Log\n{entry}\n\n---\n\n## Status", 

125 ) 

126 else: 

127 content += f"\n\n## Session Log\n{entry}\n" 

128 

129 atomic_write(issue_path, content) 

130 return True