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

1126 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:30 -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.cli.loop.info import ( # private symbol: cross-module coupling; verify signature on upgrade 

18 _format_history_event, 

19) 

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

21from little_loops.cli_args import add_json_arg 

22from little_loops.config import BRConfig 

23from little_loops.logger import Logger 

24from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context, resolve_history_db 

25from little_loops.user_messages import get_project_folder 

26 

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

28BRIDGE_MARKER = "Bridged from `commands/" 

29 

30 

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

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

33 

34 Detects three signal types: 

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

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

37 """ 

38 record_type = record.get("type") 

39 

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

41 if record_type == "queue-operation": 

42 return ( 

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

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

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

46 ) 

47 

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

49 if record_type == "user": 

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

51 if not isinstance(message, dict): 

52 return False 

53 content = message.get("content") 

54 if isinstance(content, str): 

55 return bool(_COMMAND_NAME_RE.search(content)) 

56 if isinstance(content, list): 

57 for block in content: 

58 if isinstance(block, dict): 

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

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

61 return True 

62 

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

64 if record_type == "assistant": 

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

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

67 if isinstance(content, list): 

68 for block in content: 

69 if ( 

70 isinstance(block, dict) 

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

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

73 ): 

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

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

76 return True 

77 

78 return False 

79 

80 

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

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

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

84 

85 for jsonl_file in jsonl_files: 

86 try: 

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

88 for line in f: 

89 line = line.strip() 

90 if not line: 

91 continue 

92 try: 

93 record = json.loads(line) 

94 except json.JSONDecodeError: 

95 continue 

96 if _is_ll_relevant(record): 

97 return True 

98 except OSError: 

99 continue 

100 

101 return False 

102 

103 

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

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

106 

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

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

109 records gives the canonical path without ambiguity. 

110 """ 

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

112 for jsonl_file in jsonl_files: 

113 try: 

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

115 for line in f: 

116 line = line.strip() 

117 if not line: 

118 continue 

119 try: 

120 record = json.loads(line) 

121 cwd = record.get("cwd") 

122 if isinstance(cwd, str) and cwd: 

123 return Path(cwd) 

124 except json.JSONDecodeError: 

125 continue 

126 except OSError: 

127 continue 

128 return None 

129 

130 

131def discover_all_projects(logger: Logger, *, host: str | None = None) -> list[Path]: 

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

133 

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

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

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

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

138 

139 Args: 

140 logger: Logger instance for warnings. 

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

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

143 

144 Returns: 

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

146 """ 

147 import os as _os 

148 

149 if host is None: 

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

151 

152 if host == "claude-code": 

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

154 elif host == "codex": 

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

156 elif host == "opencode": 

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

158 elif host == "pi": 

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

160 else: 

161 return [] 

162 

163 if not projects_root.exists(): 

164 return [] 

165 

166 results: list[Path] = [] 

167 

168 for project_dir in projects_root.iterdir(): 

169 if not project_dir.is_dir(): 

170 continue 

171 

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

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

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

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

176 decoded_path = _extract_cwd_from_project(project_dir) or Path( 

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

178 ) 

179 

180 if not decoded_path.exists(): 

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

182 continue 

183 

184 if _has_ll_activity(project_dir): 

185 results.append(decoded_path) 

186 

187 return sorted(results) 

188 

189 

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

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

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

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

194 if isinstance(content, list): 

195 for block in content: 

196 if ( 

197 isinstance(block, dict) 

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

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

200 ): 

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

202 if cmd in command: 

203 return True 

204 return False 

205 

206 

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

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

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

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

211 

212 

213@dataclass 

214class InvocationEvent: 

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

216 

217 tool_name: str 

218 timestamp: str 

219 session_id: str 

220 

221 

222def _extract_ll_event_streams( 

223 project_folder: Path, *, window_days: int | None = None 

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

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

226 

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

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

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

230 

231 Args: 

232 project_folder: Path to the claude project session directory. 

233 window_days: If set, filter records to within N days of the latest record. 

234 

235 Returns: 

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

237 """ 

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

239 

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

241 if not jsonl_files: 

242 return events_by_session 

243 

244 # Collect all raw events first for window-days filtering 

245 all_events: list[InvocationEvent] = [] 

246 latest_ts: str | None = None 

247 

248 for jsonl_file in jsonl_files: 

249 try: 

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

251 for line in f: 

252 line = line.strip() 

253 if not line: 

254 continue 

255 try: 

256 record = json.loads(line) 

257 except json.JSONDecodeError: 

258 continue 

259 

260 tool_name = _extract_tool_name(record) 

261 if tool_name is None: 

262 continue 

263 

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

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

266 

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

268 all_events.append(evt) 

269 

270 if ts and (latest_ts is None or ts > latest_ts): 

271 latest_ts = ts 

272 except OSError: 

273 continue 

274 

275 # Apply window-days filter 

276 if window_days is not None and latest_ts: 

277 cutoff = _parse_iso_timestamp(latest_ts) - timedelta(days=window_days) 

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

279 

280 # Bucket by session and sort 

281 for evt in all_events: 

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

283 

284 for session_id in events_by_session: 

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

286 

287 return events_by_session 

288 

289 

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

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

292 

293 Detects three signal types (matching ``_is_ll_relevant``): 

294 (a) queue-operation enqueue with ``/ll:<name>`` → skill name 

295 (b) user records with ``<command-name>/ll:<name>`` → skill name 

296 (c) assistant Bash tool-use invoking ``ll-<tool>`` → CLI tool name 

297 """ 

298 record_type = record.get("type") 

299 

300 # (a) queue-operation enqueue 

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

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

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

304 m = _QUEUE_SKILL_RE.match(content) 

305 if m: 

306 return m.group(1) 

307 

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

309 if record_type == "user": 

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

311 if not isinstance(message, dict): 

312 return None 

313 content = message.get("content") 

314 text = "" 

315 if isinstance(content, str): 

316 text = content 

317 elif isinstance(content, list): 

318 for block in content: 

319 if isinstance(block, dict): 

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

321 if text: 

322 break 

323 if text: 

324 m = _COMMAND_NAME_SKILL_RE.search(text) 

325 if m: 

326 # Extract skill name, stripping trailing </command-name> if present 

327 name = m.group(1) 

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

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

330 return name 

331 

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

333 if record_type == "assistant": 

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

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

336 if isinstance(content, list): 

337 for block in content: 

338 if ( 

339 isinstance(block, dict) 

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

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

342 ): 

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

344 m = _LL_BASH_RE.search(cmd) 

345 if m: 

346 return m.group(1) 

347 

348 return None 

349 

350 

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

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

353 

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

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

356 """ 

357 if not ts: 

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

359 try: 

360 # Handle Z suffix 

361 if ts.endswith("Z"): 

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

363 dt = datetime.fromisoformat(ts) 

364 if dt.tzinfo is None: 

365 dt = dt.replace(tzinfo=UTC) 

366 return dt 

367 except (ValueError, TypeError): 

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

369 

370 

371@dataclass 

372class Edge: 

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

374 

375 from_: str 

376 to: str 

377 freq: float 

378 

379 

380@dataclass 

381class ChainResult: 

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

383 

384 chain: list[str] 

385 count: int 

386 edges: list[Edge] 

387 

388 def to_dict(self) -> dict: 

389 return { 

390 "chain": self.chain, 

391 "count": self.count, 

392 "edges": [{"from": e.from_, "to": e.to, "freq": e.freq} for e in self.edges], 

393 } 

394 

395 

396def _count_ngrams( 

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

398 min_len: int = 2, 

399) -> Counter: 

400 """Count n-grams across per-session event streams. 

401 

402 Args: 

403 events_by_session: Per-session ordered event streams. 

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

405 

406 Returns: 

407 Counter mapping ``(tool_1, tool_2, ...)`` tuples to occurrence counts. 

408 """ 

409 counter: Counter = Counter() 

410 for events in events_by_session.values(): 

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

412 # For chains shorter than min_len, still consider them as single n-grams 

413 # but note: min_len is the minimum, so we use range(min_len, len(names)+1) 

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

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

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

417 counter[ngram] += 1 

418 return counter 

419 

420 

421def _build_chain_results( 

422 counter: Counter, 

423 min_count: int = 1, 

424 top: int | None = None, 

425) -> list[ChainResult]: 

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

427 

428 Args: 

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

430 min_count: Minimum occurrence count to include. 

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

432 

433 Returns: 

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

435 """ 

436 results: list[ChainResult] = [] 

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

438 if count < min_count: 

439 continue 

440 edges = _compute_edges(ngram, counter) 

441 results.append(ChainResult(chain=list(ngram), count=count, edges=edges)) 

442 

443 if top is not None: 

444 results = results[:top] 

445 

446 return results 

447 

448 

449def _compute_edges(ngram: tuple[str, ...], counter: Counter) -> list[Edge]: 

450 """Compute per-edge transition frequencies for an n-gram chain. 

451 

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

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

454 originating from ``from`` across the entire corpus. 

455 """ 

456 edges: list[Edge] = [] 

457 # Build a transition counter for all pairs in the corpus 

458 all_transitions: Counter = Counter() 

459 out_degree: Counter = Counter() 

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

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

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

463 all_transitions[pair] += count 

464 out_degree[ngram_key[i]] += count 

465 

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

467 from_ = ngram[i] 

468 to = ngram[i + 1] 

469 pair = (from_, to) 

470 total_out = out_degree.get(from_, 0) 

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

472 edges.append(Edge(from_=from_, to=to, freq=round(freq, 4))) 

473 

474 return edges 

475 

476 

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

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

479 if args.project: 

480 cwd_path: Path = args.project 

481 project_folder = get_project_folder(cwd_path) 

482 if project_folder is None: 

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

484 return 1 

485 project_items = [(cwd_path, project_folder)] 

486 else: 

487 decoded_paths = discover_all_projects(logger) 

488 project_items = [] 

489 for decoded_path in decoded_paths: 

490 folder = get_project_folder(decoded_path) 

491 if folder is not None: 

492 project_items.append((decoded_path, folder)) 

493 

494 # Aggregate events across all projects 

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

496 for _cwd_path, project_folder in project_items: 

497 events = _extract_ll_event_streams(project_folder, window_days=args.window_days) 

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

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

500 

501 # Sort each session's events by timestamp 

502 for sid in all_events: 

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

504 

505 # Count n-grams 

506 counter = _count_ngrams(all_events, min_len=args.min_len) 

507 results = _build_chain_results(counter, min_count=args.min_count, top=args.top) 

508 

509 if args.json: 

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

511 else: 

512 if not results: 

513 print("No sequences found.") 

514 return 0 

515 

516 # Print ranked table 

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

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

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

520 for edge in r.edges: 

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

522 

523 return 0 

524 

525 

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

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

528 rows = [] 

529 

530 if logs_dir.exists(): 

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

532 if not subdir.is_dir(): 

533 continue 

534 

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

536 if not jsonl_files: 

537 continue 

538 

539 timestamps: list[str] = [] 

540 for jsonl_file in jsonl_files: 

541 try: 

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

543 for line in f: 

544 line = line.strip() 

545 if not line: 

546 continue 

547 try: 

548 record = json.loads(line) 

549 except json.JSONDecodeError: 

550 continue 

551 ts = record.get("timestamp") 

552 if ts: 

553 timestamps.append(ts) 

554 except OSError: 

555 continue 

556 

557 if timestamps: 

558 earliest = min(timestamps)[:10] 

559 latest = max(timestamps)[:10] 

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

561 else: 

562 date_range = "" 

563 

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

565 

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

567 if rows: 

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

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

570 for name, count, date_range in rows: 

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

572 else: 

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

574 lines.append("") 

575 

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

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

578 

579 

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

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

582 if args.project: 

583 cwd_path: Path = args.project 

584 project_folder = get_project_folder(cwd_path) 

585 if project_folder is None: 

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

587 return 1 

588 project_items = [(cwd_path, project_folder)] 

589 else: 

590 decoded_paths = discover_all_projects(logger) 

591 project_items = [] 

592 for decoded_path in decoded_paths: 

593 folder = get_project_folder(decoded_path) 

594 if folder is not None: 

595 project_items.append((decoded_path, folder)) 

596 

597 for cwd_path, project_folder in project_items: 

598 slug = cwd_path.resolve().name 

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

600 

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

602 for jsonl_file in jsonl_files: 

603 try: 

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

605 for line in f: 

606 line = line.strip() 

607 if not line: 

608 continue 

609 try: 

610 record = json.loads(line) 

611 except json.JSONDecodeError: 

612 continue 

613 if _is_ll_relevant(record): 

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

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

616 except OSError: 

617 continue 

618 

619 if args.cmd: 

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

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

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

623 if matching: 

624 filtered[session_id] = matching 

625 buckets = filtered 

626 

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

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

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

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

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

632 for record in records: 

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

634 

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

636 return 0 

637 

638 

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

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

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

642 

643 if not events_file.exists(): 

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

645 return 1 

646 

647 width = shutil.get_terminal_size().columns 

648 try: 

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

650 f.seek(0, 2) 

651 while True: 

652 line = f.readline() 

653 if line: 

654 line = line.strip() 

655 if line: 

656 try: 

657 event = json.loads(line) 

658 except json.JSONDecodeError: 

659 continue 

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

661 if formatted is not None: 

662 print(formatted) 

663 else: 

664 time.sleep(0.1) 

665 except KeyboardInterrupt: 

666 return 0 

667 

668 return 0 

669 

670 

671_CORRECTION_WINDOW_SEC = 30 

672 

673 

674def _aggregate_skill_stats( 

675 db_path: Path, 

676 *, 

677 window_days: int | None = None, 

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

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

680 

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

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

683 event in the same session within _CORRECTION_WINDOW_SEC seconds. 

684 """ 

685 if not db_path.exists(): 

686 return None 

687 

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

689 conn.row_factory = sqlite3.Row 

690 try: 

691 try: 

692 skill_rows = conn.execute( 

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

694 ).fetchall() 

695 except sqlite3.OperationalError: 

696 return None 

697 

698 if not skill_rows: 

699 return {} 

700 

701 if window_days is not None: 

702 latest_ts = skill_rows[-1]["ts"] or "" 

703 cutoff = _parse_iso_timestamp(latest_ts) - timedelta(days=window_days) 

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

705 

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

707 for row in skill_rows: 

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

709 

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

711 for row in skill_rows: 

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

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

714 

715 try: 

716 corr_rows = conn.execute( 

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

718 ).fetchall() 

719 except sqlite3.OperationalError: 

720 corr_rows = [] 

721 

722 for corr in corr_rows: 

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

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

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

726 best_skill: str | None = None 

727 best_ts: str = "" 

728 for s_ts, s_name in candidates: 

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

730 best_ts = s_ts 

731 best_skill = s_name 

732 if best_skill is not None: 

733 elapsed = ( 

734 _parse_iso_timestamp(c_ts) - _parse_iso_timestamp(best_ts) 

735 ).total_seconds() 

736 if 0 <= elapsed <= _CORRECTION_WINDOW_SEC: 

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

738 

739 return dict(stats) 

740 finally: 

741 conn.close() 

742 

743 

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

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

746 

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

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

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

750 """ 

751 import yaml 

752 

753 names: set[str] = set() 

754 

755 skills_dir = root_dir / "skills" 

756 if skills_dir.is_dir(): 

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

758 try: 

759 text = skill_md.read_text() 

760 except OSError: 

761 continue 

762 if BRIDGE_MARKER in text: 

763 continue 

764 name: str = skill_md.parent.name 

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

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

767 if end != -1: 

768 try: 

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

770 except yaml.YAMLError: 

771 fm = {} 

772 if isinstance(fm, dict): 

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

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

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

776 ): 

777 continue 

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

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

780 name = name[3:] 

781 if name: 

782 names.add(name) 

783 

784 commands_dir = root_dir / "commands" 

785 if commands_dir.is_dir(): 

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

787 stem = cmd_md.stem 

788 try: 

789 text = cmd_md.read_text() 

790 except OSError: 

791 names.add(stem) 

792 continue 

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

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

795 if end != -1: 

796 try: 

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

798 except yaml.YAMLError: 

799 fm = {} 

800 if isinstance(fm, dict): 

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

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

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

804 ): 

805 continue 

806 names.add(stem) 

807 

808 return names 

809 

810 

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

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

813 if args.project: 

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

815 catalog_root = Path(args.project) 

816 else: 

817 decoded_paths = discover_all_projects(logger) 

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

819 catalog_root = Path.cwd() 

820 

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

822 for db_path in db_paths: 

823 result = _aggregate_skill_stats(db_path, window_days=args.window_days) 

824 if result is None: 

825 continue 

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

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

828 

829 catalog_names = _load_catalog_names(catalog_root) 

830 if not catalog_names: 

831 logger.warning( 

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

833 ) 

834 return 0 

835 

836 threshold = args.threshold 

837 rows = [] 

838 for name in sorted(catalog_names): 

839 count = merged.get(name, 0) 

840 if count == 0: 

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

842 elif count <= threshold: 

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

844 

845 if args.json: 

846 print_json(rows) 

847 return 0 

848 

849 if not rows: 

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

851 return 0 

852 

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

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

855 print(table(headers, table_rows)) 

856 return 0 

857 

858 

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

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

861 

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

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

864 """ 

865 import tomllib 

866 

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

868 try: 

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

870 data = tomllib.load(f) 

871 except (OSError, ValueError): 

872 return frozenset() 

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

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

875 

876 

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

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

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

880 

881 

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

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

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

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

886 

887 

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

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

890 

891 Returns a stable string suitable as a cluster key. 

892 """ 

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

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

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

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

897 

898 

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

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

901 if isinstance(content, str): 

902 return content 

903 if isinstance(content, list): 

904 parts: list[str] = [] 

905 for item in content: 

906 if isinstance(item, dict): 

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

908 if isinstance(text, str): 

909 parts.append(text) 

910 return "\n".join(parts) 

911 return "" 

912 

913 

914@dataclass 

915class _FailureCluster: 

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

917 

918 tool_name: str 

919 normalized_sig: str 

920 count: int 

921 sample_error: str 

922 session_ids: list[str] 

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

924 

925 

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

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

928 from little_loops.issue_lifecycle import FailureType, classify_failure 

929 

930 _cli_allowlist = _load_cli_allowlist(Path.cwd()) 

931 

932 if args.project: 

933 cwd_path: Path = args.project 

934 project_folder = get_project_folder(cwd_path) 

935 if project_folder is None: 

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

937 return 1 

938 project_items = [(cwd_path, project_folder)] 

939 else: 

940 decoded_paths = discover_all_projects(logger) 

941 project_items = [] 

942 for decoded_path in decoded_paths: 

943 folder = get_project_folder(decoded_path) 

944 if folder is not None: 

945 project_items.append((decoded_path, folder)) 

946 

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

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

949 latest_ts_overall: str = "" 

950 

951 for _cwd_path, project_folder in project_items: 

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

953 

954 for jsonl_file in jsonl_files: 

955 try: 

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

957 lines = f.readlines() 

958 except OSError: 

959 continue 

960 

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

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

963 

964 for line in lines: 

965 line = line.strip() 

966 if not line: 

967 continue 

968 try: 

969 record = json.loads(line) 

970 except json.JSONDecodeError: 

971 continue 

972 

973 record_type = record.get("type") 

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

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

976 

977 if ts and ts > latest_ts_overall: 

978 latest_ts_overall = ts 

979 

980 if record_type == "assistant": 

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

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

983 if not isinstance(content, list): 

984 continue 

985 for block in content: 

986 if not isinstance(block, dict): 

987 continue 

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

989 continue 

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

991 m = _LL_BASH_RE.search(cmd) 

992 if not m: 

993 continue 

994 tool_name = m.group(1) 

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

996 if _cli_allowlist and tool_name not in _cli_allowlist: 

997 continue 

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

999 if block_id: 

1000 pending[block_id] = (tool_name, ts) 

1001 

1002 elif record_type == "user": 

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

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

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

1006 continue 

1007 for block in content: 

1008 if not isinstance(block, dict): 

1009 continue 

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

1011 continue 

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

1013 if tool_use_id not in pending: 

1014 continue 

1015 tool_name, _invoke_ts = pending.pop(tool_use_id) 

1016 

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

1018 if _LL_VERIFY_RE.match(tool_name): 

1019 continue 

1020 

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

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

1023 error_text = _extract_error_text(raw_content) 

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

1025 

1026 if not (is_error_flag or has_traceback): 

1027 continue 

1028 

1029 returncode = 1 if is_error_flag else 0 

1030 failure_type, _reason = classify_failure(error_text, returncode) 

1031 if failure_type == FailureType.TRANSIENT: 

1032 continue 

1033 

1034 normalized_sig = _normalize_error_sig(error_text) 

1035 key = (_cwd_path, tool_name, normalized_sig) 

1036 

1037 if key in raw_clusters: 

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

1039 if session_id not in sids: 

1040 sids.append(session_id) 

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

1042 else: 

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

1044 

1045 # Apply window-days filter using the latest seen timestamp as anchor 

1046 if args.window_days is not None and latest_ts_overall: 

1047 cutoff = _parse_iso_timestamp(latest_ts_overall) - timedelta(days=args.window_days) 

1048 raw_clusters = { 

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

1050 } 

1051 

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

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

1054 

1055 clusters: list[_FailureCluster] = sorted( 

1056 [ 

1057 _FailureCluster( 

1058 tool_name=k[1], 

1059 normalized_sig=k[2], 

1060 count=v[0], 

1061 sample_error=v[1], 

1062 session_ids=v[2], 

1063 cwd_path=k[0], 

1064 ) 

1065 for k, v in raw_clusters.items() 

1066 ], 

1067 key=lambda c: c.count, 

1068 reverse=True, 

1069 ) 

1070 

1071 if not clusters: 

1072 if not args.json: 

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

1074 else: 

1075 print_json([]) 

1076 return 0 

1077 

1078 if args.capture: 

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

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

1081 

1082 if args.json: 

1083 print_json( 

1084 [ 

1085 { 

1086 "tool": c.tool_name, 

1087 "count": c.count, 

1088 "normalized_sig": c.normalized_sig, 

1089 "sample_error": c.sample_error, 

1090 "session_ids": c.session_ids, 

1091 } 

1092 for c in clusters 

1093 ] 

1094 ) 

1095 return 0 

1096 

1097 for c in clusters: 

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

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

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

1101 print(f" {sl}") 

1102 print() 

1103 

1104 return 0 

1105 

1106 

1107def _capture_failure_clusters( 

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

1109) -> int: 

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

1111 from little_loops.issue_lifecycle import create_issue_from_failure 

1112 from little_loops.issue_parser import IssueInfo 

1113 

1114 config = BRConfig(Path.cwd()) 

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

1116 created = 0 

1117 skipped_foreign = 0 

1118 

1119 for c in clusters: 

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

1121 skipped_foreign += 1 

1122 continue 

1123 stub_info = IssueInfo( 

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

1125 issue_type="bugs", 

1126 priority="P1", 

1127 issue_id=c.tool_name, 

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

1129 ) 

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

1131 if result is not None: 

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

1133 created += 1 

1134 

1135 if skipped_foreign: 

1136 logger.info( 

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

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

1139 ) 

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

1141 return 0 

1142 

1143 

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

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

1146 if args.project: 

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

1148 else: 

1149 decoded_paths = discover_all_projects(logger) 

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

1151 

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

1153 found_any_db = False 

1154 for db_path in db_paths: 

1155 result = _aggregate_skill_stats(db_path, window_days=args.window_days) 

1156 if result is None: 

1157 continue 

1158 found_any_db = True 

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

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

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

1162 

1163 if not merged: 

1164 if not found_any_db: 

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

1166 else: 

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

1168 return 0 

1169 

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

1171 if sort_key == "corrections": 

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

1173 else: 

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

1175 

1176 if args.json: 

1177 rows_json = [ 

1178 { 

1179 "skill": skill, 

1180 "invocations": counts["invocations"], 

1181 "corrections": counts["corrections"], 

1182 "correction_rate": ( 

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

1184 if counts["invocations"] > 0 

1185 else 0.0 

1186 ), 

1187 "errors": None, 

1188 "error_rate": None, 

1189 } 

1190 for skill, counts in ranked 

1191 ] 

1192 print_json(rows_json) 

1193 return 0 

1194 

1195 headers = ["Skill", "Invocations", "Corrections", "Corr%", "Errors"] 

1196 rows = [] 

1197 for skill, counts in ranked: 

1198 inv = counts["invocations"] 

1199 corr = counts["corrections"] 

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

1201 rows.append([skill, str(inv), str(corr), corr_pct, "N/A"]) 

1202 

1203 print(table(headers, rows)) 

1204 return 0 

1205 

1206 

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

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

1209 

1210 Tries in order: 

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

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

1213 Returns None if unresolvable. 

1214 """ 

1215 candidate = Path(session_ref) 

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

1217 return candidate 

1218 

1219 if db_path.exists(): 

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

1221 conn.row_factory = sqlite3.Row 

1222 try: 

1223 row = conn.execute( 

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

1225 (session_ref,), 

1226 ).fetchone() 

1227 if row and row["jsonl_path"]: 

1228 return Path(row["jsonl_path"]) 

1229 except sqlite3.OperationalError: 

1230 pass 

1231 finally: 

1232 conn.close() 

1233 

1234 return None 

1235 

1236 

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

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

1239 events: list[InvocationEvent] = [] 

1240 try: 

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

1242 for line in f: 

1243 line = line.strip() 

1244 if not line: 

1245 continue 

1246 try: 

1247 record = json.loads(line) 

1248 except json.JSONDecodeError: 

1249 continue 

1250 tool_name = _extract_tool_name(record) 

1251 if tool_name is None: 

1252 continue 

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

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

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

1256 except OSError: 

1257 pass 

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

1259 return events 

1260 

1261 

1262@dataclass 

1263class SessionDiff: 

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

1265 

1266 session_a: str 

1267 session_b: str 

1268 skills_added: list[str] 

1269 skills_removed: list[str] 

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

1271 sequence_diff: list[str] 

1272 

1273 def to_dict(self) -> dict: 

1274 return { 

1275 "session_a": self.session_a, 

1276 "session_b": self.session_b, 

1277 "skills_added": self.skills_added, 

1278 "skills_removed": self.skills_removed, 

1279 "count_deltas": self.count_deltas, 

1280 "sequence_diff": self.sequence_diff, 

1281 } 

1282 

1283 

1284def _compute_session_diff( 

1285 session_a: str, 

1286 events_a: list[InvocationEvent], 

1287 session_b: str, 

1288 events_b: list[InvocationEvent], 

1289) -> SessionDiff: 

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

1291 import difflib 

1292 from collections import Counter as _Counter 

1293 

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

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

1296 

1297 set_a = set(names_a) 

1298 set_b = set(names_b) 

1299 skills_added = sorted(set_b - set_a) 

1300 skills_removed = sorted(set_a - set_b) 

1301 

1302 counter_a: Counter = _Counter(names_a) 

1303 counter_b: Counter = _Counter(names_b) 

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

1305 for skill in sorted(set_a | set_b): 

1306 ca = counter_a.get(skill, 0) 

1307 cb = counter_b.get(skill, 0) 

1308 if ca != cb: 

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

1310 

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

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

1313 sequence_diff = list( 

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

1315 ) 

1316 

1317 return SessionDiff( 

1318 session_a=session_a, 

1319 session_b=session_b, 

1320 skills_added=skills_added, 

1321 skills_removed=skills_removed, 

1322 count_deltas=count_deltas, 

1323 sequence_diff=sequence_diff, 

1324 ) 

1325 

1326 

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

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

1329 db_path = resolve_history_db() 

1330 

1331 path_a = _resolve_session_log(args.session_a, db_path) 

1332 if path_a is None: 

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

1334 return 1 

1335 

1336 path_b = _resolve_session_log(args.session_b, db_path) 

1337 if path_b is None: 

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

1339 return 1 

1340 

1341 events_a = _events_from_jsonl(path_a) 

1342 events_b = _events_from_jsonl(path_b) 

1343 

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

1345 

1346 if args.json: 

1347 print_json(diff.to_dict()) 

1348 return 0 

1349 

1350 if ( 

1351 not diff.skills_added 

1352 and not diff.skills_removed 

1353 and not diff.count_deltas 

1354 and not diff.sequence_diff 

1355 ): 

1356 print("No behavioral differences found.") 

1357 return 0 

1358 

1359 if diff.skills_added: 

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

1361 for s in diff.skills_added: 

1362 print(f" + {s}") 

1363 

1364 if diff.skills_removed: 

1365 if diff.skills_added: 

1366 print() 

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

1368 for s in diff.skills_removed: 

1369 print(f" - {s}") 

1370 

1371 if diff.count_deltas: 

1372 print() 

1373 print("Invocation count changes:") 

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

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

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

1377 

1378 if diff.sequence_diff: 

1379 print() 

1380 print("Sequence diff:") 

1381 for line in diff.sequence_diff: 

1382 print(f" {line}") 

1383 

1384 return 0 

1385 

1386 

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

1388 

1389 

1390@dataclass 

1391class _EvalInvocation: 

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

1393 

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

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

1396 """ 

1397 

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

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

1400 session_id: str 

1401 timestamp: str 

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

1403 

1404 

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

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

1407 

1408 Mirrors ``_extract_tool_name``'s three signals but also captures the runner 

1409 kind and raw input-context text needed for EvalFixture export: 

1410 (a) queue enqueue ``/ll:<name>`` -> runner=skill, target=name 

1411 (b) user ``<command-name>/ll:<name>`` -> runner=skill, target=name 

1412 (c) assistant Bash ``ll-<tool>`` -> runner=cmd, target=<full command> 

1413 

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

1415 """ 

1416 record_type = record.get("type") 

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

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

1419 

1420 # (a) queue-operation enqueue 

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

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

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

1424 m = _QUEUE_SKILL_RE.match(content) 

1425 if m: 

1426 return _EvalInvocation("skill", m.group(1), sid, ts, content) 

1427 

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

1429 if record_type == "user": 

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

1431 if not isinstance(message, dict): 

1432 return None 

1433 content = message.get("content") 

1434 text = "" 

1435 if isinstance(content, str): 

1436 text = content 

1437 elif isinstance(content, list): 

1438 for block in content: 

1439 if isinstance(block, dict): 

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

1441 if text: 

1442 break 

1443 if text: 

1444 m = _COMMAND_NAME_SKILL_RE.search(text) 

1445 if m: 

1446 name = m.group(1) 

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

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

1449 return _EvalInvocation("skill", name, sid, ts, text) 

1450 

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

1452 if record_type == "assistant": 

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

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

1455 if isinstance(content, list): 

1456 for block in content: 

1457 if ( 

1458 isinstance(block, dict) 

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

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

1461 ): 

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

1463 if _LL_BASH_RE.search(cmd): 

1464 return _EvalInvocation("cmd", cmd, sid, ts, "") 

1465 

1466 return None 

1467 

1468 

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

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

1471 

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

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

1474 """ 

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

1476 return False 

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

1478 if not isinstance(message, dict): 

1479 return False 

1480 content = message.get("content") 

1481 if isinstance(content, list): 

1482 for block in content: 

1483 if ( 

1484 isinstance(block, dict) 

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

1486 and block.get("is_error") 

1487 ): 

1488 return True 

1489 return False 

1490 

1491 

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

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

1494 

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

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

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

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

1499 """ 

1500 if has_error: 

1501 return "failed" 

1502 if not metadata: 

1503 return "unknown" 

1504 if metadata.get("has_corrections"): 

1505 return "corrected" 

1506 return "accepted" 

1507 

1508 

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

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

1511 

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

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

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

1515 record for unredactable content (ARCHITECTURE-017). 

1516 """ 

1517 if not text: 

1518 return None, False 

1519 from little_loops.pii import redact_pii 

1520 

1521 redacted = redact_pii(text) 

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

1523 return redacted, redacted != text 

1524 

1525 

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

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

1528 

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

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

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

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

1533 """ 

1534 input_context, pii_detected = _redact_input_context(inv.input_context) 

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

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

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

1538 return { 

1539 "runner": inv.runner, 

1540 "target": inv.target, 

1541 "session_id": inv.session_id, 

1542 "timestamp": inv.timestamp, 

1543 "outcome": outcome, 

1544 "runner_args": [], 

1545 "exit_code": None, 

1546 "semantic": None, 

1547 "timeout": 120, 

1548 "input_context": input_context, 

1549 "issue_id": issue_id, 

1550 "skill_name": skill_name, 

1551 "pii_detected": pii_detected, 

1552 } 

1553 

1554 

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

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

1557 

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

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

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

1561 """ 

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

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

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

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

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

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

1568 timeout = fixture.get("timeout") 

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

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

1571 return argv 

1572 

1573 

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

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

1576 

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

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

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

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

1581 ``.ll/decisions.yaml``. 

1582 """ 

1583 from little_loops.history_reader import lookup_session_metadata 

1584 

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

1586 project_folder = get_project_folder(cwd_path) 

1587 if project_folder is None: 

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

1589 return 1 

1590 from little_loops.session_store import resolve_history_db 

1591 

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

1593 

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

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

1596 invocations: list[_EvalInvocation] = [] 

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

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

1599 for jsonl_file in jsonl_files: 

1600 try: 

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

1602 for line in f: 

1603 line = line.strip() 

1604 if not line: 

1605 continue 

1606 try: 

1607 record = json.loads(line) 

1608 except json.JSONDecodeError: 

1609 continue 

1610 if _record_has_error(record): 

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

1612 if sid: 

1613 session_has_error[sid] = True 

1614 inv = _extract_eval_invocation(record) 

1615 if inv is not None: 

1616 invocations.append(inv) 

1617 except OSError: 

1618 continue 

1619 

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

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

1622 

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

1624 fixtures: list[dict] = [] 

1625 skipped = 0 

1626 for inv in invocations: 

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

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

1629 continue 

1630 

1631 if inv.session_id not in metadata_cache: 

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

1633 outcome = _classify_outcome( 

1634 metadata_cache[inv.session_id], 

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

1636 ) 

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

1638 if outcome == "unknown": 

1639 skipped += 1 

1640 continue 

1641 

1642 fixture = _build_eval_fixture(inv, outcome) 

1643 

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

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

1646 continue 

1647 

1648 fixtures.append(fixture) 

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

1650 break 

1651 

1652 if args.json: 

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

1654 else: 

1655 import yaml 

1656 

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

1658 

1659 if args.out: 

1660 out_path = Path(args.out) 

1661 try: 

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

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

1664 except OSError as exc: 

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

1666 return 1 

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

1668 else: 

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

1670 

1671 if skipped: 

1672 print( 

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

1674 file=sys.stderr, 

1675 ) 

1676 

1677 return 0 

1678 

1679 

1680def _build_parser() -> argparse.ArgumentParser: 

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

1682 parser = argparse.ArgumentParser( 

1683 prog="ll-logs", 

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

1685 formatter_class=argparse.RawDescriptionHelpFormatter, 

1686 epilog=""" 

1687Examples: 

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

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

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

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

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

1693""", 

1694 ) 

1695 

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

1697 discover_parser = subparsers.add_parser( 

1698 "discover", 

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

1700 ) 

1701 add_json_arg(discover_parser) 

1702 

1703 tail_parser = subparsers.add_parser( 

1704 "tail", 

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

1706 ) 

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

1708 

1709 extract_parser = subparsers.add_parser( 

1710 "extract", 

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

1712 ) 

1713 target_group = extract_parser.add_mutually_exclusive_group(required=True) 

1714 target_group.add_argument( 

1715 "--project", 

1716 type=Path, 

1717 metavar="DIR", 

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

1719 ) 

1720 target_group.add_argument( 

1721 "--all", 

1722 action="store_true", 

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

1724 ) 

1725 extract_parser.add_argument( 

1726 "--cmd", 

1727 metavar="TOOL", 

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

1729 ) 

1730 

1731 sequences_parser = subparsers.add_parser( 

1732 "sequences", 

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

1734 ) 

1735 sequences_target = sequences_parser.add_mutually_exclusive_group(required=True) 

1736 sequences_target.add_argument( 

1737 "--project", 

1738 type=Path, 

1739 metavar="DIR", 

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

1741 ) 

1742 sequences_target.add_argument( 

1743 "--all", 

1744 action="store_true", 

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

1746 ) 

1747 sequences_parser.add_argument( 

1748 "--min-len", 

1749 type=int, 

1750 default=2, 

1751 metavar="N", 

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

1753 ) 

1754 sequences_parser.add_argument( 

1755 "--min-count", 

1756 type=int, 

1757 default=1, 

1758 metavar="M", 

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

1760 ) 

1761 sequences_parser.add_argument( 

1762 "--top", 

1763 type=int, 

1764 default=None, 

1765 metavar="N", 

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

1767 ) 

1768 sequences_parser.add_argument( 

1769 "--window-days", 

1770 type=int, 

1771 default=None, 

1772 metavar="D", 

1773 help="Only consider records within D days of latest record", 

1774 ) 

1775 add_json_arg(sequences_parser) 

1776 

1777 stats_parser = subparsers.add_parser( 

1778 "stats", 

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

1780 ) 

1781 stats_target = stats_parser.add_mutually_exclusive_group(required=True) 

1782 stats_target.add_argument( 

1783 "--project", 

1784 type=Path, 

1785 metavar="DIR", 

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

1787 ) 

1788 stats_target.add_argument( 

1789 "--all", 

1790 action="store_true", 

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

1792 ) 

1793 stats_parser.add_argument( 

1794 "--window-days", 

1795 type=int, 

1796 default=None, 

1797 metavar="D", 

1798 help="Only consider records within D days of latest record", 

1799 ) 

1800 stats_parser.add_argument( 

1801 "--sort", 

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

1803 default="freq", 

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

1805 ) 

1806 add_json_arg(stats_parser) 

1807 

1808 scan_failures_parser = subparsers.add_parser( 

1809 "scan-failures", 

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

1811 ) 

1812 scan_failures_target = scan_failures_parser.add_mutually_exclusive_group(required=True) 

1813 scan_failures_target.add_argument( 

1814 "--project", 

1815 type=Path, 

1816 metavar="DIR", 

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

1818 ) 

1819 scan_failures_target.add_argument( 

1820 "--all", 

1821 action="store_true", 

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

1823 ) 

1824 scan_failures_parser.add_argument( 

1825 "--window-days", 

1826 type=int, 

1827 default=None, 

1828 metavar="D", 

1829 help="Only consider records within D days of latest record", 

1830 ) 

1831 scan_failures_parser.add_argument( 

1832 "--capture", 

1833 action="store_true", 

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

1835 ) 

1836 scan_failures_parser.add_argument( 

1837 "--capture-foreign", 

1838 action="store_true", 

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

1840 ) 

1841 add_json_arg(scan_failures_parser) 

1842 

1843 dead_skills_parser = subparsers.add_parser( 

1844 "dead-skills", 

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

1846 ) 

1847 dead_skills_target = dead_skills_parser.add_mutually_exclusive_group(required=True) 

1848 dead_skills_target.add_argument( 

1849 "--project", 

1850 type=Path, 

1851 metavar="DIR", 

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

1853 ) 

1854 dead_skills_target.add_argument( 

1855 "--all", 

1856 action="store_true", 

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

1858 ) 

1859 dead_skills_parser.add_argument( 

1860 "--window-days", 

1861 type=int, 

1862 default=None, 

1863 metavar="D", 

1864 help="Only consider records within D days of latest record", 

1865 ) 

1866 dead_skills_parser.add_argument( 

1867 "--threshold", 

1868 type=int, 

1869 default=3, 

1870 metavar="N", 

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

1872 ) 

1873 add_json_arg(dead_skills_parser) 

1874 

1875 diff_parser = subparsers.add_parser( 

1876 "diff", 

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

1878 ) 

1879 diff_parser.add_argument( 

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

1881 ) 

1882 diff_parser.add_argument( 

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

1884 ) 

1885 add_json_arg(diff_parser) 

1886 

1887 eval_export_parser = subparsers.add_parser( 

1888 "eval-export", 

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

1890 ) 

1891 eval_export_parser.add_argument( 

1892 "--project", 

1893 type=Path, 

1894 metavar="DIR", 

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

1896 ) 

1897 eval_export_parser.add_argument( 

1898 "--skill", 

1899 metavar="NAME", 

1900 help="Filter by skill name", 

1901 ) 

1902 eval_export_parser.add_argument( 

1903 "--issue", 

1904 metavar="ID", 

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

1906 ) 

1907 eval_export_parser.add_argument( 

1908 "--limit", 

1909 type=int, 

1910 default=0, 

1911 metavar="N", 

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

1913 ) 

1914 eval_export_parser.add_argument( 

1915 "--out", 

1916 metavar="PATH", 

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

1918 ) 

1919 eval_export_parser.add_argument( 

1920 "--json", 

1921 action="store_true", 

1922 help="JSON output instead of YAML (default: YAML)", 

1923 ) 

1924 

1925 return parser 

1926 

1927 

1928def _parse_args() -> argparse.Namespace: 

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

1930 return _build_parser().parse_args() 

1931 

1932 

1933def main_logs() -> int: 

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

1935 

1936 Returns: 

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

1938 """ 

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

1940 configure_output() 

1941 logger = Logger(use_color=use_color_enabled()) 

1942 

1943 parser = _build_parser() 

1944 args = parser.parse_args() 

1945 

1946 if not args.command: 

1947 parser.print_help() 

1948 return 1 

1949 

1950 if args.command == "discover": 

1951 projects = discover_all_projects(logger) 

1952 if args.json: 

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

1954 else: 

1955 for path in projects: 

1956 print(path) 

1957 return 0 

1958 

1959 if args.command == "tail": 

1960 config = BRConfig(Path.cwd()) 

1961 loops_dir = Path(config.loops.loops_dir) 

1962 return _cmd_tail(args, loops_dir) 

1963 

1964 if args.command == "extract": 

1965 return _cmd_extract(args, logger) 

1966 

1967 if args.command == "sequences": 

1968 return _cmd_sequences(args, logger) 

1969 

1970 if args.command == "stats": 

1971 return _cmd_stats(args, logger) 

1972 

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

1974 return _cmd_scan_failures(args, logger) 

1975 

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

1977 return _cmd_dead_skills(args, logger) 

1978 

1979 if args.command == "diff": 

1980 return _cmd_diff(args, logger) 

1981 

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

1983 return _cmd_eval_export(args) 

1984 

1985 return 1