Coverage for little_loops / cli / logs.py: 11%

1134 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

1"""ll-logs: Discover, extract, analyze log entries from ~/.claude/projects/.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import json 

7import re 

8import shutil 

9import sqlite3 

10import sys 

11import time 

12from collections import Counter, defaultdict 

13from dataclasses import dataclass, field 

14from datetime import UTC, datetime, timedelta 

15from pathlib import Path 

16 

17from little_loops.analytics.association import compute_lift, compute_pmi 

18from little_loops.cli.loop.info import ( # private symbol: cross-module coupling; verify signature on upgrade 

19 _format_history_event, 

20) 

21from little_loops.cli.output import configure_output, print_json, table, use_color_enabled 

22from little_loops.cli_args import add_json_arg 

23from little_loops.config import BRConfig 

24from little_loops.logger import Logger 

25from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context, resolve_history_db 

26from little_loops.user_messages import get_project_folder 

27 

28_COMMAND_NAME_RE = re.compile(r"<command-name>/ll:") 

29BRIDGE_MARKER = "Bridged from `commands/" 

30 

31 

32def _is_ll_relevant(record: dict) -> bool: 

33 """Return True if a JSONL record indicates ll activity. 

34 

35 Detects three signal types: 

36 (a) queue-operation enqueue with /ll: content 

37 (b) user records with <command-name>/ll: pattern in message content 

38 """ 

39 record_type = record.get("type") 

40 

41 # (a) queue-operation: only enqueue records with /ll: content signal ll activity 

42 if record_type == "queue-operation": 

43 return ( 

44 record.get("operation") == "enqueue" 

45 and isinstance(record.get("content"), str) 

46 and record["content"].startswith("/ll:") 

47 ) 

48 

49 # (b) user records: check message content for <command-name>/ll: pattern 

50 if record_type == "user": 

51 message = record.get("message", {}) 

52 if not isinstance(message, dict): 

53 return False 

54 content = message.get("content") 

55 if isinstance(content, str): 

56 return bool(_COMMAND_NAME_RE.search(content)) 

57 if isinstance(content, list): 

58 for block in content: 

59 if isinstance(block, dict): 

60 text = block.get("text", "") 

61 if isinstance(text, str) and _COMMAND_NAME_RE.search(text): 

62 return True 

63 

64 # (c) assistant records: check for Bash tool-use invoking an ll- command 

65 if record_type == "assistant": 

66 message = record.get("message", {}) 

67 content = message.get("content", []) 

68 if isinstance(content, list): 

69 for block in content: 

70 if ( 

71 isinstance(block, dict) 

72 and block.get("type") == "tool_use" 

73 and block.get("name") == "Bash" 

74 ): 

75 cmd = block.get("input", {}).get("command", "") 

76 if re.search(r"\bll-\w+", cmd): 

77 return True 

78 

79 return False 

80 

81 

82def _has_ll_activity(project_folder: Path) -> bool: 

83 """Return True if any non-agent JSONL file in project_folder has ll activity.""" 

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

85 

86 for jsonl_file in jsonl_files: 

87 try: 

88 with open(jsonl_file, encoding="utf-8") as f: 

89 for line in f: 

90 line = line.strip() 

91 if not line: 

92 continue 

93 try: 

94 record = json.loads(line) 

95 except json.JSONDecodeError: 

96 continue 

97 if _is_ll_relevant(record): 

98 return True 

99 except OSError: 

100 continue 

101 

102 return False 

103 

104 

105def _extract_cwd_from_project(project_dir: Path) -> Path | None: 

106 """Extract the project working directory from cwd fields in JSONL records. 

107 

108 Claude Code encodes project paths by replacing '/' with '-', which is 

109 lossy for paths containing hyphens. Reading the cwd field from JSONL 

110 records gives the canonical path without ambiguity. 

111 """ 

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

113 for jsonl_file in jsonl_files: 

114 try: 

115 with open(jsonl_file, encoding="utf-8") as f: 

116 for line in f: 

117 line = line.strip() 

118 if not line: 

119 continue 

120 try: 

121 record = json.loads(line) 

122 cwd = record.get("cwd") 

123 if isinstance(cwd, str) and cwd: 

124 return Path(cwd) 

125 except json.JSONDecodeError: 

126 continue 

127 except OSError: 

128 continue 

129 return None 

130 

131 

132def discover_all_projects( 

133 logger: Logger, *, host: str | None = None, existing_only: bool = False 

134) -> list[Path]: 

135 """Discover all projects with ll activity for the given host. 

136 

137 Iterates the host's session directory (e.g. ``~/.claude/projects/`` for 

138 Claude Code, ``~/.codex/projects/`` for Codex), resolves each directory 

139 name back to an absolute path, checks for ll-relevant JSONL records, and 

140 returns a sorted list of paths that exist on disk. 

141 

142 Args: 

143 logger: Logger instance for diagnostics. 

144 host: Host identifier. If None, auto-detects from ``LL_HOOK_HOST`` 

145 env var (default ``"claude-code"``). 

146 existing_only: When True, silently skip paths that don't exist on disk 

147 (no debug message). Useful for scripted consumers that want clean 

148 stderr as well as clean stdout. 

149 

150 Returns: 

151 Sorted list of decoded absolute paths for projects with ll activity. 

152 """ 

153 import os as _os 

154 

155 if host is None: 

156 host = _os.environ.get("LL_HOOK_HOST", "claude-code") 

157 

158 if host == "claude-code": 

159 projects_root = Path.home() / ".claude" / "projects" 

160 elif host == "codex": 

161 projects_root = Path.home() / ".codex" / "projects" 

162 elif host == "opencode": 

163 projects_root = Path.home() / ".opencode" / "projects" 

164 elif host == "pi": 

165 projects_root = Path.home() / ".pi" / "projects" 

166 else: 

167 return [] 

168 

169 if not projects_root.exists(): 

170 return [] 

171 

172 results: list[Path] = [] 

173 

174 for project_dir in projects_root.iterdir(): 

175 if not project_dir.is_dir(): 

176 continue 

177 

178 # Prefer cwd field from JSONL records; fall back to lossy decode. 

179 # The lossy decode ("-Users-foo-bar" -> "/Users/foo/bar") breaks for 

180 # paths that contain hyphens (e.g. "little-loops", macOS per-user 

181 # temp dirs like /tmp/claude-501/). 

182 decoded_path = _extract_cwd_from_project(project_dir) or Path( 

183 project_dir.name.replace("-", "/") 

184 ) 

185 

186 if not decoded_path.exists(): 

187 if not existing_only: 

188 logger.debug(f"Decoded path does not exist: {decoded_path}") 

189 continue 

190 

191 if _has_ll_activity(project_dir): 

192 results.append(decoded_path) 

193 

194 return sorted(results) 

195 

196 

197def _cmd_matches(record: dict, cmd: str) -> bool: 

198 """Return True if record contains a Bash tool-use whose command includes cmd.""" 

199 message = record.get("message", {}) 

200 content = message.get("content", []) 

201 if isinstance(content, list): 

202 for block in content: 

203 if ( 

204 isinstance(block, dict) 

205 and block.get("type") == "tool_use" 

206 and block.get("name") == "Bash" 

207 ): 

208 command = block.get("input", {}).get("command", "") 

209 if cmd in command: 

210 return True 

211 return False 

212 

213 

214_LL_BASH_RE = re.compile(r"\b(ll-[\w-]+)") 

215_QUEUE_SKILL_RE = re.compile(r"^/ll:(\S+)") 

216_COMMAND_NAME_SKILL_RE = re.compile(r"<command-name>/ll:(\S+)") 

217_CONTENT_FREE_RE = re.compile(r"^exit\s+code\s+\d+$", re.IGNORECASE) 

218 

219 

220@dataclass 

221class InvocationEvent: 

222 """A single ll invocation event extracted from a JSONL record.""" 

223 

224 tool_name: str 

225 timestamp: str 

226 session_id: str 

227 

228 

229def _extract_ll_event_streams( 

230 project_folder: Path, *, cutoff: datetime | None = None 

231) -> dict[str, list[InvocationEvent]]: 

232 """Extract per-session ordered ll-invocation event streams from JSONL files. 

233 

234 Walks JSONL files (skipping ``agent-*``), filters to records with ll activity, 

235 extracts the tool/skill name, and returns a dict mapping ``sessionId`` to a 

236 timestamp-sorted list of ``InvocationEvent``. 

237 

238 Args: 

239 project_folder: Path to the claude project session directory. 

240 cutoff: If set, exclude records with timestamps before this datetime. 

241 

242 Returns: 

243 Dict of ``{session_id: [InvocationEvent, ...]}`` with events sorted by timestamp. 

244 """ 

245 events_by_session: dict[str, list[InvocationEvent]] = {} 

246 

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

248 if not jsonl_files: 

249 return events_by_session 

250 

251 all_events: list[InvocationEvent] = [] 

252 

253 for jsonl_file in jsonl_files: 

254 try: 

255 with open(jsonl_file, encoding="utf-8") as f: 

256 for line in f: 

257 line = line.strip() 

258 if not line: 

259 continue 

260 try: 

261 record = json.loads(line) 

262 except json.JSONDecodeError: 

263 continue 

264 

265 tool_name = _extract_tool_name(record) 

266 if tool_name is None: 

267 continue 

268 

269 ts = record.get("timestamp", "") 

270 sid = record.get("sessionId", "") 

271 

272 evt = InvocationEvent(tool_name=tool_name, timestamp=ts, session_id=sid) 

273 all_events.append(evt) 

274 except OSError: 

275 continue 

276 

277 # Apply wall-clock cutoff filter 

278 if cutoff is not None: 

279 all_events = [e for e in all_events if _parse_iso_timestamp(e.timestamp) >= cutoff] 

280 

281 # Bucket by session and sort 

282 for evt in all_events: 

283 events_by_session.setdefault(evt.session_id, []).append(evt) 

284 

285 for session_id in events_by_session: 

286 events_by_session[session_id].sort(key=lambda e: e.timestamp) 

287 

288 return events_by_session 

289 

290 

291@dataclass 

292class _InvocationSignal: 

293 """Raw ll invocation signal extracted from a JSONL record. 

294 

295 Shared by ``_extract_tool_name`` and ``_extract_eval_invocation`` — single 

296 source of truth for the three-signal detection logic. 

297 """ 

298 

299 tool_name: str # matched skill/tool name, e.g. "scan-codebase" or "ll-issues" 

300 runner: str # signal source: "queue-operation" | "user" | "bash" 

301 input_context: str # raw matched text (full cmd for bash; user/queue text otherwise) 

302 

303 

304def _detect_ll_signal(record: dict) -> _InvocationSignal | None: 

305 """Extract the ll invocation signal from a JSONL record. 

306 

307 Detects three signal types: 

308 (a) queue-operation enqueue with ``/ll:<name>`` → tool_name=name, runner=queue-operation 

309 (b) user records with ``<command-name>/ll:<name>`` → tool_name=name, runner=user 

310 (c) assistant Bash tool-use invoking ``ll-<tool>`` → tool_name=match, runner=bash 

311 

312 ``input_context`` holds the raw matched text used by eval-export consumers. 

313 Returns ``None`` for records carrying no ll invocation signal. 

314 """ 

315 record_type = record.get("type") 

316 

317 # (a) queue-operation enqueue 

318 if record_type == "queue-operation" and record.get("operation") == "enqueue": 

319 content = record.get("content", "") 

320 if isinstance(content, str) and content.startswith("/ll:"): 

321 m = _QUEUE_SKILL_RE.match(content) 

322 if m: 

323 return _InvocationSignal(m.group(1), "queue-operation", content) 

324 

325 # (b) user records with <command-name>/ll: pattern 

326 if record_type == "user": 

327 message = record.get("message", {}) 

328 if not isinstance(message, dict): 

329 return None 

330 content = message.get("content") 

331 text = "" 

332 if isinstance(content, str): 

333 text = content 

334 elif isinstance(content, list): 

335 for block in content: 

336 if isinstance(block, dict): 

337 text = block.get("text", "") 

338 if text: 

339 break 

340 if text: 

341 m = _COMMAND_NAME_SKILL_RE.search(text) 

342 if m: 

343 name = m.group(1) 

344 if name.endswith("</command-name>"): 

345 name = name[: -len("</command-name>")] 

346 return _InvocationSignal(name, "user", text) 

347 

348 # (c) assistant Bash tool-use invoking ll-<tool> 

349 if record_type == "assistant": 

350 message = record.get("message", {}) 

351 content = message.get("content", []) 

352 if isinstance(content, list): 

353 for block in content: 

354 if ( 

355 isinstance(block, dict) 

356 and block.get("type") == "tool_use" 

357 and block.get("name") == "Bash" 

358 ): 

359 cmd = block.get("input", {}).get("command", "") 

360 m = _LL_BASH_RE.search(cmd) 

361 if m: 

362 return _InvocationSignal(m.group(1), "bash", cmd) 

363 

364 return None 

365 

366 

367def _extract_tool_name(record: dict) -> str | None: 

368 """Extract the ll tool/skill name from a JSONL record.""" 

369 sig = _detect_ll_signal(record) 

370 return sig.tool_name if sig else None 

371 

372 

373def _parse_iso_timestamp(ts: str) -> datetime: 

374 """Parse an ISO 8601 timestamp string to a timezone-aware datetime. 

375 

376 Handles both ``Z``-suffixed and ``+00:00`` offset formats. Returns 

377 ``datetime.min`` with UTC tzinfo for unparseable input. 

378 """ 

379 if not ts: 

380 return datetime.min.replace(tzinfo=UTC) 

381 try: 

382 # Handle Z suffix 

383 if ts.endswith("Z"): 

384 ts = ts[:-1] + "+00:00" 

385 dt = datetime.fromisoformat(ts) 

386 if dt.tzinfo is None: 

387 dt = dt.replace(tzinfo=UTC) 

388 return dt 

389 except (ValueError, TypeError): 

390 return datetime.min.replace(tzinfo=UTC) 

391 

392 

393@dataclass 

394class Edge: 

395 """A transition edge within an n-gram chain.""" 

396 

397 from_: str 

398 to: str 

399 freq: float 

400 pmi: float | None = None 

401 lift: float | None = None 

402 

403 

404@dataclass 

405class ChainResult: 

406 """An n-gram chain with occurrence count and per-edge transition frequencies.""" 

407 

408 chain: list[str] 

409 count: int 

410 edges: list[Edge] 

411 pmi: float | None = None 

412 lift: float | None = None 

413 

414 def to_dict(self) -> dict: 

415 edges_out = [] 

416 for e in self.edges: 

417 ed: dict = {"from": e.from_, "to": e.to, "freq": e.freq} 

418 if e.pmi is not None: 

419 ed["pmi"] = e.pmi 

420 if e.lift is not None: 

421 ed["lift"] = e.lift 

422 edges_out.append(ed) 

423 result: dict = {"chain": self.chain, "count": self.count, "edges": edges_out} 

424 if self.pmi is not None: 

425 result["pmi"] = self.pmi 

426 if self.lift is not None: 

427 result["lift"] = self.lift 

428 return result 

429 

430 

431def _count_ngrams( 

432 events_by_session: dict[str, list[InvocationEvent]], 

433 min_len: int = 2, 

434) -> tuple[Counter, Counter]: 

435 """Count n-grams and unigrams across per-session event streams. 

436 

437 Args: 

438 events_by_session: Per-session ordered event streams. 

439 min_len: Minimum n-gram length (window size). 

440 

441 Returns: 

442 Tuple of (ngram_counter, unigram_counter). 

443 ngram_counter maps ``(tool_1, tool_2, ...)`` tuples to occurrence counts. 

444 unigram_counter maps individual tool names to occurrence counts. 

445 """ 

446 counter: Counter = Counter() 

447 unigram_counter: Counter = Counter() 

448 for events in events_by_session.values(): 

449 names = [e.tool_name for e in events] 

450 for name in names: 

451 unigram_counter[name] += 1 

452 for n in range(min_len, len(names) + 1): 

453 for i in range(len(names) - n + 1): 

454 ngram = tuple(names[i : i + n]) 

455 counter[ngram] += 1 

456 return counter, unigram_counter 

457 

458 

459def _build_chain_results( 

460 counter: Counter, 

461 unigram_counter: Counter | None = None, 

462 min_count: int = 1, 

463 top: int | None = None, 

464) -> list[ChainResult]: 

465 """Build ranked ``ChainResult`` list from n-gram counter. 

466 

467 Args: 

468 counter: n-gram counter from ``_count_ngrams``. 

469 unigram_counter: Unigram counter from ``_count_ngrams``. When provided, 

470 PMI and lift scores are attached to each edge and chain result. 

471 min_count: Minimum occurrence count to include. 

472 top: If set, limit to top N chains by frequency. 

473 

474 Returns: 

475 List of ``ChainResult`` sorted by count descending. 

476 """ 

477 all_transitions: Counter = Counter() 

478 out_degree: Counter = Counter() 

479 for ngram_key, count in counter.items(): 

480 for i in range(len(ngram_key) - 1): 

481 pair = (ngram_key[i], ngram_key[i + 1]) 

482 all_transitions[pair] += count 

483 out_degree[ngram_key[i]] += count 

484 

485 total_unigrams = sum(unigram_counter.values()) if unigram_counter else 0 

486 

487 results: list[ChainResult] = [] 

488 for ngram, count in counter.most_common(): 

489 if count < min_count: 

490 continue 

491 edges = _compute_edges( 

492 ngram, all_transitions, out_degree, unigram_counter, total_unigrams, counter 

493 ) 

494 

495 chain_pmi: float | None = None 

496 chain_lift: float | None = None 

497 if edges and all(e.lift is not None for e in edges): 

498 chain_lift = min(e.lift for e in edges if e.lift is not None) # type: ignore[type-var] 

499 chain_pmi = min(e.pmi for e in edges if e.pmi is not None) # type: ignore[type-var] 

500 

501 results.append( 

502 ChainResult(chain=list(ngram), count=count, edges=edges, pmi=chain_pmi, lift=chain_lift) 

503 ) 

504 

505 if top is not None: 

506 results = results[:top] 

507 

508 return results 

509 

510 

511def _compute_edges( 

512 ngram: tuple[str, ...], 

513 all_transitions: Counter, 

514 out_degree: Counter, 

515 unigram_counter: Counter | None = None, 

516 total_unigrams: int = 0, 

517 ngram_counter: Counter | None = None, 

518) -> list[Edge]: 

519 """Compute per-edge transition frequencies and PMI/lift for an n-gram chain. 

520 

521 For each adjacent pair ``(from, to)`` in the chain, computes the frequency 

522 as the proportion of times ``from → to`` appears out of all transitions 

523 originating from ``from`` across the entire corpus. When ``unigram_counter`` 

524 and ``ngram_counter`` are provided, also computes PMI and lift for each edge 

525 using the raw bigram count from ``ngram_counter`` (not the overcounted 

526 ``all_transitions`` which accumulates across all n-gram lengths). 

527 """ 

528 edges: list[Edge] = [] 

529 for i in range(len(ngram) - 1): 

530 from_ = ngram[i] 

531 to = ngram[i + 1] 

532 pair = (from_, to) 

533 total_out = out_degree.get(from_, 0) 

534 freq = all_transitions.get(pair, 0) / total_out if total_out > 0 else 0.0 

535 

536 edge_pmi: float | None = None 

537 edge_lift: float | None = None 

538 if unigram_counter and total_unigrams > 0 and ngram_counter is not None: 

539 # Use the raw bigram count (not all_transitions which overcounts from longer n-grams) 

540 count_ab = ngram_counter.get(pair, 0) 

541 count_a = unigram_counter.get(from_, 0) 

542 count_b = unigram_counter.get(to, 0) 

543 if count_ab > 0 and count_a > 0 and count_b > 0: 

544 edge_lift = round(compute_lift(count_ab, count_a, count_b, total_unigrams), 4) 

545 edge_pmi = round(compute_pmi(count_ab, count_a, count_b, total_unigrams), 4) 

546 

547 edges.append(Edge(from_=from_, to=to, freq=round(freq, 4), pmi=edge_pmi, lift=edge_lift)) 

548 

549 return edges 

550 

551 

552def _cmd_sequences(args: argparse.Namespace, logger: Logger) -> int: 

553 """Extract n-grams of ll invocations from JSONL log files.""" 

554 if args.project: 

555 cwd_path: Path = args.project 

556 project_folder = get_project_folder(cwd_path) 

557 if project_folder is None: 

558 logger.error(f"No session project folder found for: {cwd_path}") 

559 return 1 

560 project_items = [(cwd_path, project_folder)] 

561 else: 

562 decoded_paths = discover_all_projects(logger) 

563 project_items = [] 

564 for decoded_path in decoded_paths: 

565 folder = get_project_folder(decoded_path) 

566 if folder is not None: 

567 project_items.append((decoded_path, folder)) 

568 

569 cutoff = ( 

570 datetime.now(UTC) - timedelta(days=args.window_days) 

571 if args.window_days is not None 

572 else None 

573 ) 

574 

575 # Aggregate events across all projects 

576 all_events: dict[str, list[InvocationEvent]] = {} 

577 for _cwd_path, project_folder in project_items: 

578 events = _extract_ll_event_streams(project_folder, cutoff=cutoff) 

579 for sid, evt_list in events.items(): 

580 all_events.setdefault(sid, []).extend(evt_list) 

581 

582 # Sort each session's events by timestamp 

583 for sid in all_events: 

584 all_events[sid].sort(key=lambda e: e.timestamp) 

585 

586 # Count n-grams 

587 counter, unigram_counter = _count_ngrams(all_events, min_len=args.min_len) 

588 results = _build_chain_results(counter, unigram_counter, min_count=args.min_count, top=args.top) 

589 

590 if args.json: 

591 print_json([r.to_dict() for r in results]) 

592 else: 

593 if not results: 

594 print("No sequences found.") 

595 return 0 

596 

597 # Print ranked table 

598 for rank, r in enumerate(results, 1): 

599 chain_str = " → ".join(r.chain) 

600 print(f"{rank}. [{r.count}] {chain_str}") 

601 for edge in r.edges: 

602 print(f" {edge.from_}{edge.to}: {edge.freq:.4f}") 

603 

604 return 0 

605 

606 

607def generate_index(logs_dir: Path) -> None: 

608 """Generate logs/index.md summarising extracted projects.""" 

609 rows = [] 

610 

611 if logs_dir.exists(): 

612 for subdir in sorted(logs_dir.iterdir()): 

613 if not subdir.is_dir(): 

614 continue 

615 

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

617 if not jsonl_files: 

618 continue 

619 

620 timestamps: list[str] = [] 

621 for jsonl_file in jsonl_files: 

622 try: 

623 with open(jsonl_file, encoding="utf-8") as f: 

624 for line in f: 

625 line = line.strip() 

626 if not line: 

627 continue 

628 try: 

629 record = json.loads(line) 

630 except json.JSONDecodeError: 

631 continue 

632 ts = record.get("timestamp") 

633 if ts: 

634 timestamps.append(ts) 

635 except OSError: 

636 continue 

637 

638 if timestamps: 

639 earliest = min(timestamps)[:10] 

640 latest = max(timestamps)[:10] 

641 date_range = f"{earliest}{latest}" if earliest != latest else earliest 

642 else: 

643 date_range = "" 

644 

645 rows.append((subdir.name, len(jsonl_files), date_range)) 

646 

647 lines = ["# Logs Index", ""] 

648 if rows: 

649 lines.append("| Project | Sessions | Date Range |") 

650 lines.append("|---------|----------|------------|") 

651 for name, count, date_range in rows: 

652 lines.append(f"| {name} | {count} | {date_range} |") 

653 else: 

654 lines.append("*No projects extracted yet.*") 

655 lines.append("") 

656 

657 logs_dir.mkdir(parents=True, exist_ok=True) 

658 (logs_dir / "index.md").write_text("\n".join(lines), encoding="utf-8") 

659 

660 

661def _cmd_extract(args: argparse.Namespace, logger: Logger) -> int: 

662 """Extract ll-relevant JSONL records to logs/<slug>/<session-id>.jsonl.""" 

663 if args.project: 

664 cwd_path: Path = args.project 

665 project_folder = get_project_folder(cwd_path) 

666 if project_folder is None: 

667 logger.error(f"No session project folder found for: {cwd_path}") 

668 return 1 

669 project_items = [(cwd_path, project_folder)] 

670 else: 

671 decoded_paths = discover_all_projects(logger) 

672 project_items = [] 

673 for decoded_path in decoded_paths: 

674 folder = get_project_folder(decoded_path) 

675 if folder is not None: 

676 project_items.append((decoded_path, folder)) 

677 

678 for cwd_path, project_folder in project_items: 

679 slug = cwd_path.resolve().name 

680 buckets: dict[str, list[dict]] = {} 

681 

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

683 for jsonl_file in jsonl_files: 

684 try: 

685 with open(jsonl_file, encoding="utf-8") as f: 

686 for line in f: 

687 line = line.strip() 

688 if not line: 

689 continue 

690 try: 

691 record = json.loads(line) 

692 except json.JSONDecodeError: 

693 continue 

694 if _is_ll_relevant(record): 

695 session_id = record.get("sessionId", "") 

696 buckets.setdefault(session_id, []).append(record) 

697 except OSError: 

698 continue 

699 

700 if args.cmd: 

701 filtered: dict[str, list[dict]] = {} 

702 for session_id, records in buckets.items(): 

703 matching = [r for r in records if _cmd_matches(r, args.cmd)] 

704 if matching: 

705 filtered[session_id] = matching 

706 buckets = filtered 

707 

708 out_base = Path.cwd() / "logs" / slug 

709 for session_id, records in buckets.items(): 

710 out_file = out_base / f"{session_id}.jsonl" 

711 out_file.parent.mkdir(parents=True, exist_ok=True) 

712 with open(out_file, "w", encoding="utf-8") as f: 

713 for record in records: 

714 f.write(json.dumps(record) + "\n") 

715 

716 generate_index(Path.cwd() / "logs") 

717 return 0 

718 

719 

720def _cmd_tail(args: argparse.Namespace, loops_dir: Path) -> int: 

721 """Stream live events from an active loop session.""" 

722 events_file = loops_dir / ".running" / f"{args.loop}.events.jsonl" 

723 

724 if not events_file.exists(): 

725 print(f"No active session for loop '{args.loop}'", file=sys.stderr) 

726 return 1 

727 

728 width = shutil.get_terminal_size().columns 

729 try: 

730 with open(events_file, encoding="utf-8") as f: 

731 f.seek(0, 2) 

732 while True: 

733 line = f.readline() 

734 if line: 

735 line = line.strip() 

736 if line: 

737 try: 

738 event = json.loads(line) 

739 except json.JSONDecodeError: 

740 continue 

741 formatted = _format_history_event(event, verbose=False, width=width) 

742 if formatted is not None: 

743 print(formatted) 

744 else: 

745 time.sleep(0.1) 

746 except KeyboardInterrupt: 

747 return 0 

748 

749 return 0 

750 

751 

752_CORRECTION_WINDOW_SEC = 30 

753 

754 

755def _aggregate_skill_stats( 

756 db_path: Path, 

757 *, 

758 cutoff: datetime | None = None, 

759) -> dict[str, dict[str, int]] | None: 

760 """Aggregate per-skill invocation and correction counts from history.db. 

761 

762 Returns None when the database is absent, or an empty dict when the database 

763 has no skill_events rows. Corrections are attributed to the most recent skill 

764 event in the same session within _CORRECTION_WINDOW_SEC seconds. 

765 """ 

766 if not db_path.exists(): 

767 return None 

768 

769 conn = sqlite3.connect(str(db_path)) 

770 conn.row_factory = sqlite3.Row 

771 try: 

772 try: 

773 skill_rows = conn.execute( 

774 "SELECT ts, session_id, skill_name FROM skill_events ORDER BY ts" 

775 ).fetchall() 

776 except sqlite3.OperationalError: 

777 return None 

778 

779 if not skill_rows: 

780 return {} 

781 

782 if cutoff is not None: 

783 skill_rows = [r for r in skill_rows if _parse_iso_timestamp(r["ts"] or "") >= cutoff] 

784 

785 stats: dict[str, dict[str, int]] = defaultdict(lambda: {"invocations": 0, "corrections": 0}) 

786 for row in skill_rows: 

787 stats[row["skill_name"] or "unknown"]["invocations"] += 1 

788 

789 session_skills: dict[str, list[tuple[str, str]]] = defaultdict(list) 

790 for row in skill_rows: 

791 sid = row["session_id"] or "" 

792 session_skills[sid].append((row["ts"] or "", row["skill_name"] or "unknown")) 

793 

794 try: 

795 corr_rows = conn.execute( 

796 "SELECT ts, session_id FROM user_corrections ORDER BY ts" 

797 ).fetchall() 

798 except sqlite3.OperationalError: 

799 corr_rows = [] 

800 

801 for corr in corr_rows: 

802 c_ts = corr["ts"] or "" 

803 sid = corr["session_id"] or "" 

804 candidates = session_skills.get(sid, []) 

805 best_skill: str | None = None 

806 best_ts: str = "" 

807 for s_ts, s_name in candidates: 

808 if s_ts <= c_ts and s_ts >= best_ts: 

809 best_ts = s_ts 

810 best_skill = s_name 

811 if best_skill is not None: 

812 elapsed = ( 

813 _parse_iso_timestamp(c_ts) - _parse_iso_timestamp(best_ts) 

814 ).total_seconds() 

815 if 0 <= elapsed <= _CORRECTION_WINDOW_SEC: 

816 stats[best_skill]["corrections"] += 1 

817 

818 return dict(stats) 

819 finally: 

820 conn.close() 

821 

822 

823def _load_catalog_names(root_dir: Path) -> set[str]: 

824 """Load normalized skill/command names from skills/ and commands/ under root_dir. 

825 

826 Excludes bridge skills (containing BRIDGE_MARKER) and skills/commands with 

827 disable-model-invocation: true. Normalizes names by stripping the "ll-" prefix 

828 so catalog names match the skill_events.skill_name recording convention. 

829 """ 

830 import yaml 

831 

832 names: set[str] = set() 

833 

834 skills_dir = root_dir / "skills" 

835 if skills_dir.is_dir(): 

836 for skill_md in sorted(skills_dir.glob("*/SKILL.md")): 

837 try: 

838 text = skill_md.read_text() 

839 except OSError: 

840 continue 

841 if BRIDGE_MARKER in text: 

842 continue 

843 name: str = skill_md.parent.name 

844 if text.startswith("---"): 

845 end = text.find("---", 3) 

846 if end != -1: 

847 try: 

848 fm = yaml.safe_load(text[3:end]) or {} 

849 except yaml.YAMLError: 

850 fm = {} 

851 if isinstance(fm, dict): 

852 dmi = fm.get("disable-model-invocation") 

853 if (isinstance(dmi, bool) and dmi) or ( 

854 isinstance(dmi, str) and dmi.strip().lower() in ("true", "yes", "1") 

855 ): 

856 continue 

857 name = str(fm.get("name") or name) 

858 if name.startswith("ll-"): 

859 name = name[3:] 

860 if name: 

861 names.add(name) 

862 

863 commands_dir = root_dir / "commands" 

864 if commands_dir.is_dir(): 

865 for cmd_md in sorted(commands_dir.glob("*.md")): 

866 stem = cmd_md.stem 

867 try: 

868 text = cmd_md.read_text() 

869 except OSError: 

870 names.add(stem) 

871 continue 

872 if text.startswith("---"): 

873 end = text.find("---", 3) 

874 if end != -1: 

875 try: 

876 fm = yaml.safe_load(text[3:end]) or {} 

877 except yaml.YAMLError: 

878 fm = {} 

879 if isinstance(fm, dict): 

880 dmi = fm.get("disable-model-invocation") 

881 if (isinstance(dmi, bool) and dmi) or ( 

882 isinstance(dmi, str) and dmi.strip().lower() in ("true", "yes", "1") 

883 ): 

884 continue 

885 names.add(stem) 

886 

887 return names 

888 

889 

890def _cmd_dead_skills(args: argparse.Namespace, logger: Logger) -> int: 

891 """List catalog skills/commands never or rarely invoked within the window.""" 

892 if args.project: 

893 db_paths = [Path(args.project) / ".ll" / "history.db"] 

894 catalog_root = Path(args.project) 

895 else: 

896 decoded_paths = discover_all_projects(logger) 

897 db_paths = [p / ".ll" / "history.db" for p in decoded_paths] 

898 catalog_root = Path.cwd() 

899 

900 cutoff = ( 

901 datetime.now(UTC) - timedelta(days=args.window_days) 

902 if args.window_days is not None 

903 else None 

904 ) 

905 

906 merged: dict[str, int] = defaultdict(int) 

907 for db_path in db_paths: 

908 result = _aggregate_skill_stats(db_path, cutoff=cutoff) 

909 if result is None: 

910 continue 

911 for skill, counts in result.items(): 

912 merged[skill] += counts["invocations"] 

913 

914 catalog_names = _load_catalog_names(catalog_root) 

915 if not catalog_names: 

916 logger.warning( 

917 "No catalog skills found — run from an ll project root with skills/ directory." 

918 ) 

919 return 0 

920 

921 threshold = args.threshold 

922 rows = [] 

923 for name in sorted(catalog_names): 

924 count = merged.get(name, 0) 

925 if count == 0: 

926 rows.append({"skill": name, "invocations": 0, "tier": "never"}) 

927 elif count <= threshold: 

928 rows.append({"skill": name, "invocations": count, "tier": "rarely"}) 

929 

930 if args.json: 

931 print_json(rows) 

932 return 0 

933 

934 if not rows: 

935 print("No dead or rarely-invoked skills found.") 

936 return 0 

937 

938 headers = ["Skill", "Invocations", "Tier"] 

939 table_rows = [[str(r["skill"]), str(r["invocations"]), str(r["tier"])] for r in rows] 

940 print(table(headers, table_rows)) 

941 return 0 

942 

943 

944def _load_cli_allowlist(root: Path) -> frozenset[str]: 

945 """Return ll-* CLI names from [project.scripts] in scripts/pyproject.toml. 

946 

947 Returns an empty frozenset if the file cannot be read; the allowlist check 

948 is skipped when the set is empty so fallback behavior is open (no filtering). 

949 """ 

950 import tomllib 

951 

952 pyproject = root / "scripts" / "pyproject.toml" 

953 try: 

954 with open(pyproject, "rb") as f: 

955 data = tomllib.load(f) 

956 except (OSError, ValueError): 

957 return frozenset() 

958 scripts = data.get("project", {}).get("scripts", {}) 

959 return frozenset(k for k in scripts if k.startswith("ll-")) 

960 

961 

962def _is_content_free_error(error_text: str) -> bool: 

963 """Return True if error_text carries no signal beyond a bare exit code.""" 

964 return bool(_CONTENT_FREE_RE.match(error_text.strip())) 

965 

966 

967_STACK_FRAME_RE = re.compile(r'\s*File "[^"]+", line \d+[^\n]*') 

968_ABS_PATH_RE = re.compile(r"/(?:[^\s,;\"']+/)+[^\s,;\"']+") 

969_LINE_NUM_RE = re.compile(r"\bline \d+\b") 

970_LL_VERIFY_RE = re.compile(r"^ll-verify-\w+") 

971 

972 

973def _normalize_error_sig(text: str) -> str: 

974 """Strip volatile parts (paths, line numbers, stack frames) from error text. 

975 

976 Returns a stable string suitable as a cluster key. 

977 """ 

978 text = _STACK_FRAME_RE.sub("", text) 

979 text = _ABS_PATH_RE.sub("<path>", text) 

980 text = _LINE_NUM_RE.sub("line N", text) 

981 return re.sub(r"\s+", " ", text).strip()[:300] 

982 

983 

984def _extract_error_text(content: object) -> str: 

985 """Extract plain text from a tool_result content field (string or list of text blocks).""" 

986 if isinstance(content, str): 

987 return content 

988 if isinstance(content, list): 

989 parts: list[str] = [] 

990 for item in content: 

991 if isinstance(item, dict): 

992 text = item.get("text", "") 

993 if isinstance(text, str): 

994 parts.append(text) 

995 return "\n".join(parts) 

996 return "" 

997 

998 

999@dataclass 

1000class _FailureCluster: 

1001 """Aggregated failure cluster keyed on (cwd_path, tool_name, normalized_sig).""" 

1002 

1003 tool_name: str 

1004 normalized_sig: str 

1005 count: int 

1006 sample_error: str 

1007 session_ids: list[str] 

1008 cwd_path: Path = field(default_factory=lambda: Path(".")) 

1009 

1010 

1011def _cmd_scan_failures(args: argparse.Namespace, logger: Logger) -> int: 

1012 """Mine failed ll-* Bash calls from interactive session JSONL logs.""" 

1013 from little_loops.issue_lifecycle import FailureType, classify_failure 

1014 

1015 _cli_allowlist = _load_cli_allowlist(Path.cwd()) 

1016 

1017 if args.project: 

1018 cwd_path: Path = args.project 

1019 project_folder = get_project_folder(cwd_path) 

1020 if project_folder is None: 

1021 logger.error(f"No session project folder found for: {cwd_path}") 

1022 return 1 

1023 project_items = [(cwd_path, project_folder)] 

1024 else: 

1025 decoded_paths = discover_all_projects(logger) 

1026 project_items = [] 

1027 for decoded_path in decoded_paths: 

1028 folder = get_project_folder(decoded_path) 

1029 if folder is not None: 

1030 project_items.append((decoded_path, folder)) 

1031 

1032 # raw_clusters maps (cwd_path, tool_name, normalized_sig) -> (count, sample_error, session_ids, latest_ts) 

1033 raw_clusters: dict[tuple[Path, str, str], tuple[int, str, list[str], str]] = {} 

1034 

1035 for _cwd_path, project_folder in project_items: 

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

1037 

1038 for jsonl_file in jsonl_files: 

1039 # pending maps tool_use_id -> (ll_tool_name, timestamp) 

1040 pending: dict[str, tuple[str, str]] = {} 

1041 

1042 try: 

1043 with open(jsonl_file, encoding="utf-8") as f: 

1044 for line in f: 

1045 line = line.strip() 

1046 if not line: 

1047 continue 

1048 try: 

1049 record = json.loads(line) 

1050 except json.JSONDecodeError: 

1051 continue 

1052 

1053 record_type = record.get("type") 

1054 ts = record.get("timestamp", "") 

1055 session_id = record.get("sessionId", "") 

1056 

1057 if record_type == "assistant": 

1058 message = record.get("message", {}) 

1059 content = message.get("content", []) 

1060 if not isinstance(content, list): 

1061 continue 

1062 for block in content: 

1063 if not isinstance(block, dict): 

1064 continue 

1065 if block.get("type") != "tool_use" or block.get("name") != "Bash": 

1066 continue 

1067 cmd = block.get("input", {}).get("command", "") 

1068 m = _LL_BASH_RE.search(cmd) 

1069 if not m: 

1070 continue 

1071 tool_name = m.group(1) 

1072 # Skip tokens that are not real ll CLIs (e.g. ll-labs, ll-marketing) 

1073 if _cli_allowlist and tool_name not in _cli_allowlist: 

1074 continue 

1075 block_id = block.get("id", "") 

1076 if block_id: 

1077 pending[block_id] = (tool_name, ts) 

1078 

1079 elif record_type == "user": 

1080 message = record.get("message", {}) 

1081 content = message.get("content", []) 

1082 if not isinstance(content, list) or not content: 

1083 continue 

1084 for block in content: 

1085 if not isinstance(block, dict): 

1086 continue 

1087 if block.get("type") != "tool_result": 

1088 continue 

1089 tool_use_id = block.get("tool_use_id", "") 

1090 if tool_use_id not in pending: 

1091 continue 

1092 tool_name, _invoke_ts = pending.pop(tool_use_id) 

1093 

1094 # Skip ll-verify-* tools — exit 1 is expected gate behavior 

1095 if _LL_VERIFY_RE.match(tool_name): 

1096 continue 

1097 

1098 is_error_flag = block.get("is_error") is True 

1099 raw_content = block.get("content", "") 

1100 error_text = _extract_error_text(raw_content) 

1101 has_traceback = "Traceback (most recent call last)" in error_text 

1102 

1103 if not (is_error_flag or has_traceback): 

1104 continue 

1105 

1106 returncode = 1 if is_error_flag else 0 

1107 failure_type, _reason = classify_failure(error_text, returncode) 

1108 if failure_type in ( 

1109 FailureType.TRANSIENT, 

1110 FailureType.NON_RECOVERABLE, 

1111 ): 

1112 continue 

1113 

1114 normalized_sig = _normalize_error_sig(error_text) 

1115 key = (_cwd_path, tool_name, normalized_sig) 

1116 

1117 if key in raw_clusters: 

1118 cnt, sample, sids, _lts = raw_clusters[key] 

1119 if session_id not in sids: 

1120 sids.append(session_id) 

1121 raw_clusters[key] = (cnt + 1, sample, sids, ts) 

1122 else: 

1123 raw_clusters[key] = (1, error_text[:500], [session_id], ts) 

1124 except OSError: 

1125 continue 

1126 

1127 # Apply wall-clock cutoff filter 

1128 if args.window_days is not None: 

1129 cutoff = datetime.now(UTC) - timedelta(days=args.window_days) 

1130 raw_clusters = { 

1131 k: v for k, v in raw_clusters.items() if _parse_iso_timestamp(v[3]) >= cutoff 

1132 } 

1133 

1134 # Drop content-free clusters (bare "Exit code N" with no error body) 

1135 raw_clusters = {k: v for k, v in raw_clusters.items() if not _is_content_free_error(v[1])} 

1136 

1137 clusters: list[_FailureCluster] = sorted( 

1138 [ 

1139 _FailureCluster( 

1140 tool_name=k[1], 

1141 normalized_sig=k[2], 

1142 count=v[0], 

1143 sample_error=v[1], 

1144 session_ids=v[2], 

1145 cwd_path=k[0], 

1146 ) 

1147 for k, v in raw_clusters.items() 

1148 ], 

1149 key=lambda c: c.count, 

1150 reverse=True, 

1151 ) 

1152 

1153 if not clusters: 

1154 if not args.json: 

1155 print("No ll-* failures found.") 

1156 else: 

1157 print_json([]) 

1158 return 0 

1159 

1160 if args.capture: 

1161 capture_foreign = getattr(args, "capture_foreign", False) 

1162 return _capture_failure_clusters(clusters, logger, capture_foreign=capture_foreign) 

1163 

1164 if args.json: 

1165 print_json( 

1166 [ 

1167 { 

1168 "tool": c.tool_name, 

1169 "count": c.count, 

1170 "normalized_sig": c.normalized_sig, 

1171 "sample_error": c.sample_error, 

1172 "session_ids": c.session_ids, 

1173 } 

1174 for c in clusters 

1175 ] 

1176 ) 

1177 return 0 

1178 

1179 for c in clusters: 

1180 print(f"[{c.count}x] {c.tool_name}") 

1181 print(f" Sessions: {', '.join(c.session_ids[:5])}") 

1182 for sl in c.sample_error.splitlines()[:5]: 

1183 print(f" {sl}") 

1184 print() 

1185 

1186 return 0 

1187 

1188 

1189def _capture_failure_clusters( 

1190 clusters: list[_FailureCluster], logger: Logger, capture_foreign: bool = False 

1191) -> int: 

1192 """Create bug issue files for each distinct failure cluster (--capture mode).""" 

1193 from little_loops.issue_lifecycle import create_issue_from_failure 

1194 from little_loops.issue_parser import IssueInfo 

1195 

1196 config = BRConfig(Path.cwd()) 

1197 current_project = Path.cwd().resolve() 

1198 created = 0 

1199 skipped_foreign = 0 

1200 

1201 for c in clusters: 

1202 if not capture_foreign and c.cwd_path.resolve() != current_project: 

1203 skipped_foreign += 1 

1204 continue 

1205 stub_info = IssueInfo( 

1206 path=Path(f"cli/{c.tool_name}"), 

1207 issue_type="bugs", 

1208 priority="P1", 

1209 issue_id=c.tool_name, 

1210 title=f"Tool failure in {c.tool_name}", 

1211 ) 

1212 result = create_issue_from_failure(c.sample_error, stub_info, config, logger) 

1213 if result is not None: 

1214 logger.info(f"Created: {result.name}") 

1215 created += 1 

1216 

1217 if skipped_foreign: 

1218 logger.info( 

1219 f"Skipped {skipped_foreign} cluster(s) from other projects " 

1220 "(use --capture-foreign to include them)." 

1221 ) 

1222 logger.info(f"Captured {created} failure cluster(s) as bug issues.") 

1223 return 0 

1224 

1225 

1226def _cmd_stats(args: argparse.Namespace, logger: Logger) -> int: 

1227 """Aggregate skill invocation frequency and correction rate from history.db.""" 

1228 if args.project: 

1229 db_paths = [args.project / ".ll" / "history.db"] 

1230 else: 

1231 decoded_paths = discover_all_projects(logger) 

1232 db_paths = [p / ".ll" / "history.db" for p in decoded_paths] 

1233 

1234 cutoff = ( 

1235 datetime.now(UTC) - timedelta(days=args.window_days) 

1236 if args.window_days is not None 

1237 else None 

1238 ) 

1239 

1240 merged: dict[str, dict[str, int]] = defaultdict(lambda: {"invocations": 0, "corrections": 0}) 

1241 found_any_db = False 

1242 for db_path in db_paths: 

1243 result = _aggregate_skill_stats(db_path, cutoff=cutoff) 

1244 if result is None: 

1245 continue 

1246 found_any_db = True 

1247 for skill, counts in result.items(): 

1248 merged[skill]["invocations"] += counts["invocations"] 

1249 merged[skill]["corrections"] += counts["corrections"] 

1250 

1251 if not merged: 

1252 if not found_any_db: 

1253 logger.warning("No history.db found — run with an active ll project.") 

1254 else: 

1255 print("No skill events recorded yet.") 

1256 return 0 

1257 

1258 sort_key = getattr(args, "sort", "freq") 

1259 if sort_key == "corrections": 

1260 ranked = sorted(merged.items(), key=lambda kv: kv[1]["corrections"], reverse=True) 

1261 else: 

1262 ranked = sorted(merged.items(), key=lambda kv: kv[1]["invocations"], reverse=True) 

1263 

1264 if args.json: 

1265 rows_json = [ 

1266 { 

1267 "skill": skill, 

1268 "invocations": counts["invocations"], 

1269 "corrections": counts["corrections"], 

1270 "correction_rate": ( 

1271 round(counts["corrections"] / counts["invocations"], 4) 

1272 if counts["invocations"] > 0 

1273 else 0.0 

1274 ), 

1275 } 

1276 for skill, counts in ranked 

1277 ] 

1278 print_json(rows_json) 

1279 return 0 

1280 

1281 headers = ["Skill", "Invocations", "Corrections", "Corr%"] 

1282 rows = [] 

1283 for skill, counts in ranked: 

1284 inv = counts["invocations"] 

1285 corr = counts["corrections"] 

1286 corr_pct = f"{corr / inv * 100:.1f}%" if inv > 0 else "0.0%" 

1287 rows.append([skill, str(inv), str(corr), corr_pct]) 

1288 

1289 print(table(headers, rows)) 

1290 return 0 

1291 

1292 

1293def _resolve_session_log(session_ref: str, db_path: Path) -> Path | None: 

1294 """Resolve a session reference (session ID or JSONL path) to a JSONL file path. 

1295 

1296 Tries in order: 

1297 1. Direct file path if the ref resolves to an existing ``.jsonl`` file 

1298 2. DB lookup of ``session_id → jsonl_path`` in the sessions table 

1299 Returns None if unresolvable. 

1300 """ 

1301 candidate = Path(session_ref) 

1302 if candidate.suffix == ".jsonl" and candidate.exists(): 

1303 return candidate 

1304 

1305 if db_path.exists(): 

1306 conn = sqlite3.connect(str(db_path)) 

1307 conn.row_factory = sqlite3.Row 

1308 try: 

1309 row = conn.execute( 

1310 "SELECT jsonl_path FROM sessions WHERE session_id = ?", 

1311 (session_ref,), 

1312 ).fetchone() 

1313 if row and row["jsonl_path"]: 

1314 return Path(row["jsonl_path"]) 

1315 except sqlite3.OperationalError: 

1316 pass 

1317 finally: 

1318 conn.close() 

1319 

1320 return None 

1321 

1322 

1323def _events_from_jsonl(jsonl_path: Path) -> list[InvocationEvent]: 

1324 """Extract ll invocation events from a single JSONL file, sorted by timestamp.""" 

1325 events: list[InvocationEvent] = [] 

1326 try: 

1327 with open(jsonl_path, encoding="utf-8") as f: 

1328 for line in f: 

1329 line = line.strip() 

1330 if not line: 

1331 continue 

1332 try: 

1333 record = json.loads(line) 

1334 except json.JSONDecodeError: 

1335 continue 

1336 tool_name = _extract_tool_name(record) 

1337 if tool_name is None: 

1338 continue 

1339 ts = record.get("timestamp", "") 

1340 sid = record.get("sessionId", "") 

1341 events.append(InvocationEvent(tool_name=tool_name, timestamp=ts, session_id=sid)) 

1342 except OSError: 

1343 pass 

1344 events.sort(key=lambda e: e.timestamp) 

1345 return events 

1346 

1347 

1348@dataclass 

1349class SessionDiff: 

1350 """Behavioral diff between two ll sessions.""" 

1351 

1352 session_a: str 

1353 session_b: str 

1354 skills_added: list[str] 

1355 skills_removed: list[str] 

1356 count_deltas: dict[str, dict[str, int]] 

1357 sequence_diff: list[str] 

1358 

1359 def to_dict(self) -> dict: 

1360 return { 

1361 "session_a": self.session_a, 

1362 "session_b": self.session_b, 

1363 "skills_added": self.skills_added, 

1364 "skills_removed": self.skills_removed, 

1365 "count_deltas": self.count_deltas, 

1366 "sequence_diff": self.sequence_diff, 

1367 } 

1368 

1369 

1370def _compute_session_diff( 

1371 session_a: str, 

1372 events_a: list[InvocationEvent], 

1373 session_b: str, 

1374 events_b: list[InvocationEvent], 

1375) -> SessionDiff: 

1376 """Compute the behavioral diff between two session event streams.""" 

1377 import difflib 

1378 from collections import Counter as _Counter 

1379 

1380 names_a = [e.tool_name for e in events_a] 

1381 names_b = [e.tool_name for e in events_b] 

1382 

1383 set_a = set(names_a) 

1384 set_b = set(names_b) 

1385 skills_added = sorted(set_b - set_a) 

1386 skills_removed = sorted(set_a - set_b) 

1387 

1388 counter_a: Counter = _Counter(names_a) 

1389 counter_b: Counter = _Counter(names_b) 

1390 count_deltas: dict[str, dict[str, int]] = {} 

1391 for skill in sorted(set_a | set_b): 

1392 ca = counter_a.get(skill, 0) 

1393 cb = counter_b.get(skill, 0) 

1394 if ca != cb: 

1395 count_deltas[skill] = {"a": ca, "b": cb, "delta": cb - ca} 

1396 

1397 label_a = f"session_a ({session_a[:8]})" if len(session_a) > 8 else f"session_a ({session_a})" 

1398 label_b = f"session_b ({session_b[:8]})" if len(session_b) > 8 else f"session_b ({session_b})" 

1399 sequence_diff = list( 

1400 difflib.unified_diff(names_a, names_b, fromfile=label_a, tofile=label_b, lineterm="") 

1401 ) 

1402 

1403 return SessionDiff( 

1404 session_a=session_a, 

1405 session_b=session_b, 

1406 skills_added=skills_added, 

1407 skills_removed=skills_removed, 

1408 count_deltas=count_deltas, 

1409 sequence_diff=sequence_diff, 

1410 ) 

1411 

1412 

1413def _cmd_diff(args: argparse.Namespace, logger: Logger) -> int: 

1414 """Compare two sessions' ll-invocation behavior.""" 

1415 db_path = resolve_history_db() 

1416 

1417 path_a = _resolve_session_log(args.session_a, db_path) 

1418 if path_a is None: 

1419 logger.error(f"Cannot resolve session: {args.session_a}") 

1420 return 1 

1421 

1422 path_b = _resolve_session_log(args.session_b, db_path) 

1423 if path_b is None: 

1424 logger.error(f"Cannot resolve session: {args.session_b}") 

1425 return 1 

1426 

1427 events_a = _events_from_jsonl(path_a) 

1428 events_b = _events_from_jsonl(path_b) 

1429 

1430 diff = _compute_session_diff(args.session_a, events_a, args.session_b, events_b) 

1431 

1432 if args.json: 

1433 print_json(diff.to_dict()) 

1434 return 0 

1435 

1436 if ( 

1437 not diff.skills_added 

1438 and not diff.skills_removed 

1439 and not diff.count_deltas 

1440 and not diff.sequence_diff 

1441 ): 

1442 print("No behavioral differences found.") 

1443 return 0 

1444 

1445 if diff.skills_added: 

1446 print(f"Skills added ({len(diff.skills_added)}):") 

1447 for s in diff.skills_added: 

1448 print(f" + {s}") 

1449 

1450 if diff.skills_removed: 

1451 if diff.skills_added: 

1452 print() 

1453 print(f"Skills removed ({len(diff.skills_removed)}):") 

1454 for s in diff.skills_removed: 

1455 print(f" - {s}") 

1456 

1457 if diff.count_deltas: 

1458 print() 

1459 print("Invocation count changes:") 

1460 for skill, counts in sorted(diff.count_deltas.items()): 

1461 delta_str = f"+{counts['delta']}" if counts["delta"] > 0 else str(counts["delta"]) 

1462 print(f" {skill}: {counts['a']}{counts['b']} ({delta_str})") 

1463 

1464 if diff.sequence_diff: 

1465 print() 

1466 print("Sequence diff:") 

1467 for line in diff.sequence_diff: 

1468 print(f" {line}") 

1469 

1470 return 0 

1471 

1472 

1473_ISSUE_ID_RE = re.compile(r"\b[A-Z]+-\d+\b") 

1474 

1475 

1476@dataclass 

1477class _EvalInvocation: 

1478 """A reconstructed ll-harness invocation extracted from a JSONL record. 

1479 

1480 Carries the runner kind and raw (un-redacted) input-context text that the 

1481 EvalFixture export needs but that ``InvocationEvent`` discards. 

1482 """ 

1483 

1484 runner: str # "skill" | "cmd" 

1485 target: str # skill name (runner==skill) or full shell command (runner==cmd) 

1486 session_id: str 

1487 timestamp: str 

1488 input_context: str # raw user-message text; "" when none (e.g. Bash invocations) 

1489 

1490 

1491def _extract_eval_invocation(record: dict) -> _EvalInvocation | None: 

1492 """Reconstruct a single ll-harness invocation from a JSONL record. 

1493 

1494 Delegates signal detection to ``_detect_ll_signal`` and wraps the result 

1495 in an ``_EvalInvocation`` with ``session_id`` and ``timestamp`` from the 

1496 record. Runner mapping: queue-operation/user → "skill"; bash → "cmd" 

1497 (target becomes the full command; input_context is ""). 

1498 

1499 Returns None for records that carry no ll invocation signal. 

1500 """ 

1501 sig = _detect_ll_signal(record) 

1502 if sig is None: 

1503 return None 

1504 sid = record.get("sessionId", "") 

1505 ts = record.get("timestamp", "") 

1506 if sig.runner == "bash": 

1507 return _EvalInvocation("cmd", sig.input_context, sid, ts, "") 

1508 return _EvalInvocation("skill", sig.tool_name, sid, ts, sig.input_context) 

1509 

1510 

1511def _record_has_error(record: dict) -> bool: 

1512 """True if a JSONL record is a ``tool_result`` flagged ``is_error``. 

1513 

1514 Used as the session-level ``failed`` outcome signal: session logs expose no 

1515 output-quality judgment, only execution evidence (ARCHITECTURE-017). 

1516 """ 

1517 if record.get("type") != "user": 

1518 return False 

1519 message = record.get("message", {}) 

1520 if not isinstance(message, dict): 

1521 return False 

1522 content = message.get("content") 

1523 if isinstance(content, list): 

1524 for block in content: 

1525 if ( 

1526 isinstance(block, dict) 

1527 and block.get("type") == "tool_result" 

1528 and block.get("is_error") 

1529 ): 

1530 return True 

1531 return False 

1532 

1533 

1534def _classify_outcome(metadata: dict, *, has_error: bool) -> str: 

1535 """Map session metadata + error signal to an EvalFixture execution outcome. 

1536 

1537 Precedence ``failed`` > ``corrected`` > ``accepted``; ``unknown`` when the DB 

1538 returned no metadata. Per ARCHITECTURE-017 the taxonomy is EXECUTION, not 

1539 output quality. ``metadata`` is the dict from 

1540 ``history_reader.lookup_session_metadata`` (``{}`` when the session is absent). 

1541 """ 

1542 if has_error: 

1543 return "failed" 

1544 if not metadata: 

1545 return "unknown" 

1546 if metadata.get("has_corrections"): 

1547 return "corrected" 

1548 return "accepted" 

1549 

1550 

1551def _redact_input_context(text: str) -> tuple[str | None, bool]: 

1552 """Best-effort, non-blocking redaction of user-message text. 

1553 

1554 Applies ``pii.redact_pii`` (email/phone/SSN) then ``_ABS_PATH_RE`` (absolute 

1555 paths). Returns ``(redacted_text_or_None, pii_detected)`` where ``pii_detected`` 

1556 is True when either pass altered the text. Never raises and never drops a 

1557 record for unredactable content (ARCHITECTURE-017). 

1558 """ 

1559 if not text: 

1560 return None, False 

1561 from little_loops.pii import redact_pii 

1562 

1563 redacted = redact_pii(text) 

1564 redacted = _ABS_PATH_RE.sub("<path>", redacted) 

1565 return redacted, redacted != text 

1566 

1567 

1568def _build_eval_fixture(inv: _EvalInvocation, outcome: str) -> dict: 

1569 """Map a reconstructed invocation + outcome to an EvalFixture v1 record. 

1570 

1571 Pure function (no I/O) — the mapping core covered by unit tests. Schema and 

1572 field semantics per decision ARCHITECTURE-017 in ``.ll/decisions.yaml``: the 

1573 fixture replays into ``ll-harness <runner> <target> [runner_args...] 

1574 [--exit-code N] [--semantic TEXT] [--timeout S]`` (ll-harness has no loader). 

1575 """ 

1576 input_context, pii_detected = _redact_input_context(inv.input_context) 

1577 issue_match = _ISSUE_ID_RE.search(inv.input_context) if inv.input_context else None 

1578 issue_id = issue_match.group(0) if issue_match else None 

1579 skill_name = inv.target if inv.runner == "skill" else None 

1580 return { 

1581 "runner": inv.runner, 

1582 "target": inv.target, 

1583 "session_id": inv.session_id, 

1584 "timestamp": inv.timestamp, 

1585 "outcome": outcome, 

1586 "runner_args": [], 

1587 "exit_code": None, 

1588 "semantic": None, 

1589 "timeout": 120, 

1590 "input_context": input_context, 

1591 "issue_id": issue_id, 

1592 "skill_name": skill_name, 

1593 "pii_detected": pii_detected, 

1594 } 

1595 

1596 

1597def _fixture_to_harness_argv(fixture: dict) -> list[str]: 

1598 """Serialize an EvalFixture record back into an ``ll-harness`` argv. 

1599 

1600 ll-harness has no fixture loader (ARCHITECTURE-017); a fixture replays by 

1601 serializing its fields into the harness CLI arg surface. Used by the 

1602 round-trip test to prove every exported fixture is a valid harness invocation. 

1603 """ 

1604 argv: list[str] = [fixture["runner"], fixture["target"]] 

1605 argv.extend(fixture.get("runner_args") or []) 

1606 if fixture.get("exit_code") is not None: 

1607 argv.extend(["--exit-code", str(fixture["exit_code"])]) 

1608 if fixture.get("semantic") is not None: 

1609 argv.extend(["--semantic", str(fixture["semantic"])]) 

1610 timeout = fixture.get("timeout") 

1611 if timeout is not None and timeout != 120: 

1612 argv.extend(["--timeout", str(timeout)]) 

1613 return argv 

1614 

1615 

1616def _cmd_eval_export(args: argparse.Namespace) -> int: 

1617 """Export ll-harness eval fixtures reconstructed from session logs (FEAT-1971). 

1618 

1619 Walks the current project's JSONL logs, reconstructs each ll invocation, sources 

1620 an execution outcome from ``history_reader.lookup_session_metadata``, redacts the 

1621 input context, and writes EvalFixture v1 records (YAML default, JSON with 

1622 ``--json``). Schema + outcome taxonomy: decision ARCHITECTURE-017 in 

1623 ``.ll/decisions.yaml``. 

1624 """ 

1625 from little_loops.history_reader import lookup_session_metadata 

1626 

1627 cwd_path = Path(args.project) if args.project else Path.cwd() 

1628 project_folder = get_project_folder(cwd_path) 

1629 if project_folder is None: 

1630 print(f"No session project folder found for: {cwd_path}", file=sys.stderr) 

1631 return 1 

1632 db_path = resolve_history_db(cwd_path / ".ll" / "history.db") 

1633 

1634 # Single JSONL pass: collect raw invocations + per-session error flags together 

1635 # (avoids the double-parse the decision warns against). 

1636 invocations: list[_EvalInvocation] = [] 

1637 session_has_error: dict[str, bool] = {} 

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

1639 for jsonl_file in jsonl_files: 

1640 try: 

1641 with open(jsonl_file, encoding="utf-8") as f: 

1642 for line in f: 

1643 line = line.strip() 

1644 if not line: 

1645 continue 

1646 try: 

1647 record = json.loads(line) 

1648 except json.JSONDecodeError: 

1649 continue 

1650 if _record_has_error(record): 

1651 sid = record.get("sessionId", "") 

1652 if sid: 

1653 session_has_error[sid] = True 

1654 inv = _extract_eval_invocation(record) 

1655 if inv is not None: 

1656 invocations.append(inv) 

1657 except OSError: 

1658 continue 

1659 

1660 # Stable, deterministic order: by timestamp then session. 

1661 invocations.sort(key=lambda e: (e.timestamp, e.session_id)) 

1662 

1663 metadata_cache: dict[str, dict] = {} 

1664 fixtures: list[dict] = [] 

1665 skipped = 0 

1666 for inv in invocations: 

1667 # --skill: keep only skill-runner invocations of the named target. 

1668 if args.skill and not (inv.runner == "skill" and inv.target == args.skill): 

1669 continue 

1670 

1671 if inv.session_id not in metadata_cache: 

1672 metadata_cache[inv.session_id] = lookup_session_metadata(inv.session_id, db=db_path) 

1673 outcome = _classify_outcome( 

1674 metadata_cache[inv.session_id], 

1675 has_error=session_has_error.get(inv.session_id, False), 

1676 ) 

1677 # No extractable execution outcome -> skip with a logged count. 

1678 if outcome == "unknown": 

1679 skipped += 1 

1680 continue 

1681 

1682 fixture = _build_eval_fixture(inv, outcome) 

1683 

1684 # --issue: match the extracted issue_id or a literal occurrence in target. 

1685 if args.issue and args.issue != fixture["issue_id"] and args.issue not in inv.target: 

1686 continue 

1687 

1688 fixtures.append(fixture) 

1689 if args.limit and len(fixtures) >= args.limit: 

1690 break 

1691 

1692 if args.json: 

1693 output = json.dumps(fixtures, indent=2) 

1694 else: 

1695 import yaml 

1696 

1697 output = yaml.safe_dump(fixtures, sort_keys=False, default_flow_style=False) 

1698 

1699 if args.out: 

1700 out_path = Path(args.out) 

1701 try: 

1702 out_path.parent.mkdir(parents=True, exist_ok=True) 

1703 out_path.write_text(output, encoding="utf-8") 

1704 except OSError as exc: 

1705 print(f"Failed to write {out_path}: {exc}", file=sys.stderr) 

1706 return 1 

1707 print(f"Wrote {len(fixtures)} fixture(s) to {out_path}", file=sys.stderr) 

1708 else: 

1709 print(output, end="" if output.endswith("\n") else "\n") 

1710 

1711 if skipped: 

1712 print( 

1713 f"Skipped {skipped} invocation(s) with no extractable outcome", 

1714 file=sys.stderr, 

1715 ) 

1716 

1717 return 0 

1718 

1719 

1720def _build_parser() -> argparse.ArgumentParser: 

1721 """Build the argument parser for ll-logs.""" 

1722 parser = argparse.ArgumentParser( 

1723 prog="ll-logs", 

1724 description="Discover and extract ll-relevant JSONL entries from Claude Code logs", 

1725 formatter_class=argparse.RawDescriptionHelpFormatter, 

1726 epilog=""" 

1727Examples: 

1728 %(prog)s discover # List all projects with ll activity 

1729 %(prog)s tail --loop <name> # Stream live events from an active loop session 

1730 %(prog)s extract --all # Extract all projects to logs/ 

1731 %(prog)s extract --project /path # Extract one project to logs/<slug>/ 

1732 %(prog)s extract --all --cmd ll-history # Filter to ll-history invocations 

1733""", 

1734 ) 

1735 

1736 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

1737 discover_parser = subparsers.add_parser( 

1738 "discover", 

1739 help="List all Claude projects with ll activity (one path per line, sorted)", 

1740 ) 

1741 add_json_arg(discover_parser) 

1742 discover_parser.add_argument( 

1743 "--existing-only", 

1744 action="store_true", 

1745 default=False, 

1746 help="Only emit paths that currently exist on disk; suppress all diagnostic output.", 

1747 ) 

1748 

1749 tail_parser = subparsers.add_parser( 

1750 "tail", 

1751 help="Stream live events from an active loop session", 

1752 ) 

1753 tail_parser.add_argument("--loop", required=True, metavar="NAME", help="Loop name to tail") 

1754 tail_parser.add_argument( 

1755 "--project", type=Path, metavar="DIR", help="Project root to tail loops from (default: CWD)" 

1756 ) 

1757 

1758 extract_parser = subparsers.add_parser( 

1759 "extract", 

1760 help="Extract ll-relevant JSONL records to logs/<slug>/<session-id>.jsonl", 

1761 ) 

1762 target_group = extract_parser.add_mutually_exclusive_group(required=True) 

1763 target_group.add_argument( 

1764 "--project", 

1765 type=Path, 

1766 metavar="DIR", 

1767 help="Working directory of the target project", 

1768 ) 

1769 target_group.add_argument( 

1770 "--all", 

1771 action="store_true", 

1772 help="Extract all projects with ll activity", 

1773 ) 

1774 extract_parser.add_argument( 

1775 "--cmd", 

1776 metavar="TOOL", 

1777 help="Filter to records containing this ll- tool name (e.g. ll-history)", 

1778 ) 

1779 

1780 sequences_parser = subparsers.add_parser( 

1781 "sequences", 

1782 help="Extract tool-chain n-grams of ll invocations from JSONL logs", 

1783 ) 

1784 sequences_target = sequences_parser.add_mutually_exclusive_group(required=True) 

1785 sequences_target.add_argument( 

1786 "--project", 

1787 type=Path, 

1788 metavar="DIR", 

1789 help="Working directory of the target project", 

1790 ) 

1791 sequences_target.add_argument( 

1792 "--all", 

1793 action="store_true", 

1794 help="Analyze all projects with ll activity", 

1795 ) 

1796 sequences_parser.add_argument( 

1797 "--min-len", 

1798 type=int, 

1799 default=2, 

1800 metavar="N", 

1801 help="Minimum n-gram length (default: 2)", 

1802 ) 

1803 sequences_parser.add_argument( 

1804 "--min-count", 

1805 type=int, 

1806 default=1, 

1807 metavar="M", 

1808 help="Minimum occurrence count to include (default: 1)", 

1809 ) 

1810 sequences_parser.add_argument( 

1811 "--top", 

1812 type=int, 

1813 default=None, 

1814 metavar="N", 

1815 help="Limit output to top N chains by frequency", 

1816 ) 

1817 sequences_parser.add_argument( 

1818 "--window-days", 

1819 type=int, 

1820 default=None, 

1821 metavar="D", 

1822 help="Only consider records within the last D calendar days", 

1823 ) 

1824 add_json_arg(sequences_parser) 

1825 

1826 stats_parser = subparsers.add_parser( 

1827 "stats", 

1828 help="Aggregate skill invocation frequency and correction rate from history.db", 

1829 ) 

1830 stats_target = stats_parser.add_mutually_exclusive_group(required=True) 

1831 stats_target.add_argument( 

1832 "--project", 

1833 type=Path, 

1834 metavar="DIR", 

1835 help="Working directory of the target project", 

1836 ) 

1837 stats_target.add_argument( 

1838 "--all", 

1839 action="store_true", 

1840 help="Aggregate across all projects with ll activity", 

1841 ) 

1842 stats_parser.add_argument( 

1843 "--window-days", 

1844 type=int, 

1845 default=None, 

1846 metavar="D", 

1847 help="Only consider records within the last D calendar days", 

1848 ) 

1849 stats_parser.add_argument( 

1850 "--sort", 

1851 choices=["freq", "corrections"], 

1852 default="freq", 

1853 help="Sort output by invocation frequency or correction count (default: freq)", 

1854 ) 

1855 add_json_arg(stats_parser) 

1856 

1857 scan_failures_parser = subparsers.add_parser( 

1858 "scan-failures", 

1859 help="Mine failed ll-* calls from interactive session logs and propose bug issues", 

1860 ) 

1861 scan_failures_target = scan_failures_parser.add_mutually_exclusive_group(required=True) 

1862 scan_failures_target.add_argument( 

1863 "--project", 

1864 type=Path, 

1865 metavar="DIR", 

1866 help="Working directory of the target project", 

1867 ) 

1868 scan_failures_target.add_argument( 

1869 "--all", 

1870 action="store_true", 

1871 help="Scan all projects with ll activity", 

1872 ) 

1873 scan_failures_parser.add_argument( 

1874 "--window-days", 

1875 type=int, 

1876 default=None, 

1877 metavar="D", 

1878 help="Only consider records within the last D calendar days", 

1879 ) 

1880 scan_failures_parser.add_argument( 

1881 "--capture", 

1882 action="store_true", 

1883 help="Create bug issue files for each failure cluster (one per tool+error signature)", 

1884 ) 

1885 scan_failures_parser.add_argument( 

1886 "--capture-foreign", 

1887 action="store_true", 

1888 help="Allow --capture to include failures from projects other than the current directory (only meaningful with --all)", 

1889 ) 

1890 add_json_arg(scan_failures_parser) 

1891 

1892 dead_skills_parser = subparsers.add_parser( 

1893 "dead-skills", 

1894 help="List catalog skills/commands with zero or low invocations across the corpus", 

1895 ) 

1896 dead_skills_target = dead_skills_parser.add_mutually_exclusive_group(required=True) 

1897 dead_skills_target.add_argument( 

1898 "--project", 

1899 type=Path, 

1900 metavar="DIR", 

1901 help="Working directory of the target project (also used as catalog root)", 

1902 ) 

1903 dead_skills_target.add_argument( 

1904 "--all", 

1905 action="store_true", 

1906 help="Aggregate across all projects; catalog loaded from current directory", 

1907 ) 

1908 dead_skills_parser.add_argument( 

1909 "--window-days", 

1910 type=int, 

1911 default=None, 

1912 metavar="D", 

1913 help="Only consider records within the last D calendar days", 

1914 ) 

1915 dead_skills_parser.add_argument( 

1916 "--threshold", 

1917 type=int, 

1918 default=3, 

1919 metavar="N", 

1920 help="Skills with invocations <= N are 'rarely' invoked (default: 3)", 

1921 ) 

1922 add_json_arg(dead_skills_parser) 

1923 

1924 diff_parser = subparsers.add_parser( 

1925 "diff", 

1926 help="Compare two sessions' ll-invocation behavior (skills, sequences, counts)", 

1927 ) 

1928 diff_parser.add_argument( 

1929 "session_a", metavar="SESSION_A", help="First session ID or JSONL file path" 

1930 ) 

1931 diff_parser.add_argument( 

1932 "session_b", metavar="SESSION_B", help="Second session ID or JSONL file path" 

1933 ) 

1934 add_json_arg(diff_parser) 

1935 

1936 eval_export_parser = subparsers.add_parser( 

1937 "eval-export", 

1938 help="Export eval fixtures from ll-harness session logs", 

1939 ) 

1940 eval_export_parser.add_argument( 

1941 "--project", 

1942 type=Path, 

1943 metavar="DIR", 

1944 help="Project working directory (default: current directory)", 

1945 ) 

1946 eval_export_parser.add_argument( 

1947 "--skill", 

1948 metavar="NAME", 

1949 help="Filter by skill name", 

1950 ) 

1951 eval_export_parser.add_argument( 

1952 "--issue", 

1953 metavar="ID", 

1954 help="Filter by issue ID in session context", 

1955 ) 

1956 eval_export_parser.add_argument( 

1957 "--limit", 

1958 type=int, 

1959 default=0, 

1960 metavar="N", 

1961 help="Cap output records (0 = unlimited)", 

1962 ) 

1963 eval_export_parser.add_argument( 

1964 "--out", 

1965 metavar="PATH", 

1966 help="Write output to file (default: stdout)", 

1967 ) 

1968 add_json_arg(eval_export_parser, help_text="JSON output instead of YAML (default: YAML)") 

1969 

1970 return parser 

1971 

1972 

1973def _parse_args() -> argparse.Namespace: 

1974 """Parse command-line arguments. Exposed for testing.""" 

1975 return _build_parser().parse_args() 

1976 

1977 

1978def main_logs() -> int: 

1979 """Entry point for ll-logs command. 

1980 

1981 Returns: 

1982 0 on success, 1 when no subcommand given or on error. 

1983 """ 

1984 with cli_event_context(DEFAULT_DB_PATH, "ll-logs", sys.argv[1:]): 

1985 configure_output() 

1986 logger = Logger(use_color=use_color_enabled()) 

1987 

1988 parser = _build_parser() 

1989 args = parser.parse_args() 

1990 

1991 if not args.command: 

1992 parser.print_help() 

1993 return 1 

1994 

1995 if args.command == "discover": 

1996 projects = discover_all_projects(logger, existing_only=args.existing_only) 

1997 if args.json: 

1998 print_json({"paths": [str(p) for p in projects]}) 

1999 else: 

2000 for path in projects: 

2001 print(path) 

2002 return 0 

2003 

2004 if args.command == "tail": 

2005 project_root = args.project if args.project else Path.cwd() 

2006 config = BRConfig(project_root) 

2007 loops_dir = Path(config.loops.loops_dir) 

2008 return _cmd_tail(args, loops_dir) 

2009 

2010 if args.command == "extract": 

2011 return _cmd_extract(args, logger) 

2012 

2013 if args.command == "sequences": 

2014 return _cmd_sequences(args, logger) 

2015 

2016 if args.command == "stats": 

2017 return _cmd_stats(args, logger) 

2018 

2019 if args.command == "scan-failures": 

2020 return _cmd_scan_failures(args, logger) 

2021 

2022 if args.command == "dead-skills": 

2023 return _cmd_dead_skills(args, logger) 

2024 

2025 if args.command == "diff": 

2026 return _cmd_diff(args, logger) 

2027 

2028 if args.command == "eval-export": 

2029 return _cmd_eval_export(args) 

2030 

2031 return 1