1"""ll-loop info subcommands: list, history, show."""

2

3 from __future__ import annotations

4

5 import argparse

6 import os

7 from datetime import datetime

8 from pathlib import Path

9 from typing import Any

10

11 from little_loops.cli.loop._helpers import (

12 get_builtin_loops_dir,

13 load_loop_with_spec,

14 resolve_loop_path,

15)

16 from little_loops.cli.loop.diagram_modes import resolve_facets

17 from little_loops.cli.loop.layout import ( # noqa: F401

18 _EDGE_LABEL_COLORS,

19 _box_inner_lines,

20 _colorize_diagram_labels,

21 _colorize_label,

22 _render_fsm_diagram,

23)

24 from little_loops.cli.output import colorize, print_json, strip_ansi, terminal_width

25 from little_loops.fsm import is_runnable_loop

26 from little_loops.fsm.fragments import resolve_inheritance

27 from little_loops.fsm.schema import FSMLoop, StateConfig

28 from little_loops.fsm.validation import load_and_validate

29 from little_loops.logger import Logger

30

31

32 def _load_loop_meta(path: Path) -> dict[str, Any]:

33 """Return metadata from a loop YAML file (description, category, labels)."""

34 import yaml

35

36 try:

37 with open(path) as f:

38 spec = yaml.safe_load(f) or {}

39 try:

40 spec = resolve_inheritance(spec, path.parent)

41 except Exception:

42 pass

43 desc_raw = spec.get("description", "") or ""

44 if desc_raw.strip():

45 raw_lines = desc_raw.splitlines()

46 desc = raw_lines[0].rstrip()

47 if len(raw_lines) > 1:

48 desc += "…"

49 else:

50 desc = ""

51 category = spec.get("category", "") or ""

52 labels: list[str] = spec.get("labels", []) or []

53 visibility = spec.get("visibility", "public") or "public"

54 if visibility not in ("public", "internal", "example"):

55 visibility = "public"

56 return {

57 "description": desc,

58 "category": category,

59 "labels": labels,

60 "visibility": visibility,

61 }

62 except Exception:

63 return {"description": "", "category": "", "labels": [], "visibility": "public"}

64

65

66 def cmd_list(

67 args: argparse.Namespace,

68 loops_dir: Path,

69) -> int:

70 """List loops."""

71 status_filter = getattr(args, "status", None)

72 if getattr(args, "running", False) or status_filter:

73 from little_loops.fsm.persistence import list_running_loops

74

75 states = list_running_loops(loops_dir)

76 if status_filter:

77 states = [s for s in states if s.status == status_filter]

78 if not states:

79 if status_filter:

80 print(f"No loops with status: {status_filter}")

81 return 1

82 print("No running loops")

83 return 0

84 if getattr(args, "json", False):

85 print_json([s.to_dict() for s in states])

86 return 0

87 print(colorize("Running loops:", "1"))

88 _STATUS_COLORS = {"running": "32", "interrupted": "33", "stopped": "2", "starting": "33"}

89

90 # Group by loop_name to avoid duplicate rows for multi-instance loops

91 from collections import defaultdict

92

93 groups: dict[str, list] = defaultdict(list)

94 for state in states:

95 groups[state.loop_name].append(state)

96

97 for loop_name_key, group_states in groups.items():

98 if len(group_states) == 1:

99 state = group_states[0]

100 elapsed_s = (state.accumulated_ms or 0) // 1000

101 elapsed_str = (

102 f"{elapsed_s}s" if elapsed_s < 60 else f"{elapsed_s // 60}m {elapsed_s % 60}s"

103 )

104 name_str = colorize(state.loop_name, "1")

105 state_str = colorize(state.current_state, "34")

106 status_color = _STATUS_COLORS.get(state.status, "2")

107 display_status = "paused" if state.status == "interrupted" else state.status

108 status_str = colorize(f"[{display_status}]", status_color)

109 elapsed_colored = colorize(elapsed_str, "2")

110 print(

111 f" {name_str}: {state_str} (iteration {state.iteration})"

112 f" {status_str} {elapsed_colored}"

113 )

114 else:

115 # Multiple instances: show a grouped summary

116 name_str = colorize(loop_name_key, "1")

117 statuses = ", ".join(

118 colorize(

119 f"[{'paused' if s.status == 'interrupted' else s.status}]",

120 _STATUS_COLORS.get(s.status, "2"),

121 )

122 for s in group_states

123 )

124 count_str = colorize(f"({len(group_states)} instances)", "2")

125 print(f" {name_str}: {count_str} {statuses}")

126 return 0

127

128 builtin_only = getattr(args, "builtin", False)

129

130 def _rel_key(path: Path, base: Path) -> str:

131 """Relative-path identifier matching what `ll-loop run` accepts."""

132 return str(path.relative_to(base).with_suffix(""))

133

134 # Collect project loops (skipped when --builtin is set). Recurse into

135 # subdirectories (e.g. oracles/) and filter to runnable FSM definitions so

136 # library fragments under loops/lib/ stay hidden.

137 project_names: set[str] = set()

138 yaml_files: list[Path] = []

139 if not builtin_only and loops_dir.exists():

140 yaml_files = sorted(p for p in loops_dir.rglob("*.yaml") if is_runnable_loop(p))

141 project_names = {_rel_key(p, loops_dir) for p in yaml_files}

142

143 # Collect built-in loops (excluding those overridden by a project loop at

144 # the same relative path).

145 builtin_dir = get_builtin_loops_dir()

146 builtin_files: list[Path] = []

147 if builtin_dir.exists():

148 builtin_files = [

149 f

150 for f in sorted(builtin_dir.rglob("*.yaml"))

151 if is_runnable_loop(f) and _rel_key(f, builtin_dir) not in project_names

152 ]

153

154 if not yaml_files and not builtin_files:

155 if getattr(args, "json", False):

156 print_json([])

157 return 0

158 print("No loops available")

159 return 0

160

161 # Build combined metadata list

162 all_loops: list[dict[str, Any]] = []

163 for path in yaml_files:

164 meta = _load_loop_meta(path)

165 all_loops.append(

166 {"name": _rel_key(path, loops_dir), "path": path, "builtin": False, **meta}

167 )

168 for path in builtin_files:

169 meta = _load_loop_meta(path)

170 all_loops.append(

171 {"name": _rel_key(path, builtin_dir), "path": path, "builtin": True, **meta}

172 )

173

174 # Apply --category filter

175 category_filter = getattr(args, "category", None)

176 if category_filter:

177 all_loops = [lp for lp in all_loops if lp["category"] == category_filter]

178

179 # Apply --label filter (action="append" → list or None)

180 label_filters: list[str] = getattr(args, "label", None) or []

181 if label_filters:

182 all_loops = [

183 lp

184 for lp in all_loops

185 if any(lf.lower() in [lb.lower() for lb in lp["labels"]] for lf in label_filters)

186 ]

187

188 # Apply visibility filter. The new --visibility flag is the canonical interface;

189 # the legacy --all / --internal / --examples flags remain supported for backwards

190 # compatibility and map onto the same underlying tiers.

191 visibility_flag: str | None = getattr(args, "visibility", None)

192 show_all = getattr(args, "all", False) or visibility_flag == "all"

193 show_internal = getattr(args, "internal", False) or visibility_flag == "internal"

194 show_examples = getattr(args, "examples", False) or visibility_flag == "example"

195 # When --visibility public is explicit, enforce it even if other legacy flags are set.

196 show_public_only = visibility_flag == "public"

197 hidden_counts: dict[str, int] = {"internal": 0, "example": 0}

198 if not show_all:

199 if show_public_only:

200 for lp in all_loops:

201 vis = lp.get("visibility", "public")

202 if vis in hidden_counts:

203 hidden_counts[vis] += 1

204 all_loops = [lp for lp in all_loops if lp.get("visibility", "public") == "public"]

205 elif show_internal or show_examples:

206 wanted = set()

207 if show_internal:

208 wanted.add("internal")

209 if show_examples:

210 wanted.add("example")

211 all_loops = [lp for lp in all_loops if lp.get("visibility", "public") in wanted]

212 else:

213 for lp in all_loops:

214 vis = lp.get("visibility", "public")

215 if vis in hidden_counts:

216 hidden_counts[vis] += 1

217 all_loops = [lp for lp in all_loops if lp.get("visibility", "public") == "public"]

218

219 if not all_loops:

220 if getattr(args, "json", False):

221 print_json([])

222 return 0

223 print("No loops match the given filters")

224 return 0

225

226 if getattr(args, "json", False):

227 items: list[dict[str, Any]] = []

228 for lp in all_loops:

229 item: dict[str, Any] = {

230 "name": lp["name"],

231 "path": str(lp["path"]),

232 "category": lp["category"],

233 "labels": lp["labels"],

234 "visibility": lp.get("visibility", "public"),

235 "description": lp["description"],

236 }

237 if lp["builtin"]:

238 item["built_in"] = True

239 items.append(item)

240 print_json(items)

241 return 0

242

243 # Human-readable: group by category

244 buckets: dict[str, list[dict[str, Any]]] = {}

245 for lp in all_loops:

246 cat = lp["category"] or "uncategorized"

247 if cat not in buckets:

248 buckets[cat] = []

249 buckets[cat].append(lp)

250

251 # Sort categories; "uncategorized" always last

252 sorted_cats = sorted(c for c in buckets if c != "uncategorized")

253 if "uncategorized" in buckets:

254 sorted_cats.append("uncategorized")

255

256 # Cap name column so outlier names don't crush the description budget.

257 _MAX_NAME_COL = 32

258 _MAX_LABELS = 2

259 max_name_len = max((len(lp["name"]) for lp in all_loops), default=0)

260 name_col = min(max_name_len, _MAX_NAME_COL) + 2

261 tw = terminal_width()

262

263 # Summary header

264 n_project = sum(1 for lp in all_loops if not lp["builtin"])

265 n_builtin = len(all_loops) - n_project

266 summary = f" {len(all_loops)} loops · {len(buckets)} categories"

267 if n_project:

268 summary += f" · {n_project} project, {n_builtin} built-in"

269 print(colorize(summary, "2"))

270 print()

271

272 cats_printed = False

273 for cat in sorted_cats:

274 group = buckets[cat]

275 if cats_printed:

276 print() # blank line between category groups

277 cats_printed = True

278 cat_title = cat.replace("-", " ").title()

279 print(colorize(f" ▸ {cat_title} ({len(group)})", "36;1"))

280 for lp in group:

281 # Name: project loops get bold cyan, built-in loops get dimmer cyan

282 name_color = "36" if lp["builtin"] else "36;1"

283 display_name = (

284 _truncate(lp["name"], _MAX_NAME_COL)

285 if len(lp["name"]) > _MAX_NAME_COL

286 else lp["name"]

287 )

288 name_str = colorize(display_name.ljust(name_col), name_color)

289

290 # Suffix: cap labels; mark only project loops (built-in is the default)

291 suffix_parts: list[str] = []

292 labels = lp["labels"] or []

293 visible_labels = labels[:_MAX_LABELS]

294 hidden_label_count = len(labels) - _MAX_LABELS

295 for label in visible_labels:

296 suffix_parts.append(colorize(f"[{label}]", "2"))

297 if hidden_label_count > 0:

298 suffix_parts.append(colorize(f"[+{hidden_label_count}]", "2"))

299 if not lp["builtin"]:

300 suffix_parts.append(colorize("●", "36;1"))

301

302 if suffix_parts:

303 suffix_raw = " " + " ".join(suffix_parts)

304 suffix_visible = len(strip_ansi(suffix_raw))

305 else:

306 suffix_raw = ""

307 suffix_visible = 0

308

309 # Available width for description: indent + name_col + " " + desc + suffix

310 avail = tw - 2 - name_col - 2 - suffix_visible

311 desc_text = lp["description"] or ""

312 if desc_text and avail < len(desc_text):

313 desc_text = _truncate(desc_text, max(avail, 20))

314 desc_str = f" {colorize(desc_text, '2')}" if desc_text else ""

315

316 print(f" {name_str}{desc_str}{suffix_raw}")

317

318 # Footer: surface hidden tiers and point users at the natural-language router

319 # rather than asking them to scan dozens of loops.

320 print()

321 if n_project:

322 print(colorize(" ● = project loop (overrides built-in)", "2"))

323 hidden_bits: list[str] = []

324 if hidden_counts.get("internal"):

325 hidden_bits.append(f"{hidden_counts['internal']} internal (--internal)")

326 if hidden_counts.get("example"):

327 hidden_bits.append(f"{hidden_counts['example']} example (--examples)")

328 if hidden_bits:

329 print(colorize(f" Hidden: {', '.join(hidden_bits)} · all with --all", "2"))

330 print(

331 colorize(

332 ' Not sure which loop? `ll-loop run loop-router --input goal="<what you want>"`',

333 "2",

334 )

335 )

336 return 0

337

338

339_EVENT_TYPE_WIDTH = 16 # width of "handoff_detected"

340

341

342 def _truncate(text: str, max_len: int) -> str:

343 """Truncate text to max_len with ellipsis."""

344 if max_len < 1:

345 return ""

346 if len(text) <= max_len:

347 return text

348 return text[: max_len - 1] + "\u2026"

349

350

351 def _format_history_event(

352 event: dict[str, Any], verbose: bool, width: int, full: bool = False

353) -> str | None:

354 """Format a single history event. Returns None to skip the event."""

355 raw_ts = event.get("ts", "")

356 try:

357 ts = datetime.fromisoformat(raw_ts).strftime("%H:%M:%S")

358 except (ValueError, TypeError):

359 ts = raw_ts[:8] if len(raw_ts) >= 8 else raw_ts.ljust(8)

360

361 event_type = event.get("event", "unknown")

362

363 if event_type == "action_output" and not verbose:

364 return None

365

366 ts_str = colorize(ts, "2")

367 etype_padded = event_type.ljust(_EVENT_TYPE_WIDTH)

368 etype_color = "0"

369 detail = ""

370 extra_lines: list[str] = []

371

372 # Indentation prefix for verbose sub-lines (aligns under event detail column)

373 _indent = " " * (8 + 2 + _EVENT_TYPE_WIDTH + 2)

374

375 if event_type == "loop_start":

376 etype_color = "1"

377 detail = event.get("loop", "")

378

379 elif event_type == "loop_complete":

380 etype_color = "1"

381 final_state = event.get("final_state", "")

382 iterations = event.get("iterations", "")

383 terminated_by = event.get("terminated_by", "")

384 detail = f"{final_state} {iterations} iter [{terminated_by}]"

385 if error := event.get("error"):

386 detail += f" {colorize(error, '31')}"

387

388 elif event_type == "loop_resume":

389 etype_color = "1"

390 from_state = event.get("from_state", "")

391 iteration = event.get("iteration", "")

392 detail = f"from={from_state} iter={iteration}"

393

394 elif event_type == "state_enter":

395 etype_color = "34"

396 state = event.get("state", "")

397 iteration = event.get("iteration", "")

398 detail = f"{colorize(state, '1')} (iter {iteration})"

399

400 elif event_type == "action_start":

401 action = event.get("action", "")

402 is_prompt = event.get("is_prompt", False)

403 kind_label = "prompt" if is_prompt else "shell"

404 kind_str = colorize(f"[{kind_label}]", "2")

405 first_line = (

406 next((ln.strip() for ln in action.splitlines() if ln.strip()), "")

407 if is_prompt

408 else action

409 )

410 avail = width - 8 - 2 - _EVENT_TYPE_WIDTH - 2 - len(kind_label) - 2 - 2

411 detail = f"{_truncate(first_line, max(avail, 20))} {kind_str}"

412

413 elif event_type == "action_output":

414 # Only reached in verbose mode

415 etype_color = "2"

416 detail = colorize("\u2502 " + event.get("line", ""), "2")

417

418 elif event_type == "action_complete":

419 exit_code = event.get("exit_code", 0)

420 duration_ms = event.get("duration_ms", 0)

421 if exit_code == 0:

422 etype_color = "2"

423 status_str = colorize("\u2713", "32")

424 else:

425 etype_color = "38;5;208"

426 status_str = colorize(f"\u2717 exit={exit_code}", "38;5;208")

427 detail = f"{status_str} {duration_ms}ms"

428 is_prompt = event.get("is_prompt", False)

429 session_jsonl = event.get("session_jsonl") if is_prompt else None

430 if session_jsonl:

431 session_display = session_jsonl if verbose else os.path.basename(session_jsonl)

432 detail += f" session={colorize(session_display, '2')}"

433 if verbose:

434 output_preview = event.get("output_preview", "")

435 if output_preview:

436 avail_w = width - len(_indent) - 2

437 preview_text = (

438 output_preview if full else _truncate(output_preview, max(avail_w, 40))

439 )

440 for preview_line in preview_text.splitlines()[:5]:

441 extra_lines.append(colorize(_indent + "\u2502 " + preview_line, "2"))

442

443 elif event_type == "evaluate":

444 verdict = event.get("verdict", "")

445 confidence = event.get("confidence", "")

446 reason = event.get("reason", "")

447 if verdict == "yes":

448 etype_color = "32"

449 verdict_str = colorize("\u2713 yes", "32")

450 else:

451 etype_color = "38;5;208"

452 verdict_str = colorize(f"\u2717 {verdict}", "38;5;208")

453 conf_part = f" confidence={confidence}" if confidence != "" else ""

454 avail = width - 8 - 2 - _EVENT_TYPE_WIDTH - 2 - len("\u2713 yes") - len(conf_part) - 2

455 reason_part = f" {_truncate(reason, max(avail, 20))}" if reason else ""

456 detail = f"{verdict_str}{conf_part}{reason_part}"

457 if verbose:

458 llm_model = event.get("llm_model", "")

459 llm_latency_ms = event.get("llm_latency_ms", "")

460 llm_prompt = event.get("llm_prompt", "")

461 llm_raw_output = event.get("llm_raw_output", "")

462 if llm_model or llm_prompt:

463 meta_parts = []

464 if llm_model:

465 meta_parts.append(f"model={llm_model}")

466 if llm_latency_ms != "":

467 meta_parts.append(f"latency={llm_latency_ms}ms")

468 meta_str = " ".join(meta_parts)

469 extra_lines.append(

470 colorize(_indent + colorize("LLM Call", "2") + " " + meta_str, "2")

471 )

472 avail_w = width - len(_indent) - len("Prompt: ") - 2

473 if llm_prompt:

474 prompt_text = llm_prompt if full else _truncate(llm_prompt, max(avail_w, 40))

475 extra_lines.append(colorize(_indent + "Prompt: " + prompt_text, "2"))

476 if llm_raw_output:

477 resp_text = (

478 llm_raw_output if full else _truncate(llm_raw_output, max(avail_w, 40))

479 )

480 extra_lines.append(colorize(_indent + "Response: " + resp_text, "2"))

481

482 elif event_type == "route":

483 etype_color = "2"

484 from_state = event.get("from", "")

485 to_state = event.get("to", "")

486 detail = f"{from_state} \u2192 {colorize(to_state, '34')}"

487

488 elif event_type == "handoff_detected":

489 etype_color = "33"

490 detail = f"state={event.get('state', '')} iter={event.get('iteration', '')}"

491

492 else:

493 details = {k: v for k, v in event.items() if k not in ("event", "ts")}

494 detail = " ".join(f"{k}={v}" for k, v in details.items())

495

496 etype_str = colorize(etype_padded, etype_color)

497 main_line = f"{ts_str} {etype_str} {detail}"

498 if extra_lines:

499 return "\n".join([main_line] + extra_lines)

500 return main_line

501

502

503 def _format_duration(ms: int) -> str:

504 """Format milliseconds as a human-readable duration."""

505 if ms < 1000:

506 return f"{ms}ms"

507 s = ms // 1000

508 if s < 60:

509 return f"{s}s"

510 m, s = divmod(s, 60)

511 if m < 60:

512 return f"{m}m{s:02d}s"

513 h, m = divmod(m, 60)

514 return f"{h}h{m:02d}m{s:02d}s"

515

516

517 def _list_archived_runs(loop_name: str, loops_dir: Path, as_json: bool) -> int:

518 """List archived runs for a loop."""

519 import json as _json

520

521 from little_loops.fsm.persistence import HISTORY_DIR, LoopState

522

523 history_base = loops_dir / HISTORY_DIR

524 if not history_base.exists():

525 print(f"No history for: {loop_name}")

526 return 0

527

528 # Flat layout: run dirs are <run_id>-<loop_name> directly under .history/

529 suffix = f"-{loop_name}"

530 runs: list[tuple[str, LoopState | None]] = []

531 for run_dir in sorted(history_base.iterdir(), key=lambda d: d.name, reverse=True):

532 if not run_dir.is_dir() or not run_dir.name.endswith(suffix):

533 continue

534 run_id = run_dir.name[: -len(suffix)]

535 state_file = run_dir / "state.json"

536 state: LoopState | None = None

537 if state_file.exists():

538 try:

539 data = _json.loads(state_file.read_text())

540 state = LoopState.from_dict(data)

541 except (ValueError, KeyError):

542 pass

543 runs.append((run_id, state))

544

545 if not runs:

546 print(f"No history for: {loop_name}")

547 return 0

548

549 if as_json:

550 print(

551 _json.dumps(

552 [

553 {

554 "run_id": rid,

555 "status": s.status if s else None,

556 "started_at": s.started_at if s else None,

557 "iterations": s.iteration if s else None,

558 "duration_ms": s.accumulated_ms if s else None,

559 }

560 for rid, s in runs

561 ],

562 indent=2,

563 )

564 )

565 return 0

566

567 status_colors = {

568 "completed": "\033[32m",

569 "failed": "\033[31m",

570 "interrupted": "\033[33m",

571 "awaiting_continuation": "\033[36m",

572 "timed_out": "\033[33m",

573 "running": "\033[34m",

574 }

575 reset = "\033[0m"

576

577 print(f"Archived runs for: {loop_name} ({len(runs)} total)")

578 print()

579

580 for run_id, state in runs:

581 if state is not None:

582 color = status_colors.get(state.status, "")

583 status_str = f"{color}{state.status}{reset}"

584 duration_str = _format_duration(state.accumulated_ms) if state.accumulated_ms else "?"

585 started = state.started_at[:19].replace("T", " ") if state.started_at else "?"

586 iters = f"{state.iteration} iters"

587 else:

588 status_str = "unknown"

589 duration_str = "?"

590 started = "?"

591 iters = "?"

592 print(f" {run_id} {status_str} {started} {iters} {duration_str}")

593

594 print()

595 print(f"To view events: ll-loop history {loop_name} <run-id>")

596 return 0

597

598

599 def cmd_history(

600 loop_name: str,

601 run_id: str | None,

602 args: argparse.Namespace,

603 loops_dir: Path,

604) -> int:

605 """Show loop history.

606

607 Without run_id: lists all archived runs with status and duration.

608 With run_id: shows events for that specific archived run.

609 """

610 tail = getattr(args, "tail", 50)

611 full = getattr(args, "full", False)

612 verbose = getattr(args, "verbose", False) or full

613 as_json = getattr(args, "json", False)

614

615 if run_id is None:

616 return _list_archived_runs(loop_name, loops_dir, as_json)

617

618 # Show events for a specific archived run

619 from little_loops.fsm.persistence import get_archived_events

620

621 events = get_archived_events(loop_name, run_id, loops_dir)

622

623 if not events:

624 print(f"No events found for run {run_id} of loop {loop_name}")

625 return 1

626

627 w = terminal_width()

628 if not verbose:

629 events = [e for e in events if e.get("event") != "action_output"]

630

631 # Apply optional filters (before --tail slice)

632 event_filter = getattr(args, "event", None)

633 if event_filter:

634 events = [e for e in events if e.get("event") == event_filter]

635

636 state_filter = getattr(args, "state", None)

637 if state_filter:

638 events = [

639 e

640 for e in events

641 if (

642 e.get("state") == state_filter

643 or e.get("from") == state_filter

644 or e.get("to") == state_filter

645 )

646 ]

647

648 since_str = getattr(args, "since", None)

649 if since_str:

650 from datetime import timedelta

651

652 from little_loops.text_utils import parse_duration

653

654 cutoff = datetime.now() - timedelta(seconds=parse_duration(since_str))

655 events = [

656 e

657 for e in events

658 if datetime.fromisoformat(e["ts"].replace("Z", "+00:00")).replace(tzinfo=None) >= cutoff

659 ]

660

661 if as_json:

662 print_json(events[-tail:])

663 return 0

664 for event in events[-tail:]:

665 line = _format_history_event(event, verbose, w, full=full)

666 if line is not None:

667 print(line)

668

669 return 0

670

671

672 def cmd_audit_meta(loop_name: str, args: argparse.Namespace, loops_dir: Path) -> int:

673 """Summarize meta-eval.jsonl agreement stats from all archived runs of a loop.

674

675 Reads meta-eval.jsonl from each archived run, computes:

676 - Total iterations with llm_structured evaluate events

677 - Agreement rate (agreed / total)

678 - Mean diff size (files_changed) per verdict

679 - Divergence flags:

680 - agreed=false streak >=3 → "LLM optimistic drift detected"

681 - agreed=true with files_changed==0 streak >=3 → "Trivial agreement detected"

682

683 Returns 0 if no flags triggered, 1 if any threshold crossed.

684 """

685 import json as _json

686

687 from little_loops.fsm.persistence import HISTORY_DIR

688

689 history_root = loops_dir / HISTORY_DIR

690 if not history_root.exists():

691 print(f"No history for: {loop_name}")

692 return 0

693

694 suffix = f"-{loop_name}"

695 all_entries: list[dict[str, Any]] = []

696

697 for run_dir in sorted(history_root.iterdir(), key=lambda d: d.name):

698 if not run_dir.is_dir() or not run_dir.name.endswith(suffix):

699 continue

700 meta_eval_file = run_dir / "meta-eval.jsonl"

701 if not meta_eval_file.exists():

702 continue

703 for line in meta_eval_file.read_text(encoding="utf-8").splitlines():

704 line = line.strip()

705 if line:

706 try:

707 all_entries.append(_json.loads(line))

708 except _json.JSONDecodeError:

709 pass

710

711 if not all_entries:

712 print(f"No meta-eval data for: {loop_name}")

713 return 0

714

715 total = len(all_entries)

716 agreed_count = sum(1 for e in all_entries if e.get("agreed") is True)

717 agreement_rate = agreed_count / total if total else 0.0

718

719 # Mean diff size per verdict

720 agreed_sizes = [

721 e.get("diff_stats", {}).get("files_changed", 0) or 0

722 for e in all_entries

723 if e.get("agreed") is True

724 ]

725 disagreed_sizes = [

726 e.get("diff_stats", {}).get("files_changed", 0) or 0

727 for e in all_entries

728 if e.get("agreed") is False

729 ]

730 mean_agreed = sum(agreed_sizes) / len(agreed_sizes) if agreed_sizes else 0.0

731 mean_disagreed = sum(disagreed_sizes) / len(disagreed_sizes) if disagreed_sizes else 0.0

732

733 # Detect streaks

734 flags: list[str] = []

735 optimistic_streak = 0

736 trivial_streak = 0

737 max_optimistic = 0

738 max_trivial = 0

739

740 for entry in all_entries:

741 agreed = entry.get("agreed")

742 files_changed = (entry.get("diff_stats") or {}).get("files_changed", 0) or 0

743

744 if agreed is False:

745 optimistic_streak += 1

746 trivial_streak = 0

747 elif agreed is True and files_changed == 0:

748 trivial_streak += 1

749 optimistic_streak = 0

750 else:

751 optimistic_streak = 0

752 trivial_streak = 0

753

754 max_optimistic = max(max_optimistic, optimistic_streak)

755 max_trivial = max(max_trivial, trivial_streak)

756

757 if max_optimistic >= 3:

758 flags.append(f"LLM optimistic drift detected (streak={max_optimistic})")

759 if max_trivial >= 3:

760 flags.append(f"Trivial agreement detected (streak={max_trivial})")

761

762 as_json = getattr(args, "json", False)

763 if as_json:

764 result = {

765 "loop": loop_name,

766 "total_entries": total,

767 "agreed_count": agreed_count,

768 "agreement_rate": round(agreement_rate, 4),

769 "mean_files_changed_when_agreed": round(mean_agreed, 2),

770 "mean_files_changed_when_disagreed": round(mean_disagreed, 2),

771 "max_optimistic_streak": max_optimistic,

772 "max_trivial_streak": max_trivial,

773 "flags": flags,

774 }

775 print_json(result)

776 else:

777 print(f"Meta-eval audit: {loop_name}")

778 print(f" Total entries: {total}")

779 print(f" Agreed: {agreed_count}/{total} ({agreement_rate:.0%})")

780 print(f" Mean Δfiles agreed: {mean_agreed:.1f}")

781 print(f" Mean Δfiles disagreed: {mean_disagreed:.1f}")

782 if flags:

783 print()

784 for flag in flags:

785 print(f" ⚠ {flag}")

786 else:

787 print(" No divergence flags.")

788

789 if as_json:

790 return 0 # let caller inspect JSON for flags; non-zero kills provider.runCli

791 return 1 if flags else 0

792

793

794 def cmd_diagnose_evaluators(loop_name: str, args: argparse.Namespace, loops_dir: Path) -> int:

795 """Detect non-discriminating evaluators from run history.

796

797 Walks .loops/.history/*-{loop_name}/events.jsonl, computes per-state

798 Bernoulli variance p*(1-p) on verdicts, flags states below threshold

799 with pattern-matched recommendations.

800

801 Returns 0 if no states flagged, 1 if any low-variance evaluators found.

802 """

803 from little_loops.analytics.variance import compute_evaluator_variance

804

805 threshold = getattr(args, "threshold", 0.05)

806 min_runs = getattr(args, "min_runs", 10)

807 as_json = getattr(args, "json", False)

808

809 report = compute_evaluator_variance(loop_name, loops_dir, threshold, min_runs)

810

811 if report is None:

812 if not (loops_dir / ".history").exists():

813 print(f"No history for: {loop_name}")

814 elif len(list((loops_dir / ".history").glob(f"*-{loop_name}"))) < min_runs:

815 runs_found = len(list((loops_dir / ".history").glob(f"*-{loop_name}")))

816 print(

817 f"Insufficient history for: {loop_name} "

818 f"(found {runs_found} run(s), need {min_runs})"

819 )

820 else:

821 print(f"No evaluate events for: {loop_name}")

822 return 0

823

824 flagged = [s for s in report.states if s.variance < threshold]

825

826 if as_json:

827 print_json(report.to_dict())

828 else:

829 print(f"Evaluator Variance Report (n={report.total_runs} runs)")

830 if not report.states:

831 print(" No evaluator states found in run history.")

832 for state in report.states:

833 disc_label = "✓ discriminating" if state.variance >= threshold else "⚠ low variance"

834 ci_str = ""

835 if state.ci is not None:

836 ci_str = f" CI=[{state.ci[0]:.2f}, {state.ci[1]:.2f}]"

837 print(

838 f" {state.state:20s} pass_rate={state.pass_rate:.2f} "

839 f"variance={state.variance:.2f}{ci_str} {disc_label}"

840 )

841 if state.recommendation:

842 for line in state.recommendation.split("\n"):

843 print(f" {line.strip()}")

844

845 if as_json:

846 return 0

847 return 1 if flagged else 0

848

849

850 def cmd_calibrate_budget(loop_name: str, args: argparse.Namespace, loops_dir: Path) -> int:

851 """Report per-evaluator Bernoulli variance to guide max_steps calibration.

852

853 Calls compute_evaluator_variance() from analytics.variance and reports

854 p*(1-p) per evaluator state. Variance below threshold signals that

855 increasing max_steps is unlikely to help; fix the evaluator first.

856

857 Returns 0 if all evaluators are healthy, 1 if any are flagged.

858 """

859 from little_loops.analytics.variance import compute_evaluator_variance

860

861 threshold = getattr(args, "threshold", 0.05)

862 min_runs = getattr(args, "min_runs", 10)

863 as_json = getattr(args, "json", False)

864

865 report = compute_evaluator_variance(loop_name, loops_dir, threshold, min_runs)

866

867 if report is None:

868 if not (loops_dir / ".history").exists():

869 print(f"No history for: {loop_name}")

870 elif len(list((loops_dir / ".history").glob(f"*-{loop_name}"))) < min_runs:

871 runs_found = len(list((loops_dir / ".history").glob(f"*-{loop_name}")))

872 print(

873 f"Insufficient history for: {loop_name} "

874 f"(found {runs_found} run(s), need {min_runs})"

875 )

876 else:

877 print(f"No evaluate events for: {loop_name}")

878 return 0

879

880 flagged = [s for s in report.states if s.variance < threshold]

881

882 if as_json:

883 print_json(report.to_dict())

884 return 0

885

886 print(f"Loop: {loop_name}")

887 for state in report.states:

888 evaluator_type = state.evaluator_type or "unknown"

889 print(f"Evaluator: {state.state} ({evaluator_type})")

890 ci_str = ""

891 if state.ci is not None:

892 ci_str = f" CI=[{state.ci[0]:.2f}, {state.ci[1]:.2f}]"

893 if state.variance < threshold:

894 print(

895 f" Variance p*(1-p): {state.variance:.2f}{ci_str}"

896 f" ⚠ WARN: below {threshold} threshold"

897 f" — fix evaluator before increasing max_steps"

898 )

899 else:

900 print(f" Variance p*(1-p): {state.variance:.2f}{ci_str} ✓ OK")

901

902 return 1 if flagged else 0

903

904

905 def cmd_promote_baseline(loop_name: str, args: argparse.Namespace, loops_dir: Path) -> int:

906 """Promote the latest run's action output as the new comparator baseline.

907

908 Reads action_output events from the most recent .history entry and writes

909 the concatenated output to .loops/baselines/<loop>/output.txt.

910 """

911 import json as _json

912

913 history_base = loops_dir / ".history"

914 if not history_base.exists():

915 print(f"No history for: {loop_name}")

916 return 1

917

918 suffix = f"-{loop_name}"

919 matched = sorted(

920 [d for d in history_base.iterdir() if d.is_dir() and d.name.endswith(suffix)],

921 key=lambda d: d.name,

922 reverse=True,

923 )

924 if not matched:

925 print(f"No history for: {loop_name}")

926 return 1

927

928 latest = matched[0]

929 events_file = latest / "events.jsonl"

930 if not events_file.exists():

931 print(f"No events.jsonl in latest run: {latest.name}")

932 return 1

933

934 lines = []

935 with open(events_file) as f:

936 for raw in f:

937 raw = raw.strip()

938 if not raw:

939 continue

940 try:

941 event = _json.loads(raw)

942 except _json.JSONDecodeError:

943 continue

944 if event.get("type") == "action_output":

945 line = event.get("line", "")

946 if line:

947 lines.append(line)

948

949 if not lines:

950 print(f"No action_output events in latest run: {latest.name}")

951 return 1

952

953 target = Path(loops_dir) / "baselines" / loop_name / "output.txt"

954 target.parent.mkdir(parents=True, exist_ok=True)

955 target.write_text("\n".join(lines))

956 print(f"Promoted baseline for {loop_name}: {target}")

957 return 0

958

959

960# ---------------------------------------------------------------------------

961# FSM diagram renderer — delegated to layout module (re-exported above)

962# ---------------------------------------------------------------------------

963

964

965# ---------------------------------------------------------------------------

966# State overview table

967# ---------------------------------------------------------------------------

968

969

970 def _compact_transitions(state: StateConfig) -> str:

971 """Return a compact transition string for the overview table."""

972 raw: list[tuple[str, str]] = []

973 for label, target in [

974 ("yes", state.on_yes),

975 ("no", state.on_no),

976 ("error", state.on_error),

977 ("partial", state.on_partial),

978 ("next", state.next),

979 ]:

980 if target:

981 raw.append((label, target))

982 if state.route:

983 for verdict, target in state.route.routes.items():

984 raw.append((verdict, target))

985 if state.route.default:

986 raw.append(("_", state.route.default))

987 if not raw:

988 return "\u2014"

989 # Group by target, preserving first-seen order

990 seen: list[str] = []

991 by_target: dict[str, list[str]] = {}

992 for label, target in raw:

993 if target not in by_target:

994 seen.append(target)

995 by_target[target] = []

996 by_target[target].append(label)

997 return ", ".join(f"{'/'.join(by_target[t])}\u2192{t}" for t in seen)

998

999

1000 def _print_state_overview_table(fsm: FSMLoop) -> None:

1001 """Print a compact summary table of all states."""

1002 rows: list[tuple[str, str, str, str]] = []

1003 for name, state in fsm.states.items():

1004 # State name column

1005 state_col = f"\u2192 {name}" if name == fsm.initial else f" {name}"

1006

1007 # Type column

1008 if state.terminal:

1009 type_col = "\u2014"

1010 elif state.action_type:

1011 type_col = state.action_type

1012 elif state.action:

1013 type_col = "shell"

1014 else:

1015 type_col = "\u2014"

1016

1017 # Action preview column

1018 if state.terminal:

1019 action_col = "(terminal)"

1020 elif state.action:

1021 src_lines = [ln.rstrip() for ln in state.action.strip().splitlines() if ln.rstrip()]

1022 action_col = src_lines[0] if src_lines else "\u2014"

1023 else:

1024 action_col = "\u2014"

1025

1026 # Transitions column

1027 trans_col = _compact_transitions(state)

1028 rows.append((state_col, type_col, action_col, trans_col))

1029

1030 if not rows:

1031 return

1032

1033 tw = terminal_width()

1034 headers = ("State", "Type", "Action Preview", "Transitions")

1035 col0_w = max(len(headers[0]), max(len(r[0]) for r in rows))

1036 col1_w = max(len(headers[1]), max(len(r[1]) for r in rows))

1037 # Remaining width split between action preview and transitions

1038 fixed = col0_w + col1_w + 10 # margins + separators

1039 remaining = max(20, tw - fixed)

1040 col2_w = min(50, max(len(headers[2]), max(len(r[2]) for r in rows)), remaining * 3 // 5)

1041 col2_w = max(10, col2_w)

1042 col3_w = max(10, remaining - col2_w)

1043

1044 print(f" {headers[0]:<{col0_w}} {headers[1]:<{col1_w}} {headers[2]:<{col2_w}} {headers[3]}")

1045 dash = "\u2500"

1046 print(f" {dash * col0_w} {dash * col1_w} {dash * col2_w} {dash * col3_w}")

1047 for state_col, type_col, action_col, trans_col in rows:

1048 if len(action_col) > col2_w:

1049 action_col = action_col[: col2_w - 1] + "\u2026"

1050 if len(trans_col) > col3_w:

1051 trans_col = trans_col[: col3_w - 1] + "\u2026"

1052 colored_type = colorize(type_col, "2") if type_col == "\u2014" else type_col

1053 print(

1054 f" {state_col:<{col0_w}} {colored_type:<{col1_w}} "

1055 f"{action_col:<{col2_w}} {trans_col}"

1056 )

1057

1058

1059# ---------------------------------------------------------------------------

1060# cmd_show

1061# ---------------------------------------------------------------------------

1062

1063_EVALUATE_TYPE_DISPLAY: dict[str, str] = {

1064 "llm": "LLM",

1065 "llm_structured": "LLM (structured)",

1066 "exit_code": "exit code",

1067 "output_numeric": "numeric",

1068 "output_contains": "contains",

1069 "output_json": "JSON",

1070 "convergence": "convergence",

1071 "diff_stall": "diff stall",

1072 "action_stall": "action stall",

1073 "comparator": "blind comparator",

1074}

1075

1076

1077 def _humanize_evaluate_type(ev_type: str) -> str:

1078 return _EVALUATE_TYPE_DISPLAY.get(ev_type, ev_type)

1079

1080

1081 def cmd_show(

1082 loop_name: str,

1083 args: argparse.Namespace,

1084 loops_dir: Path,

1085 logger: Logger,

1086) -> int:

1087 """Show loop details and structure."""

1088 try:

1089 path = resolve_loop_path(loop_name, loops_dir)

1090 fsm, spec = load_loop_with_spec(loop_name, loops_dir, logger)

1091 except FileNotFoundError as e:

1092 logger.error(str(e))

1093 return 1

1094 except ValueError as e:

1095 logger.error(f"Invalid loop: {e}")

1096 return 1

1097

1098 if getattr(args, "json", False) and getattr(args, "show_diagrams", None) is not None:

1099 logger.error("--json and --show-diagrams are mutually exclusive")

1100 return 1

1101

1102 if getattr(args, "json", False):

1103 data = fsm.to_dict()

1104 if getattr(args, "resolved", False):

1105 for state in data.get("states", {}).values():

1106 if "loop" in state:

1107 try:

1108 child_path = resolve_loop_path(state["loop"], loops_dir)

1109 child_fsm, _ = load_and_validate(child_path)

1110 state["_subloop"] = child_fsm.to_dict().get("states", {})

1111 except (FileNotFoundError, ValueError):

1112 pass

1113 print_json(data)

1114 return 0

1115

1116 tw = terminal_width()

1117

1118 # Compute stats for header

1119 n_states = len(fsm.states)

1120 n_transitions = sum(

1121 bool(s.on_yes)

1122 + bool(s.on_no)

1123 + bool(s.on_error)

1124 + bool(s.on_partial)

1125 + bool(s.next)

1126 + bool(s.on_maintain)

1127 + (len(s.route.routes) + bool(s.route.default) if s.route else 0)

1128 for s in fsm.states.values()

1129 )

1130

1131 # --- Compact metadata header ---

1132 # Line 1: ── name ───────── N states · M transitions ──

1133 stats_parts: list[str] = []

1134 stats_parts.append(f"{n_states} states")

1135 stats_parts.append(f"{n_transitions} transitions")

1136 stats_str = " \u00b7 ".join(stats_parts)

1137

1138 header_left = f"\u2500\u2500 {loop_name} "

1139 header_right = f" {stats_str} \u2500\u2500"

1140 dashes = "\u2500" * max(0, tw - len(header_left) - len(header_right))

1141 print(f"{header_left}{dashes}{header_right}")

1142

1143 # Line 2: source · max: N iter · handoff: X [· optional fields]

1144 config_parts: list[str] = [str(path), f"max: {fsm.max_steps} steps"]

1145 config_parts.append(f"handoff: {fsm.on_handoff}")

1146 if fsm.max_iterations is not None:

1147 config_parts.append(f"max_iterations: {fsm.max_iterations}")

1148 if fsm.on_max_iterations is not None:

1149 config_parts.append(f"on_max_iterations: {fsm.on_max_iterations}")

1150 if fsm.timeout:

1151 config_parts.append(f"timeout: {fsm.timeout}s")

1152 if fsm.backoff:

1153 config_parts.append(f"backoff: {fsm.backoff}s")

1154 if fsm.maintain:

1155 config_parts.append("maintain: yes")

1156 if fsm.context:

1157 config_parts.append(f"context: {', '.join(fsm.context.keys())}")

1158 if fsm.scope:

1159 config_parts.append(f"scope: {', '.join(fsm.scope)}")

1160 llm = fsm.llm

1161 llm_parts = []

1162 if llm.model != "sonnet":

1163 llm_parts.append(f"model={llm.model}")

1164 if llm.max_tokens != 256:

1165 llm_parts.append(f"max_tokens={llm.max_tokens}")

1166 if llm.timeout != 30:

1167 llm_parts.append(f"timeout={llm.timeout}s")

1168 if llm_parts:

1169 config_parts.append(f"llm: {', '.join(llm_parts)}")

1170 if fsm.config is not None:

1171 cfg_parts = []

1172 if fsm.config.handoff_threshold is not None:

1173 cfg_parts.append(f"handoff_threshold={fsm.config.handoff_threshold}")

1174 if fsm.config.readiness_threshold is not None:

1175 cfg_parts.append(f"readiness_threshold={fsm.config.readiness_threshold}")

1176 if fsm.config.outcome_threshold is not None:

1177 cfg_parts.append(f"outcome_threshold={fsm.config.outcome_threshold}")

1178 if fsm.config.max_continuations is not None:

1179 cfg_parts.append(f"max_continuations={fsm.config.max_continuations}")

1180 if cfg_parts:

1181 config_parts.append(f"config: {', '.join(cfg_parts)}")

1182 imports = spec.get("import", [])

1183 if imports:

1184 config_parts.append(f"imports: {', '.join(imports)}")

1185 print(" " + " \u00b7 ".join(config_parts))

1186

1187 # --- Description ---

1188 description = spec.get("description", "").strip()

1189 if description:

1190 print()

1191 print("Description:")

1192 for line in description.splitlines():

1193 print(f" {line}")

1194

1195 # --- ASCII FSM Diagram ---

1196 verbose = getattr(args, "verbose", False)

1197 from pathlib import Path

1198

1199 from little_loops.config import BRConfig

1200

1201 badges = BRConfig(Path.cwd()).loops.glyphs.to_dict()

1202 facets = resolve_facets(args)

1203 print()

1204 print("Diagram:")

1205 if facets is None:

1206 diagram = _render_fsm_diagram(fsm, verbose=verbose, badges=badges)

1207 else:

1208 diagram = _render_fsm_diagram(

1209 fsm,

1210 badges=badges,

1211 mode=facets.scope,

1212 suppress_labels=not facets.edge_labels,

1213 title_only=facets.state_detail == "title",

1214 )

1215 if diagram:

1216 print(diagram)

1217

1218 # --- State overview table ---

1219 print()

1220 _print_state_overview_table(fsm)

1221

1222 # --- States & Transitions (verbose only) ---

1223 if verbose:

1224 print()

1225 print("States:")

1226 first_state = True

1227 for name, state in fsm.states.items():

1228 if not first_state:

1229 print()

1230 first_state = False

1231

1232 # Improved state section header: ── name ──── MARKERS · type ──

1233 right_parts = []

1234 if name == fsm.initial:

1235 right_parts.append("INITIAL")

1236 if state.terminal:

1237 right_parts.append("TERMINAL")

1238 if state.action_type:

1239 right_parts.append(state.action_type)

1240 right_info = " \u00b7 ".join(right_parts)

1241 inner_left = f"\u2500\u2500 {name} "

1242 inner_right = f" {right_info} \u2500\u2500" if right_info else " \u2500\u2500"

1243 fill = "\u2500" * max(0, tw - 2 - len(inner_left) - len(inner_right))

1244 print(f" {inner_left}{fill}{inner_right}")

1245

1246 if state.action:

1247 if verbose:

1248 indented = "\n ".join(state.action.strip().splitlines())

1249 print(f" action:\n {indented}")

1250 elif state.action_type == "prompt":

1251 lines_act = state.action.strip().splitlines()

1252 preview = "\n ".join(lines_act[:3])

1253 if len(lines_act) > 3 or len(state.action) > 200:

1254 preview += " ..."

1255 print(f" action:\n {preview}")

1256 else: # shell, slash_command, or None

1257 action_display = (

1258 state.action[:70] + "..." if len(state.action) > 70 else state.action

1259 )

1260 print(f" action: {action_display}")

1261 if state.evaluate:

1262 ev = state.evaluate

1263 print(f" evaluate: {_humanize_evaluate_type(ev.type)}")

1264 if ev.prompt:

1265 if verbose:

1266 print(" prompt:")

1267 for line in ev.prompt.strip().splitlines():

1268 print(f" \u2502 {line}")

1269 else:

1270 ev_lines = ev.prompt.strip().splitlines()

1271 preview = ev_lines[0][:100] + (

1272 " ..." if len(ev_lines) > 1 or len(ev_lines[0]) > 100 else ""

1273 )

1274 print(f" prompt: {preview}")

1275 if ev.min_confidence != 0.5:

1276 print(f" min_confidence: {ev.min_confidence}")

1277 if ev.operator:

1278 print(f" operator: {ev.operator} {ev.target}")

1279 if ev.pattern:

1280 print(f" pattern: {ev.pattern}")

1281 if state.capture:

1282 print(f" capture: {state.capture}")

1283 if state.timeout:

1284 print(f" timeout: {state.timeout}s")

1285 # Collect (label, target) pairs

1286 raw_transitions: list[tuple[str, str]] = []

1287 for label, target in [

1288 ("yes", state.on_yes),

1289 ("no", state.on_no),

1290 ("error", state.on_error),

1291 ("partial", state.on_partial),

1292 ("next", state.next),

1293 ("maintain", state.on_maintain),

1294 ]:

1295 if target:

1296 raw_transitions.append((label, target))

1297 if state.route:

1298 for verdict, target in state.route.routes.items():

1299 raw_transitions.append((verdict, target))

1300 if state.route.default:

1301 raw_transitions.append(("_", state.route.default))

1302 # Group by target, preserving first-seen order

1303 target_labels: dict[str, list[str]] = {}

1304 seen_targets: list[str] = []

1305 for label, target in raw_transitions:

1306 if target not in target_labels:

1307 target_labels[target] = []

1308 seen_targets.append(target)

1309 target_labels[target].append(label)

1310 transitions = [

1311 f"{_colorize_label('/'.join(target_labels[t]))} \u2500\u2500\u2192 {t}"

1312 for t in seen_targets

1313 ]

1314 if transitions:

1315 print(" Transitions:")

1316 for t in transitions:

1317 print(f" {t}")

1318

1319 # --- Commands ---

1320 print()

1321 print("Commands:")

1322 if fsm.commands:

1323 cmds = [(e.cmd, e.comment) for e in fsm.commands]

1324 else:

1325 cmds = [

1326 (f"ll-loop run {loop_name}", "run"),

1327 (f"ll-loop test {loop_name}", "single test iteration"),

1328 (f"ll-loop stop {loop_name}", "stop a running loop"),

1329 (f"ll-loop status {loop_name}", "check if running"),

1330 (f"ll-loop history {loop_name}", "execution history"),

1331 ]

1332 col_width = max(len(c) for c, _ in cmds) + 2

1333 for cmd, comment in cmds:

1334 print(f" {cmd:<{col_width}} # {comment}")

1335

1336 return 0

1337

1338

1339 def cmd_fragments(

1340 lib_path: str,

1341 args: argparse.Namespace,

1342 loops_dir: Path,

1343 logger: Logger,

1344) -> int:

1345 """List fragments in a library file with their descriptions.

1346

1347 Resolves the library path relative to loops_dir or the built-in loops directory,

1348 then prints a table of fragment names and their description fields.

1349 """

1350 import yaml

1351

1352 # Resolve path: absolute/direct → loops_dir-relative → builtin

1353 p = Path(lib_path)

1354 if not p.exists():

1355 candidate = loops_dir / lib_path

1356 if candidate.exists():

1357 p = candidate

1358 else:

1359 builtin_candidate = get_builtin_loops_dir() / lib_path

1360 if builtin_candidate.exists():

1361 p = builtin_candidate

1362 else:

1363 logger.error(f"Fragment library not found: {lib_path}")

1364 return 1

1365

1366 try:

1367 with open(p) as f:

1368 data = yaml.safe_load(f) or {}

1369 except Exception as e:

1370 logger.error(f"Failed to load library: {e}")

1371 return 1

1372

1373 fragments: dict[str, Any] = data.get("fragments", {})

1374 if not fragments:

1375 print(f"No fragments defined in: {p}")

1376 return 0

1377

1378 tw = terminal_width()

1379 print(colorize(f"Fragments in {p.name} ({len(fragments)}):", "1"))

1380 print()

1381

1382 max_name_len = max(len(name) for name in fragments)

1383 name_col_w = max(max_name_len, 8)

1384 desc_col_w = max(20, tw - name_col_w - 6)

1385

1386 header_name = "Fragment".ljust(name_col_w)

1387 print(f" {colorize(header_name, '1')} Description")

1388 print(f" {'─' * name_col_w} {'─' * desc_col_w}")

1389

1390 for name, frag in fragments.items():

1391 raw_desc = frag.get("description", "") if isinstance(frag, dict) else ""

1392 raw_desc = raw_desc.strip()

1393 first_line = raw_desc.splitlines()[0] if raw_desc else ""

1394 if first_line and len(first_line) > desc_col_w:

1395 first_line = first_line[: desc_col_w - 1] + "\u2026"

1396 desc_str = first_line if first_line else colorize("(no description)", "2")

1397 padded_name = name.ljust(name_col_w)

1398 print(f" {colorize(padded_name, '36;1')} {desc_str}")

1399

1400 return 0