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

2

3 from __future__ import annotations

4

5 import argparse

6 import json

7 import re

8 import shutil

9 import sqlite3

10 import sys

11 import time

12 from collections import Counter, defaultdict

13 from dataclasses import dataclass, field

14 from datetime import UTC, datetime, timedelta

15 from pathlib import Path

16

17 from little_loops.analytics.association import compute_lift, compute_pmi

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

19 _format_history_event,

20)

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

22 from little_loops.cli_args import add_json_arg

23 from little_loops.config import BRConfig

24 from little_loops.logger import Logger

25 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context, resolve_history_db

26 from little_loops.user_messages import get_project_folder

27

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

29 BRIDGE_MARKER = "Bridged from `commands/"

30

31

32 def _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

82 def _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

105 def _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

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

197 def _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

221 class 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

229 def _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

292 class _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

304 def _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

367 def _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

373 def _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

394 class 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

405 class 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

431 def _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

459 def _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

511 def _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

552 def _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

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

661 def _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

720 def _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

755 def _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

823 def _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

890 def _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

944 def _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

962 def _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

973 def _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

984 def _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

1000 class _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

1011 def _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

1189 def _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

1226 def _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

1293 def _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

1323 def _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

1349 class 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

1370 def _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

1413 def _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

1477 class _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

1491 def _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

1511 def _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

1534 def _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

1551 def _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

1568 def _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

1597 def _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

1616 def _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

1720 def _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="""

1727 Examples:

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

1973 def _parse_args() -> argparse.Namespace:

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

1975 return _build_parser().parse_args()

1976

1977

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