Coverage for little_loops / cli / loop / info.py: 0%
729 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
1"""ll-loop info subcommands: list, history, show."""
3from __future__ import annotations
5import argparse
6import os
7import re
8from datetime import datetime
9from pathlib import Path
10from typing import Any
12from little_loops.cli.loop._helpers import (
13 get_builtin_loops_dir,
14 load_loop_with_spec,
15 resolve_loop_path,
16)
17from little_loops.cli.loop.diagram_modes import resolve_facets
18from little_loops.cli.loop.layout import ( # noqa: F401
19 _EDGE_LABEL_COLORS,
20 _box_inner_lines,
21 _colorize_diagram_labels,
22 _colorize_label,
23 _render_fsm_diagram,
24)
25from little_loops.cli.output import colorize, print_json, terminal_width
26from little_loops.fsm import is_runnable_loop
27from little_loops.fsm.schema import FSMLoop, StateConfig
28from little_loops.fsm.validation import load_and_validate
29from little_loops.logger import Logger
32def _load_loop_meta(path: Path) -> dict[str, Any]:
33 """Return metadata from a loop YAML file (description, category, labels)."""
34 import yaml
36 try:
37 with open(path) as f:
38 spec = yaml.safe_load(f) or {}
39 desc_raw = spec.get("description", "") or ""
40 if desc_raw.strip():
41 raw_lines = desc_raw.splitlines()
42 desc = raw_lines[0]
43 if len(raw_lines) > 1:
44 desc += "…"
45 else:
46 desc = ""
47 category = spec.get("category", "") or ""
48 labels: list[str] = spec.get("labels", []) or []
49 return {"description": desc, "category": category, "labels": labels}
50 except Exception:
51 return {"description": "", "category": "", "labels": []}
54def cmd_list(
55 args: argparse.Namespace,
56 loops_dir: Path,
57) -> int:
58 """List loops."""
59 status_filter = getattr(args, "status", None)
60 if getattr(args, "running", False) or status_filter:
61 from little_loops.fsm.persistence import list_running_loops
63 states = list_running_loops(loops_dir)
64 if status_filter:
65 states = [s for s in states if s.status == status_filter]
66 if not states:
67 if status_filter:
68 print(f"No loops with status: {status_filter}")
69 return 1
70 print("No running loops")
71 return 0
72 if getattr(args, "json", False):
73 print_json([s.to_dict() for s in states])
74 return 0
75 print(colorize("Running loops:", "1"))
76 _STATUS_COLORS = {"running": "32", "interrupted": "33", "stopped": "2", "starting": "33"}
78 # Group by loop_name to avoid duplicate rows for multi-instance loops
79 from collections import defaultdict
81 groups: dict[str, list] = defaultdict(list)
82 for state in states:
83 groups[state.loop_name].append(state)
85 for loop_name_key, group_states in groups.items():
86 if len(group_states) == 1:
87 state = group_states[0]
88 elapsed_s = (state.accumulated_ms or 0) // 1000
89 elapsed_str = (
90 f"{elapsed_s}s" if elapsed_s < 60 else f"{elapsed_s // 60}m {elapsed_s % 60}s"
91 )
92 name_str = colorize(state.loop_name, "1")
93 state_str = colorize(state.current_state, "34")
94 status_color = _STATUS_COLORS.get(state.status, "2")
95 display_status = "paused" if state.status == "interrupted" else state.status
96 status_str = colorize(f"[{display_status}]", status_color)
97 elapsed_colored = colorize(elapsed_str, "2")
98 print(
99 f" {name_str}: {state_str} (iteration {state.iteration})"
100 f" {status_str} {elapsed_colored}"
101 )
102 else:
103 # Multiple instances: show a grouped summary
104 name_str = colorize(loop_name_key, "1")
105 statuses = ", ".join(
106 colorize(
107 f"[{'paused' if s.status == 'interrupted' else s.status}]",
108 _STATUS_COLORS.get(s.status, "2"),
109 )
110 for s in group_states
111 )
112 count_str = colorize(f"({len(group_states)} instances)", "2")
113 print(f" {name_str}: {count_str} {statuses}")
114 return 0
116 builtin_only = getattr(args, "builtin", False)
118 def _rel_key(path: Path, base: Path) -> str:
119 """Relative-path identifier matching what `ll-loop run` accepts."""
120 return str(path.relative_to(base).with_suffix(""))
122 # Collect project loops (skipped when --builtin is set). Recurse into
123 # subdirectories (e.g. oracles/) and filter to runnable FSM definitions so
124 # library fragments under loops/lib/ stay hidden.
125 project_names: set[str] = set()
126 yaml_files: list[Path] = []
127 if not builtin_only and loops_dir.exists():
128 yaml_files = sorted(p for p in loops_dir.rglob("*.yaml") if is_runnable_loop(p))
129 project_names = {_rel_key(p, loops_dir) for p in yaml_files}
131 # Collect built-in loops (excluding those overridden by a project loop at
132 # the same relative path).
133 builtin_dir = get_builtin_loops_dir()
134 builtin_files: list[Path] = []
135 if builtin_dir.exists():
136 builtin_files = [
137 f
138 for f in sorted(builtin_dir.rglob("*.yaml"))
139 if is_runnable_loop(f) and _rel_key(f, builtin_dir) not in project_names
140 ]
142 if not yaml_files and not builtin_files:
143 if getattr(args, "json", False):
144 print_json([])
145 return 0
146 print("No loops available")
147 return 0
149 # Build combined metadata list
150 all_loops: list[dict[str, Any]] = []
151 for path in yaml_files:
152 meta = _load_loop_meta(path)
153 all_loops.append(
154 {"name": _rel_key(path, loops_dir), "path": path, "builtin": False, **meta}
155 )
156 for path in builtin_files:
157 meta = _load_loop_meta(path)
158 all_loops.append(
159 {"name": _rel_key(path, builtin_dir), "path": path, "builtin": True, **meta}
160 )
162 # Apply --category filter
163 category_filter = getattr(args, "category", None)
164 if category_filter:
165 all_loops = [lp for lp in all_loops if lp["category"] == category_filter]
167 # Apply --label filter (action="append" → list or None)
168 label_filters: list[str] = getattr(args, "label", None) or []
169 if label_filters:
170 all_loops = [
171 lp
172 for lp in all_loops
173 if any(lf.lower() in [lb.lower() for lb in lp["labels"]] for lf in label_filters)
174 ]
176 if not all_loops:
177 if getattr(args, "json", False):
178 print_json([])
179 return 0
180 print("No loops match the given filters")
181 return 0
183 if getattr(args, "json", False):
184 items: list[dict[str, Any]] = []
185 for lp in all_loops:
186 item: dict[str, Any] = {
187 "name": lp["name"],
188 "path": str(lp["path"]),
189 "category": lp["category"],
190 "labels": lp["labels"],
191 }
192 if lp["builtin"]:
193 item["built_in"] = True
194 items.append(item)
195 print_json(items)
196 return 0
198 # Human-readable: group by category
199 buckets: dict[str, list[dict[str, Any]]] = {}
200 for lp in all_loops:
201 cat = lp["category"] or "uncategorized"
202 if cat not in buckets:
203 buckets[cat] = []
204 buckets[cat].append(lp)
206 # Sort categories; "uncategorized" always last
207 sorted_cats = sorted(c for c in buckets if c != "uncategorized")
208 if "uncategorized" in buckets:
209 sorted_cats.append("uncategorized")
211 # Compute max name width for column alignment
212 max_name_len = max((len(lp["name"]) for lp in all_loops), default=0)
213 name_col = max_name_len + 2 # padding after longest name
214 tw = terminal_width()
216 cats_printed = False
217 for cat in sorted_cats:
218 group = buckets[cat]
219 if cats_printed:
220 print() # blank line between category groups
221 cats_printed = True
222 print(colorize(f"{cat} ({len(group)}):", "1"))
223 for lp in group:
224 # Name: project loops get bold cyan, built-in loops get dimmer cyan
225 name_color = "36" if lp["builtin"] else "36;1"
226 name_str = colorize(lp["name"].ljust(name_col), name_color)
228 # Suffix: labels + [built-in] tag
229 suffix_parts: list[str] = []
230 if lp["labels"]:
231 for label in lp["labels"]:
232 suffix_parts.append(colorize(f"[{label}]", "2"))
233 if lp["builtin"]:
234 suffix_parts.append(colorize("[built-in]", "2"))
236 if suffix_parts:
237 suffix_raw = " " + " ".join(suffix_parts)
238 suffix_visible = len(_strip_ansi(suffix_raw))
239 else:
240 suffix_raw = ""
241 suffix_visible = 0
243 # Available width for description: indent + name_col + " " + desc + suffix
244 avail = tw - 2 - name_col - 2 - suffix_visible
245 desc_text = lp["description"] or ""
246 if desc_text and avail < len(desc_text):
247 desc_text = _truncate(desc_text, max(avail, 20))
248 desc_str = f" {colorize(desc_text, '2')}" if desc_text else ""
250 print(f" {name_str}{desc_str}{suffix_raw}")
251 return 0
254_EVENT_TYPE_WIDTH = 16 # width of "handoff_detected"
256_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
259def _strip_ansi(text: str) -> str:
260 return _ANSI_RE.sub("", text)
263def _truncate(text: str, max_len: int) -> str:
264 """Truncate text to max_len with ellipsis."""
265 if max_len < 1:
266 return ""
267 if len(text) <= max_len:
268 return text
269 return text[: max_len - 1] + "\u2026"
272def _format_history_event(
273 event: dict[str, Any], verbose: bool, width: int, full: bool = False
274) -> str | None:
275 """Format a single history event. Returns None to skip the event."""
276 raw_ts = event.get("ts", "")
277 try:
278 ts = datetime.fromisoformat(raw_ts).strftime("%H:%M:%S")
279 except (ValueError, TypeError):
280 ts = raw_ts[:8] if len(raw_ts) >= 8 else raw_ts.ljust(8)
282 event_type = event.get("event", "unknown")
284 if event_type == "action_output" and not verbose:
285 return None
287 ts_str = colorize(ts, "2")
288 etype_padded = event_type.ljust(_EVENT_TYPE_WIDTH)
289 etype_color = "0"
290 detail = ""
291 extra_lines: list[str] = []
293 # Indentation prefix for verbose sub-lines (aligns under event detail column)
294 _indent = " " * (8 + 2 + _EVENT_TYPE_WIDTH + 2)
296 if event_type == "loop_start":
297 etype_color = "1"
298 detail = event.get("loop", "")
300 elif event_type == "loop_complete":
301 etype_color = "1"
302 final_state = event.get("final_state", "")
303 iterations = event.get("iterations", "")
304 terminated_by = event.get("terminated_by", "")
305 detail = f"{final_state} {iterations} iter [{terminated_by}]"
307 elif event_type == "loop_resume":
308 etype_color = "1"
309 from_state = event.get("from_state", "")
310 iteration = event.get("iteration", "")
311 detail = f"from={from_state} iter={iteration}"
313 elif event_type == "state_enter":
314 etype_color = "34"
315 state = event.get("state", "")
316 iteration = event.get("iteration", "")
317 detail = f"{colorize(state, '1')} (iter {iteration})"
319 elif event_type == "action_start":
320 action = event.get("action", "")
321 is_prompt = event.get("is_prompt", False)
322 kind_label = "prompt" if is_prompt else "shell"
323 kind_str = colorize(f"[{kind_label}]", "2")
324 first_line = (
325 next((ln.strip() for ln in action.splitlines() if ln.strip()), "")
326 if is_prompt
327 else action
328 )
329 avail = width - 8 - 2 - _EVENT_TYPE_WIDTH - 2 - len(kind_label) - 2 - 2
330 detail = f"{_truncate(first_line, max(avail, 20))} {kind_str}"
332 elif event_type == "action_output":
333 # Only reached in verbose mode
334 etype_color = "2"
335 detail = colorize("\u2502 " + event.get("line", ""), "2")
337 elif event_type == "action_complete":
338 exit_code = event.get("exit_code", 0)
339 duration_ms = event.get("duration_ms", 0)
340 if exit_code == 0:
341 etype_color = "2"
342 status_str = colorize("\u2713", "32")
343 else:
344 etype_color = "38;5;208"
345 status_str = colorize(f"\u2717 exit={exit_code}", "38;5;208")
346 detail = f"{status_str} {duration_ms}ms"
347 is_prompt = event.get("is_prompt", False)
348 session_jsonl = event.get("session_jsonl") if is_prompt else None
349 if session_jsonl:
350 session_display = session_jsonl if verbose else os.path.basename(session_jsonl)
351 detail += f" session={colorize(session_display, '2')}"
352 if verbose:
353 output_preview = event.get("output_preview", "")
354 if output_preview:
355 avail_w = width - len(_indent) - 2
356 preview_text = (
357 output_preview if full else _truncate(output_preview, max(avail_w, 40))
358 )
359 for preview_line in preview_text.splitlines()[:5]:
360 extra_lines.append(colorize(_indent + "\u2502 " + preview_line, "2"))
362 elif event_type == "evaluate":
363 verdict = event.get("verdict", "")
364 confidence = event.get("confidence", "")
365 reason = event.get("reason", "")
366 if verdict == "yes":
367 etype_color = "32"
368 verdict_str = colorize("\u2713 yes", "32")
369 else:
370 etype_color = "38;5;208"
371 verdict_str = colorize(f"\u2717 {verdict}", "38;5;208")
372 conf_part = f" confidence={confidence}" if confidence != "" else ""
373 avail = width - 8 - 2 - _EVENT_TYPE_WIDTH - 2 - len("\u2713 yes") - len(conf_part) - 2
374 reason_part = f" {_truncate(reason, max(avail, 20))}" if reason else ""
375 detail = f"{verdict_str}{conf_part}{reason_part}"
376 if verbose:
377 llm_model = event.get("llm_model", "")
378 llm_latency_ms = event.get("llm_latency_ms", "")
379 llm_prompt = event.get("llm_prompt", "")
380 llm_raw_output = event.get("llm_raw_output", "")
381 if llm_model or llm_prompt:
382 meta_parts = []
383 if llm_model:
384 meta_parts.append(f"model={llm_model}")
385 if llm_latency_ms != "":
386 meta_parts.append(f"latency={llm_latency_ms}ms")
387 meta_str = " ".join(meta_parts)
388 extra_lines.append(
389 colorize(_indent + colorize("LLM Call", "2") + " " + meta_str, "2")
390 )
391 avail_w = width - len(_indent) - len("Prompt: ") - 2
392 if llm_prompt:
393 prompt_text = llm_prompt if full else _truncate(llm_prompt, max(avail_w, 40))
394 extra_lines.append(colorize(_indent + "Prompt: " + prompt_text, "2"))
395 if llm_raw_output:
396 resp_text = (
397 llm_raw_output if full else _truncate(llm_raw_output, max(avail_w, 40))
398 )
399 extra_lines.append(colorize(_indent + "Response: " + resp_text, "2"))
401 elif event_type == "route":
402 etype_color = "2"
403 from_state = event.get("from", "")
404 to_state = event.get("to", "")
405 detail = f"{from_state} \u2192 {colorize(to_state, '34')}"
407 elif event_type == "handoff_detected":
408 etype_color = "33"
409 detail = f"state={event.get('state', '')} iter={event.get('iteration', '')}"
411 else:
412 details = {k: v for k, v in event.items() if k not in ("event", "ts")}
413 detail = " ".join(f"{k}={v}" for k, v in details.items())
415 etype_str = colorize(etype_padded, etype_color)
416 main_line = f"{ts_str} {etype_str} {detail}"
417 if extra_lines:
418 return "\n".join([main_line] + extra_lines)
419 return main_line
422def _format_duration(ms: int) -> str:
423 """Format milliseconds as a human-readable duration."""
424 if ms < 1000:
425 return f"{ms}ms"
426 s = ms // 1000
427 if s < 60:
428 return f"{s}s"
429 m, s = divmod(s, 60)
430 if m < 60:
431 return f"{m}m{s:02d}s"
432 h, m = divmod(m, 60)
433 return f"{h}h{m:02d}m{s:02d}s"
436def _list_archived_runs(loop_name: str, loops_dir: Path, as_json: bool) -> int:
437 """List archived runs for a loop."""
438 import json as _json
440 from little_loops.fsm.persistence import HISTORY_DIR, LoopState
442 history_base = loops_dir / HISTORY_DIR
443 if not history_base.exists():
444 print(f"No history for: {loop_name}")
445 return 0
447 # Flat layout: run dirs are <run_id>-<loop_name> directly under .history/
448 suffix = f"-{loop_name}"
449 runs: list[tuple[str, LoopState | None]] = []
450 for run_dir in sorted(history_base.iterdir(), key=lambda d: d.name, reverse=True):
451 if not run_dir.is_dir() or not run_dir.name.endswith(suffix):
452 continue
453 run_id = run_dir.name[: -len(suffix)]
454 state_file = run_dir / "state.json"
455 state: LoopState | None = None
456 if state_file.exists():
457 try:
458 data = _json.loads(state_file.read_text())
459 state = LoopState.from_dict(data)
460 except (ValueError, KeyError):
461 pass
462 runs.append((run_id, state))
464 if not runs:
465 print(f"No history for: {loop_name}")
466 return 0
468 if as_json:
469 print(
470 _json.dumps(
471 [
472 {
473 "run_id": rid,
474 "status": s.status if s else None,
475 "started_at": s.started_at if s else None,
476 "iterations": s.iteration if s else None,
477 "duration_ms": s.accumulated_ms if s else None,
478 }
479 for rid, s in runs
480 ],
481 indent=2,
482 )
483 )
484 return 0
486 status_colors = {
487 "completed": "\033[32m",
488 "failed": "\033[31m",
489 "interrupted": "\033[33m",
490 "awaiting_continuation": "\033[36m",
491 "timed_out": "\033[33m",
492 "running": "\033[34m",
493 }
494 reset = "\033[0m"
496 print(f"Archived runs for: {loop_name} ({len(runs)} total)")
497 print()
499 for run_id, state in runs:
500 if state is not None:
501 color = status_colors.get(state.status, "")
502 status_str = f"{color}{state.status}{reset}"
503 duration_str = _format_duration(state.accumulated_ms) if state.accumulated_ms else "?"
504 started = state.started_at[:19].replace("T", " ") if state.started_at else "?"
505 iters = f"{state.iteration} iters"
506 else:
507 status_str = "unknown"
508 duration_str = "?"
509 started = "?"
510 iters = "?"
511 print(f" {run_id} {status_str} {started} {iters} {duration_str}")
513 print()
514 print(f"To view events: ll-loop history {loop_name} <run-id>")
515 return 0
518def cmd_history(
519 loop_name: str,
520 run_id: str | None,
521 args: argparse.Namespace,
522 loops_dir: Path,
523) -> int:
524 """Show loop history.
526 Without run_id: lists all archived runs with status and duration.
527 With run_id: shows events for that specific archived run.
528 """
529 tail = getattr(args, "tail", 50)
530 full = getattr(args, "full", False)
531 verbose = getattr(args, "verbose", False) or full
532 as_json = getattr(args, "json", False)
534 if run_id is None:
535 return _list_archived_runs(loop_name, loops_dir, as_json)
537 # Show events for a specific archived run
538 from little_loops.fsm.persistence import get_archived_events
540 events = get_archived_events(loop_name, run_id, loops_dir)
542 if not events:
543 print(f"No events found for run {run_id} of loop {loop_name}")
544 return 1
546 w = terminal_width()
547 if not verbose:
548 events = [e for e in events if e.get("event") != "action_output"]
550 # Apply optional filters (before --tail slice)
551 event_filter = getattr(args, "event", None)
552 if event_filter:
553 events = [e for e in events if e.get("event") == event_filter]
555 state_filter = getattr(args, "state", None)
556 if state_filter:
557 events = [
558 e
559 for e in events
560 if (
561 e.get("state") == state_filter
562 or e.get("from") == state_filter
563 or e.get("to") == state_filter
564 )
565 ]
567 since_str = getattr(args, "since", None)
568 if since_str:
569 from datetime import timedelta
571 from little_loops.text_utils import parse_duration
573 cutoff = datetime.now() - timedelta(seconds=parse_duration(since_str))
574 events = [
575 e
576 for e in events
577 if datetime.fromisoformat(e["ts"].replace("Z", "+00:00")).replace(tzinfo=None) >= cutoff
578 ]
580 if as_json:
581 print_json(events[-tail:])
582 return 0
583 for event in events[-tail:]:
584 line = _format_history_event(event, verbose, w, full=full)
585 if line is not None:
586 print(line)
588 return 0
591def cmd_audit_meta(loop_name: str, args: argparse.Namespace, loops_dir: Path) -> int:
592 """Summarize meta-eval.jsonl agreement stats from all archived runs of a loop.
594 Reads meta-eval.jsonl from each archived run, computes:
595 - Total iterations with llm_structured evaluate events
596 - Agreement rate (agreed / total)
597 - Mean diff size (files_changed) per verdict
598 - Divergence flags:
599 - agreed=false streak >=3 → "LLM optimistic drift detected"
600 - agreed=true with files_changed==0 streak >=3 → "Trivial agreement detected"
602 Returns 0 if no flags triggered, 1 if any threshold crossed.
603 """
604 import json as _json
606 from little_loops.fsm.persistence import HISTORY_DIR
608 history_root = loops_dir / HISTORY_DIR
609 if not history_root.exists():
610 print(f"No history for: {loop_name}")
611 return 0
613 suffix = f"-{loop_name}"
614 all_entries: list[dict[str, Any]] = []
616 for run_dir in sorted(history_root.iterdir(), key=lambda d: d.name):
617 if not run_dir.is_dir() or not run_dir.name.endswith(suffix):
618 continue
619 meta_eval_file = run_dir / "meta-eval.jsonl"
620 if not meta_eval_file.exists():
621 continue
622 for line in meta_eval_file.read_text(encoding="utf-8").splitlines():
623 line = line.strip()
624 if line:
625 try:
626 all_entries.append(_json.loads(line))
627 except _json.JSONDecodeError:
628 pass
630 if not all_entries:
631 print(f"No meta-eval data for: {loop_name}")
632 return 0
634 total = len(all_entries)
635 agreed_count = sum(1 for e in all_entries if e.get("agreed") is True)
636 agreement_rate = agreed_count / total if total else 0.0
638 # Mean diff size per verdict
639 agreed_sizes = [
640 e.get("diff_stats", {}).get("files_changed", 0) or 0
641 for e in all_entries
642 if e.get("agreed") is True
643 ]
644 disagreed_sizes = [
645 e.get("diff_stats", {}).get("files_changed", 0) or 0
646 for e in all_entries
647 if e.get("agreed") is False
648 ]
649 mean_agreed = sum(agreed_sizes) / len(agreed_sizes) if agreed_sizes else 0.0
650 mean_disagreed = sum(disagreed_sizes) / len(disagreed_sizes) if disagreed_sizes else 0.0
652 # Detect streaks
653 flags: list[str] = []
654 optimistic_streak = 0
655 trivial_streak = 0
656 max_optimistic = 0
657 max_trivial = 0
659 for entry in all_entries:
660 agreed = entry.get("agreed")
661 files_changed = (entry.get("diff_stats") or {}).get("files_changed", 0) or 0
663 if agreed is False:
664 optimistic_streak += 1
665 trivial_streak = 0
666 elif agreed is True and files_changed == 0:
667 trivial_streak += 1
668 optimistic_streak = 0
669 else:
670 optimistic_streak = 0
671 trivial_streak = 0
673 max_optimistic = max(max_optimistic, optimistic_streak)
674 max_trivial = max(max_trivial, trivial_streak)
676 if max_optimistic >= 3:
677 flags.append(f"LLM optimistic drift detected (streak={max_optimistic})")
678 if max_trivial >= 3:
679 flags.append(f"Trivial agreement detected (streak={max_trivial})")
681 as_json = getattr(args, "json", False)
682 if as_json:
683 result = {
684 "loop": loop_name,
685 "total_entries": total,
686 "agreed_count": agreed_count,
687 "agreement_rate": round(agreement_rate, 4),
688 "mean_files_changed_when_agreed": round(mean_agreed, 2),
689 "mean_files_changed_when_disagreed": round(mean_disagreed, 2),
690 "max_optimistic_streak": max_optimistic,
691 "max_trivial_streak": max_trivial,
692 "flags": flags,
693 }
694 print_json(result)
695 else:
696 print(f"Meta-eval audit: {loop_name}")
697 print(f" Total entries: {total}")
698 print(f" Agreed: {agreed_count}/{total} ({agreement_rate:.0%})")
699 print(f" Mean Δfiles agreed: {mean_agreed:.1f}")
700 print(f" Mean Δfiles disagreed: {mean_disagreed:.1f}")
701 if flags:
702 print()
703 for flag in flags:
704 print(f" ⚠ {flag}")
705 else:
706 print(" No divergence flags.")
708 if as_json:
709 return 0 # let caller inspect JSON for flags; non-zero kills provider.runCli
710 return 1 if flags else 0
713# ---------------------------------------------------------------------------
714# FSM diagram renderer — delegated to layout module (re-exported above)
715# ---------------------------------------------------------------------------
718# ---------------------------------------------------------------------------
719# State overview table
720# ---------------------------------------------------------------------------
723def _compact_transitions(state: StateConfig) -> str:
724 """Return a compact transition string for the overview table."""
725 raw: list[tuple[str, str]] = []
726 for label, target in [
727 ("yes", state.on_yes),
728 ("no", state.on_no),
729 ("error", state.on_error),
730 ("partial", state.on_partial),
731 ("next", state.next),
732 ]:
733 if target:
734 raw.append((label, target))
735 if state.route:
736 for verdict, target in state.route.routes.items():
737 raw.append((verdict, target))
738 if state.route.default:
739 raw.append(("_", state.route.default))
740 if not raw:
741 return "\u2014"
742 # Group by target, preserving first-seen order
743 seen: list[str] = []
744 by_target: dict[str, list[str]] = {}
745 for label, target in raw:
746 if target not in by_target:
747 seen.append(target)
748 by_target[target] = []
749 by_target[target].append(label)
750 return ", ".join(f"{'/'.join(by_target[t])}\u2192{t}" for t in seen)
753def _print_state_overview_table(fsm: FSMLoop) -> None:
754 """Print a compact summary table of all states."""
755 rows: list[tuple[str, str, str, str]] = []
756 for name, state in fsm.states.items():
757 # State name column
758 state_col = f"\u2192 {name}" if name == fsm.initial else f" {name}"
760 # Type column
761 if state.terminal:
762 type_col = "\u2014"
763 elif state.action_type:
764 type_col = state.action_type
765 elif state.action:
766 type_col = "shell"
767 else:
768 type_col = "\u2014"
770 # Action preview column
771 if state.terminal:
772 action_col = "(terminal)"
773 elif state.action:
774 src_lines = [ln.rstrip() for ln in state.action.strip().splitlines() if ln.rstrip()]
775 action_col = src_lines[0] if src_lines else "\u2014"
776 else:
777 action_col = "\u2014"
779 # Transitions column
780 trans_col = _compact_transitions(state)
781 rows.append((state_col, type_col, action_col, trans_col))
783 if not rows:
784 return
786 tw = terminal_width()
787 headers = ("State", "Type", "Action Preview", "Transitions")
788 col0_w = max(len(headers[0]), max(len(r[0]) for r in rows))
789 col1_w = max(len(headers[1]), max(len(r[1]) for r in rows))
790 # Remaining width split between action preview and transitions
791 fixed = col0_w + col1_w + 10 # margins + separators
792 remaining = max(20, tw - fixed)
793 col2_w = min(50, max(len(headers[2]), max(len(r[2]) for r in rows)), remaining * 3 // 5)
794 col2_w = max(10, col2_w)
795 col3_w = max(10, remaining - col2_w)
797 print(f" {headers[0]:<{col0_w}} {headers[1]:<{col1_w}} {headers[2]:<{col2_w}} {headers[3]}")
798 dash = "\u2500"
799 print(f" {dash * col0_w} {dash * col1_w} {dash * col2_w} {dash * col3_w}")
800 for state_col, type_col, action_col, trans_col in rows:
801 if len(action_col) > col2_w:
802 action_col = action_col[: col2_w - 1] + "\u2026"
803 if len(trans_col) > col3_w:
804 trans_col = trans_col[: col3_w - 1] + "\u2026"
805 colored_type = colorize(type_col, "2") if type_col == "\u2014" else type_col
806 print(
807 f" {state_col:<{col0_w}} {colored_type:<{col1_w}} "
808 f"{action_col:<{col2_w}} {trans_col}"
809 )
812# ---------------------------------------------------------------------------
813# cmd_show
814# ---------------------------------------------------------------------------
816_EVALUATE_TYPE_DISPLAY: dict[str, str] = {
817 "llm": "LLM",
818 "llm_structured": "LLM (structured)",
819 "exit_code": "exit code",
820 "output_numeric": "numeric",
821 "output_contains": "contains",
822 "output_json": "JSON",
823 "convergence": "convergence",
824}
827def _humanize_evaluate_type(ev_type: str) -> str:
828 return _EVALUATE_TYPE_DISPLAY.get(ev_type, ev_type)
831def cmd_show(
832 loop_name: str,
833 args: argparse.Namespace,
834 loops_dir: Path,
835 logger: Logger,
836) -> int:
837 """Show loop details and structure."""
838 try:
839 path = resolve_loop_path(loop_name, loops_dir)
840 fsm, spec = load_loop_with_spec(loop_name, loops_dir, logger)
841 except FileNotFoundError as e:
842 logger.error(str(e))
843 return 1
844 except ValueError as e:
845 logger.error(f"Invalid loop: {e}")
846 return 1
848 if getattr(args, "json", False) and getattr(args, "show_diagrams", None) is not None:
849 logger.error("--json and --show-diagrams are mutually exclusive")
850 return 1
852 if getattr(args, "json", False):
853 data = fsm.to_dict()
854 if getattr(args, "resolved", False):
855 for state in data.get("states", {}).values():
856 if "loop" in state:
857 try:
858 child_path = resolve_loop_path(state["loop"], loops_dir)
859 child_fsm, _ = load_and_validate(child_path)
860 state["_subloop"] = child_fsm.to_dict().get("states", {})
861 except (FileNotFoundError, ValueError):
862 pass
863 print_json(data)
864 return 0
866 tw = terminal_width()
868 # Compute stats for header
869 n_states = len(fsm.states)
870 n_transitions = sum(
871 bool(s.on_yes)
872 + bool(s.on_no)
873 + bool(s.on_error)
874 + bool(s.on_partial)
875 + bool(s.next)
876 + bool(s.on_maintain)
877 + (len(s.route.routes) + bool(s.route.default) if s.route else 0)
878 for s in fsm.states.values()
879 )
881 # --- Compact metadata header ---
882 # Line 1: ── name ───────── N states · M transitions ──
883 stats_parts: list[str] = []
884 stats_parts.append(f"{n_states} states")
885 stats_parts.append(f"{n_transitions} transitions")
886 stats_str = " \u00b7 ".join(stats_parts)
888 header_left = f"\u2500\u2500 {loop_name} "
889 header_right = f" {stats_str} \u2500\u2500"
890 dashes = "\u2500" * max(0, tw - len(header_left) - len(header_right))
891 print(f"{header_left}{dashes}{header_right}")
893 # Line 2: source · max: N iter · handoff: X [· optional fields]
894 config_parts: list[str] = [str(path), f"max: {fsm.max_iterations} iter"]
895 config_parts.append(f"handoff: {fsm.on_handoff}")
896 if fsm.on_max_iterations is not None:
897 config_parts.append(f"on_max_iterations: {fsm.on_max_iterations}")
898 if fsm.timeout:
899 config_parts.append(f"timeout: {fsm.timeout}s")
900 if fsm.backoff:
901 config_parts.append(f"backoff: {fsm.backoff}s")
902 if fsm.maintain:
903 config_parts.append("maintain: yes")
904 if fsm.context:
905 config_parts.append(f"context: {', '.join(fsm.context.keys())}")
906 if fsm.scope:
907 config_parts.append(f"scope: {', '.join(fsm.scope)}")
908 llm = fsm.llm
909 llm_parts = []
910 if llm.model != "sonnet":
911 llm_parts.append(f"model={llm.model}")
912 if llm.max_tokens != 256:
913 llm_parts.append(f"max_tokens={llm.max_tokens}")
914 if llm.timeout != 30:
915 llm_parts.append(f"timeout={llm.timeout}s")
916 if llm_parts:
917 config_parts.append(f"llm: {', '.join(llm_parts)}")
918 if fsm.config is not None:
919 cfg_parts = []
920 if fsm.config.handoff_threshold is not None:
921 cfg_parts.append(f"handoff_threshold={fsm.config.handoff_threshold}")
922 if fsm.config.readiness_threshold is not None:
923 cfg_parts.append(f"readiness_threshold={fsm.config.readiness_threshold}")
924 if fsm.config.outcome_threshold is not None:
925 cfg_parts.append(f"outcome_threshold={fsm.config.outcome_threshold}")
926 if fsm.config.max_continuations is not None:
927 cfg_parts.append(f"max_continuations={fsm.config.max_continuations}")
928 if cfg_parts:
929 config_parts.append(f"config: {', '.join(cfg_parts)}")
930 imports = spec.get("import", [])
931 if imports:
932 config_parts.append(f"imports: {', '.join(imports)}")
933 print(" " + " \u00b7 ".join(config_parts))
935 # --- Description ---
936 description = spec.get("description", "").strip()
937 if description:
938 print()
939 print("Description:")
940 for line in description.splitlines():
941 print(f" {line}")
943 # --- ASCII FSM Diagram ---
944 verbose = getattr(args, "verbose", False)
945 from pathlib import Path
947 from little_loops.config import BRConfig
949 badges = BRConfig(Path.cwd()).loops.glyphs.to_dict()
950 facets = resolve_facets(args)
951 print()
952 print("Diagram:")
953 if facets is None:
954 diagram = _render_fsm_diagram(fsm, verbose=verbose, badges=badges)
955 else:
956 diagram = _render_fsm_diagram(
957 fsm,
958 badges=badges,
959 mode=facets.scope,
960 suppress_labels=not facets.edge_labels,
961 title_only=facets.state_detail == "title",
962 )
963 if diagram:
964 print(diagram)
966 # --- State overview table ---
967 print()
968 _print_state_overview_table(fsm)
970 # --- States & Transitions (verbose only) ---
971 if verbose:
972 print()
973 print("States:")
974 first_state = True
975 for name, state in fsm.states.items():
976 if not first_state:
977 print()
978 first_state = False
980 # Improved state section header: ── name ──── MARKERS · type ──
981 right_parts = []
982 if name == fsm.initial:
983 right_parts.append("INITIAL")
984 if state.terminal:
985 right_parts.append("TERMINAL")
986 if state.action_type:
987 right_parts.append(state.action_type)
988 right_info = " \u00b7 ".join(right_parts)
989 inner_left = f"\u2500\u2500 {name} "
990 inner_right = f" {right_info} \u2500\u2500" if right_info else " \u2500\u2500"
991 fill = "\u2500" * max(0, tw - 2 - len(inner_left) - len(inner_right))
992 print(f" {inner_left}{fill}{inner_right}")
994 if state.action:
995 if verbose:
996 indented = "\n ".join(state.action.strip().splitlines())
997 print(f" action:\n {indented}")
998 elif state.action_type == "prompt":
999 lines_act = state.action.strip().splitlines()
1000 preview = "\n ".join(lines_act[:3])
1001 if len(lines_act) > 3 or len(state.action) > 200:
1002 preview += " ..."
1003 print(f" action:\n {preview}")
1004 else: # shell, slash_command, or None
1005 action_display = (
1006 state.action[:70] + "..." if len(state.action) > 70 else state.action
1007 )
1008 print(f" action: {action_display}")
1009 if state.evaluate:
1010 ev = state.evaluate
1011 print(f" evaluate: {_humanize_evaluate_type(ev.type)}")
1012 if ev.prompt:
1013 if verbose:
1014 print(" prompt:")
1015 for line in ev.prompt.strip().splitlines():
1016 print(f" \u2502 {line}")
1017 else:
1018 ev_lines = ev.prompt.strip().splitlines()
1019 preview = ev_lines[0][:100] + (
1020 " ..." if len(ev_lines) > 1 or len(ev_lines[0]) > 100 else ""
1021 )
1022 print(f" prompt: {preview}")
1023 if ev.min_confidence != 0.5:
1024 print(f" min_confidence: {ev.min_confidence}")
1025 if ev.operator:
1026 print(f" operator: {ev.operator} {ev.target}")
1027 if ev.pattern:
1028 print(f" pattern: {ev.pattern}")
1029 if state.capture:
1030 print(f" capture: {state.capture}")
1031 if state.timeout:
1032 print(f" timeout: {state.timeout}s")
1033 # Collect (label, target) pairs
1034 raw_transitions: list[tuple[str, str]] = []
1035 for label, target in [
1036 ("yes", state.on_yes),
1037 ("no", state.on_no),
1038 ("error", state.on_error),
1039 ("partial", state.on_partial),
1040 ("next", state.next),
1041 ("maintain", state.on_maintain),
1042 ]:
1043 if target:
1044 raw_transitions.append((label, target))
1045 if state.route:
1046 for verdict, target in state.route.routes.items():
1047 raw_transitions.append((verdict, target))
1048 if state.route.default:
1049 raw_transitions.append(("_", state.route.default))
1050 # Group by target, preserving first-seen order
1051 target_labels: dict[str, list[str]] = {}
1052 seen_targets: list[str] = []
1053 for label, target in raw_transitions:
1054 if target not in target_labels:
1055 target_labels[target] = []
1056 seen_targets.append(target)
1057 target_labels[target].append(label)
1058 transitions = [
1059 f"{_colorize_label('/'.join(target_labels[t]))} \u2500\u2500\u2192 {t}"
1060 for t in seen_targets
1061 ]
1062 if transitions:
1063 print(" Transitions:")
1064 for t in transitions:
1065 print(f" {t}")
1067 # --- Commands ---
1068 print()
1069 print("Commands:")
1070 if fsm.commands:
1071 cmds = [(e.cmd, e.comment) for e in fsm.commands]
1072 else:
1073 cmds = [
1074 (f"ll-loop run {loop_name}", "run"),
1075 (f"ll-loop test {loop_name}", "single test iteration"),
1076 (f"ll-loop stop {loop_name}", "stop a running loop"),
1077 (f"ll-loop status {loop_name}", "check if running"),
1078 (f"ll-loop history {loop_name}", "execution history"),
1079 ]
1080 col_width = max(len(c) for c, _ in cmds) + 2
1081 for cmd, comment in cmds:
1082 print(f" {cmd:<{col_width}} # {comment}")
1084 return 0
1087def cmd_fragments(
1088 lib_path: str,
1089 args: argparse.Namespace,
1090 loops_dir: Path,
1091 logger: Logger,
1092) -> int:
1093 """List fragments in a library file with their descriptions.
1095 Resolves the library path relative to loops_dir or the built-in loops directory,
1096 then prints a table of fragment names and their description fields.
1097 """
1098 import yaml
1100 # Resolve path: absolute/direct → loops_dir-relative → builtin
1101 p = Path(lib_path)
1102 if not p.exists():
1103 candidate = loops_dir / lib_path
1104 if candidate.exists():
1105 p = candidate
1106 else:
1107 builtin_candidate = get_builtin_loops_dir() / lib_path
1108 if builtin_candidate.exists():
1109 p = builtin_candidate
1110 else:
1111 logger.error(f"Fragment library not found: {lib_path}")
1112 return 1
1114 try:
1115 with open(p) as f:
1116 data = yaml.safe_load(f) or {}
1117 except Exception as e:
1118 logger.error(f"Failed to load library: {e}")
1119 return 1
1121 fragments: dict[str, Any] = data.get("fragments", {})
1122 if not fragments:
1123 print(f"No fragments defined in: {p}")
1124 return 0
1126 tw = terminal_width()
1127 print(colorize(f"Fragments in {p.name} ({len(fragments)}):", "1"))
1128 print()
1130 max_name_len = max(len(name) for name in fragments)
1131 name_col_w = max(max_name_len, 8)
1132 desc_col_w = max(20, tw - name_col_w - 6)
1134 header_name = "Fragment".ljust(name_col_w)
1135 print(f" {colorize(header_name, '1')} Description")
1136 print(f" {'─' * name_col_w} {'─' * desc_col_w}")
1138 for name, frag in fragments.items():
1139 raw_desc = frag.get("description", "") if isinstance(frag, dict) else ""
1140 raw_desc = raw_desc.strip()
1141 first_line = raw_desc.splitlines()[0] if raw_desc else ""
1142 if first_line and len(first_line) > desc_col_w:
1143 first_line = first_line[: desc_col_w - 1] + "\u2026"
1144 desc_str = first_line if first_line else colorize("(no description)", "2")
1145 padded_name = name.ljust(name_col_w)
1146 print(f" {colorize(padded_name, '36;1')} {desc_str}")
1148 return 0