1"""Session log linking for issue files.

2

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

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

5"""

6

7 from __future__ import annotations

8

9 import re

10 from datetime import UTC, datetime

11 from pathlib import Path

12

13 from little_loops.file_utils import atomic_write

14 from 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

24 def 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

43 def 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

63 def 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

86 def 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 the session JSONL filename (the session id; not the

95 absolute path, which is machine-specific).

96

97 Args:

98 issue_path: Path to the issue markdown file.

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

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

101

102 Returns:

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

104 """

105 if session_jsonl is None:

106 session_jsonl = get_current_session_jsonl()

107 if session_jsonl is None:

108 return False

109

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

111 # Record only the session JSONL filename (the session id), not the absolute

112 # path: the home-directory prefix is machine-specific and leaks the user's

113 # local layout into committed issue files. The session id maps back to a

114 # full path via .ll/history.db when needed.

115 entry = f"- `{command}` - {timestamp} - `{session_jsonl.name}`"

116

117 content = issue_path.read_text()

118

119 if "## Session Log" in content:

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

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

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

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

124 else:

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

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

127 content = content.replace(

128 "\n---\n\n## Status",

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

130 )

131 else:

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

133

134 atomic_write(issue_path, content)

135 return True