Coverage for little_loops / cli / loop / info.py: 0%
615 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-18 03:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-18 03:19 -0500
1"""ll-loop info subcommands: list, history, show."""
3from __future__ import annotations
5import argparse
6import os
7from datetime import datetime
8from pathlib import Path
9from typing import Any
11from little_loops.cli.loop._helpers import (
12 get_builtin_loops_dir,
13 load_loop_with_spec,
14 resolve_loop_path,
15)
16from little_loops.cli.loop.layout import ( # noqa: F401
17 _EDGE_LABEL_COLORS,
18 _box_inner_lines,
19 _colorize_diagram_labels,
20 _colorize_label,
21 _render_fsm_diagram,
22)
23from little_loops.cli.output import colorize, print_json, terminal_width
24from little_loops.fsm.schema import FSMLoop, StateConfig
25from little_loops.fsm.validation import load_and_validate
26from little_loops.logger import Logger
29def _load_loop_meta(path: Path) -> dict[str, Any]:
30 """Return metadata from a loop YAML file (description, category, labels)."""
31 import yaml
33 try:
34 with open(path) as f:
35 spec = yaml.safe_load(f) or {}
36 desc_raw = spec.get("description", "") or ""
37 desc = desc_raw.splitlines()[0] if desc_raw.strip() else ""
38 category = spec.get("category", "") or ""
39 labels: list[str] = spec.get("labels", []) or []
40 return {"description": desc, "category": category, "labels": labels}
41 except Exception:
42 return {"description": "", "category": "", "labels": []}
45def cmd_list(
46 args: argparse.Namespace,
47 loops_dir: Path,
48) -> int:
49 """List loops."""
50 status_filter = getattr(args, "status", None)
51 if getattr(args, "running", False) or status_filter:
52 from little_loops.fsm.persistence import list_running_loops
54 states = list_running_loops(loops_dir)
55 if status_filter:
56 states = [s for s in states if s.status == status_filter]
57 if not states:
58 if status_filter:
59 print(f"No loops with status: {status_filter}")
60 return 1
61 print("No running loops")
62 return 0
63 if getattr(args, "json", False):
64 print_json([s.to_dict() for s in states])
65 return 0
66 print(colorize("Running loops:", "1"))
67 _STATUS_COLORS = {"running": "32", "interrupted": "33", "stopped": "2", "starting": "33"}
69 # Group by loop_name to avoid duplicate rows for multi-instance loops
70 from collections import defaultdict
72 groups: dict[str, list] = defaultdict(list)
73 for state in states:
74 groups[state.loop_name].append(state)
76 for loop_name_key, group_states in groups.items():
77 if len(group_states) == 1:
78 state = group_states[0]
79 elapsed_s = (state.accumulated_ms or 0) // 1000
80 elapsed_str = (
81 f"{elapsed_s}s" if elapsed_s < 60 else f"{elapsed_s // 60}m {elapsed_s % 60}s"
82 )
83 name_str = colorize(state.loop_name, "1")
84 state_str = colorize(state.current_state, "34")
85 status_color = _STATUS_COLORS.get(state.status, "2")
86 status_str = colorize(f"[{state.status}]", status_color)
87 elapsed_colored = colorize(elapsed_str, "2")
88 print(
89 f" {name_str}: {state_str} (iteration {state.iteration})"
90 f" {status_str} {elapsed_colored}"
91 )
92 else:
93 # Multiple instances: show a grouped summary
94 name_str = colorize(loop_name_key, "1")
95 statuses = ", ".join(
96 colorize(f"[{s.status}]", _STATUS_COLORS.get(s.status, "2"))
97 for s in group_states
98 )
99 count_str = colorize(f"({len(group_states)} instances)", "2")
100 print(f" {name_str}: {count_str} {statuses}")
101 return 0
103 builtin_only = getattr(args, "builtin", False)
105 # Collect project loops (skipped when --builtin is set)
106 project_names: set[str] = set()
107 yaml_files: list[Path] = []
108 if not builtin_only and loops_dir.exists():
109 yaml_files = sorted(loops_dir.glob("*.yaml"))
110 project_names = {p.stem for p in yaml_files}
112 # Collect built-in loops (excluding those overridden by project)
113 builtin_dir = get_builtin_loops_dir()
114 builtin_files: list[Path] = []
115 if builtin_dir.exists():
116 builtin_files = [
117 f for f in sorted(builtin_dir.glob("*.yaml")) if f.stem not in project_names
118 ]
120 if not yaml_files and not builtin_files:
121 if getattr(args, "json", False):
122 print_json([])
123 return 0
124 print("No loops available")
125 return 0
127 # Build combined metadata list
128 all_loops: list[dict[str, Any]] = []
129 for path in yaml_files:
130 meta = _load_loop_meta(path)
131 all_loops.append({"name": path.stem, "path": path, "builtin": False, **meta})
132 for path in builtin_files:
133 meta = _load_loop_meta(path)
134 all_loops.append({"name": path.stem, "path": path, "builtin": True, **meta})
136 # Apply --category filter
137 category_filter = getattr(args, "category", None)
138 if category_filter:
139 all_loops = [lp for lp in all_loops if lp["category"] == category_filter]
141 # Apply --label filter (action="append" → list or None)
142 label_filters: list[str] = getattr(args, "label", None) or []
143 if label_filters:
144 all_loops = [
145 lp
146 for lp in all_loops
147 if any(lf.lower() in [lb.lower() for lb in lp["labels"]] for lf in label_filters)
148 ]
150 if not all_loops:
151 if getattr(args, "json", False):
152 print_json([])
153 return 0
154 print("No loops match the given filters")
155 return 0
157 if getattr(args, "json", False):
158 items: list[dict[str, Any]] = []
159 for lp in all_loops:
160 item: dict[str, Any] = {
161 "name": lp["name"],
162 "path": str(lp["path"]),
163 "category": lp["category"],
164 "labels": lp["labels"],
165 }
166 if lp["builtin"]:
167 item["built_in"] = True
168 items.append(item)
169 print_json(items)
170 return 0
172 # Human-readable: group by category
173 buckets: dict[str, list[dict[str, Any]]] = {}
174 for lp in all_loops:
175 cat = lp["category"] or "uncategorized"
176 if cat not in buckets:
177 buckets[cat] = []
178 buckets[cat].append(lp)
180 # Sort categories; "uncategorized" always last
181 sorted_cats = sorted(c for c in buckets if c != "uncategorized")
182 if "uncategorized" in buckets:
183 sorted_cats.append("uncategorized")
185 for cat in sorted_cats:
186 group = buckets[cat]
187 print(colorize(f"{cat} ({len(group)}):", "1"))
188 for lp in group:
189 name_str = colorize(lp["name"], "36;1")
190 desc_str = f" {colorize(lp['description'], '2')}" if lp["description"] else ""
191 tag_str = f" {colorize('[built-in]', '2')}" if lp["builtin"] else ""
192 print(f" {name_str}{desc_str}{tag_str}")
193 print()
194 return 0
197_EVENT_TYPE_WIDTH = 16 # width of "handoff_detected"
200def _truncate(text: str, max_len: int) -> str:
201 """Truncate text to max_len with ellipsis."""
202 if max_len < 1:
203 return ""
204 if len(text) <= max_len:
205 return text
206 return text[: max_len - 1] + "\u2026"
209def _format_history_event(
210 event: dict[str, Any], verbose: bool, width: int, full: bool = False
211) -> str | None:
212 """Format a single history event. Returns None to skip the event."""
213 raw_ts = event.get("ts", "")
214 try:
215 ts = datetime.fromisoformat(raw_ts).strftime("%H:%M:%S")
216 except (ValueError, TypeError):
217 ts = raw_ts[:8] if len(raw_ts) >= 8 else raw_ts.ljust(8)
219 event_type = event.get("event", "unknown")
221 if event_type == "action_output" and not verbose:
222 return None
224 ts_str = colorize(ts, "2")
225 etype_padded = event_type.ljust(_EVENT_TYPE_WIDTH)
226 etype_color = "0"
227 detail = ""
228 extra_lines: list[str] = []
230 # Indentation prefix for verbose sub-lines (aligns under event detail column)
231 _indent = " " * (8 + 2 + _EVENT_TYPE_WIDTH + 2)
233 if event_type == "loop_start":
234 etype_color = "1"
235 detail = event.get("loop", "")
237 elif event_type == "loop_complete":
238 etype_color = "1"
239 final_state = event.get("final_state", "")
240 iterations = event.get("iterations", "")
241 terminated_by = event.get("terminated_by", "")
242 detail = f"{final_state} {iterations} iter [{terminated_by}]"
244 elif event_type == "loop_resume":
245 etype_color = "1"
246 from_state = event.get("from_state", "")
247 iteration = event.get("iteration", "")
248 detail = f"from={from_state} iter={iteration}"
250 elif event_type == "state_enter":
251 etype_color = "34"
252 state = event.get("state", "")
253 iteration = event.get("iteration", "")
254 detail = f"{colorize(state, '1')} (iter {iteration})"
256 elif event_type == "action_start":
257 action = event.get("action", "")
258 is_prompt = event.get("is_prompt", False)
259 kind_label = "prompt" if is_prompt else "shell"
260 kind_str = colorize(f"[{kind_label}]", "2")
261 first_line = (
262 next((ln.strip() for ln in action.splitlines() if ln.strip()), "")
263 if is_prompt
264 else action
265 )
266 avail = width - 8 - 2 - _EVENT_TYPE_WIDTH - 2 - len(kind_label) - 2 - 2
267 detail = f"{_truncate(first_line, max(avail, 20))} {kind_str}"
269 elif event_type == "action_output":
270 # Only reached in verbose mode
271 etype_color = "2"
272 detail = colorize("\u2502 " + event.get("line", ""), "2")
274 elif event_type == "action_complete":
275 exit_code = event.get("exit_code", 0)
276 duration_ms = event.get("duration_ms", 0)
277 if exit_code == 0:
278 etype_color = "2"
279 status_str = colorize("\u2713", "32")
280 else:
281 etype_color = "38;5;208"
282 status_str = colorize(f"\u2717 exit={exit_code}", "38;5;208")
283 detail = f"{status_str} {duration_ms}ms"
284 is_prompt = event.get("is_prompt", False)
285 session_jsonl = event.get("session_jsonl") if is_prompt else None
286 if session_jsonl:
287 session_display = session_jsonl if verbose else os.path.basename(session_jsonl)
288 detail += f" session={colorize(session_display, '2')}"
289 if verbose:
290 output_preview = event.get("output_preview", "")
291 if output_preview:
292 avail_w = width - len(_indent) - 2
293 preview_text = (
294 output_preview if full else _truncate(output_preview, max(avail_w, 40))
295 )
296 for preview_line in preview_text.splitlines()[:5]:
297 extra_lines.append(colorize(_indent + "\u2502 " + preview_line, "2"))
299 elif event_type == "evaluate":
300 verdict = event.get("verdict", "")
301 confidence = event.get("confidence", "")
302 reason = event.get("reason", "")
303 if verdict == "yes":
304 etype_color = "32"
305 verdict_str = colorize("\u2713 yes", "32")
306 else:
307 etype_color = "38;5;208"
308 verdict_str = colorize(f"\u2717 {verdict}", "38;5;208")
309 conf_part = f" confidence={confidence}" if confidence != "" else ""
310 avail = width - 8 - 2 - _EVENT_TYPE_WIDTH - 2 - len("\u2713 yes") - len(conf_part) - 2
311 reason_part = f" {_truncate(reason, max(avail, 20))}" if reason else ""
312 detail = f"{verdict_str}{conf_part}{reason_part}"
313 if verbose:
314 llm_model = event.get("llm_model", "")
315 llm_latency_ms = event.get("llm_latency_ms", "")
316 llm_prompt = event.get("llm_prompt", "")
317 llm_raw_output = event.get("llm_raw_output", "")
318 if llm_model or llm_prompt:
319 meta_parts = []
320 if llm_model:
321 meta_parts.append(f"model={llm_model}")
322 if llm_latency_ms != "":
323 meta_parts.append(f"latency={llm_latency_ms}ms")
324 meta_str = " ".join(meta_parts)
325 extra_lines.append(
326 colorize(_indent + colorize("LLM Call", "2") + " " + meta_str, "2")
327 )
328 avail_w = width - len(_indent) - len("Prompt: ") - 2
329 if llm_prompt:
330 prompt_text = llm_prompt if full else _truncate(llm_prompt, max(avail_w, 40))
331 extra_lines.append(colorize(_indent + "Prompt: " + prompt_text, "2"))
332 if llm_raw_output:
333 resp_text = (
334 llm_raw_output if full else _truncate(llm_raw_output, max(avail_w, 40))
335 )
336 extra_lines.append(colorize(_indent + "Response: " + resp_text, "2"))
338 elif event_type == "route":
339 etype_color = "2"
340 from_state = event.get("from", "")
341 to_state = event.get("to", "")
342 detail = f"{from_state} \u2192 {colorize(to_state, '34')}"
344 elif event_type == "handoff_detected":
345 etype_color = "33"
346 detail = f"state={event.get('state', '')} iter={event.get('iteration', '')}"
348 else:
349 details = {k: v for k, v in event.items() if k not in ("event", "ts")}
350 detail = " ".join(f"{k}={v}" for k, v in details.items())
352 etype_str = colorize(etype_padded, etype_color)
353 main_line = f"{ts_str} {etype_str} {detail}"
354 if extra_lines:
355 return "\n".join([main_line] + extra_lines)
356 return main_line
359def _format_duration(ms: int) -> str:
360 """Format milliseconds as a human-readable duration."""
361 if ms < 1000:
362 return f"{ms}ms"
363 s = ms // 1000
364 if s < 60:
365 return f"{s}s"
366 m, s = divmod(s, 60)
367 if m < 60:
368 return f"{m}m{s:02d}s"
369 h, m = divmod(m, 60)
370 return f"{h}h{m:02d}m{s:02d}s"
373def _list_archived_runs(loop_name: str, loops_dir: Path, as_json: bool) -> int:
374 """List archived runs for a loop."""
375 import json as _json
377 from little_loops.fsm.persistence import HISTORY_DIR, LoopState
379 history_base = loops_dir / HISTORY_DIR
380 if not history_base.exists():
381 print(f"No history for: {loop_name}")
382 return 0
384 # Flat layout: run dirs are <run_id>-<loop_name> directly under .history/
385 suffix = f"-{loop_name}"
386 runs: list[tuple[str, LoopState | None]] = []
387 for run_dir in sorted(history_base.iterdir(), key=lambda d: d.name, reverse=True):
388 if not run_dir.is_dir() or not run_dir.name.endswith(suffix):
389 continue
390 run_id = run_dir.name[: -len(suffix)]
391 state_file = run_dir / "state.json"
392 state: LoopState | None = None
393 if state_file.exists():
394 try:
395 data = _json.loads(state_file.read_text())
396 state = LoopState.from_dict(data)
397 except (ValueError, KeyError):
398 pass
399 runs.append((run_id, state))
401 if not runs:
402 print(f"No history for: {loop_name}")
403 return 0
405 if as_json:
406 print(
407 _json.dumps(
408 [
409 {
410 "run_id": rid,
411 "status": s.status if s else None,
412 "started_at": s.started_at if s else None,
413 "iterations": s.iteration if s else None,
414 "duration_ms": s.accumulated_ms if s else None,
415 }
416 for rid, s in runs
417 ],
418 indent=2,
419 )
420 )
421 return 0
423 status_colors = {
424 "completed": "\033[32m",
425 "failed": "\033[31m",
426 "interrupted": "\033[33m",
427 "awaiting_continuation": "\033[36m",
428 "timed_out": "\033[33m",
429 "running": "\033[34m",
430 }
431 reset = "\033[0m"
433 print(f"Archived runs for: {loop_name} ({len(runs)} total)")
434 print()
436 for run_id, state in runs:
437 if state is not None:
438 color = status_colors.get(state.status, "")
439 status_str = f"{color}{state.status}{reset}"
440 duration_str = _format_duration(state.accumulated_ms) if state.accumulated_ms else "?"
441 started = state.started_at[:19].replace("T", " ") if state.started_at else "?"
442 iters = f"{state.iteration} iters"
443 else:
444 status_str = "unknown"
445 duration_str = "?"
446 started = "?"
447 iters = "?"
448 print(f" {run_id} {status_str} {started} {iters} {duration_str}")
450 print()
451 print(f"To view events: ll-loop history {loop_name} <run-id>")
452 return 0
455def cmd_history(
456 loop_name: str,
457 run_id: str | None,
458 args: argparse.Namespace,
459 loops_dir: Path,
460) -> int:
461 """Show loop history.
463 Without run_id: lists all archived runs with status and duration.
464 With run_id: shows events for that specific archived run.
465 """
466 tail = getattr(args, "tail", 50)
467 full = getattr(args, "full", False)
468 verbose = getattr(args, "verbose", False) or full
469 as_json = getattr(args, "json", False)
471 if run_id is None:
472 return _list_archived_runs(loop_name, loops_dir, as_json)
474 # Show events for a specific archived run
475 from little_loops.fsm.persistence import get_archived_events
477 events = get_archived_events(loop_name, run_id, loops_dir)
479 if not events:
480 print(f"No events found for run {run_id} of loop {loop_name}")
481 return 1
483 w = terminal_width()
484 if not verbose:
485 events = [e for e in events if e.get("event") != "action_output"]
487 # Apply optional filters (before --tail slice)
488 event_filter = getattr(args, "event", None)
489 if event_filter:
490 events = [e for e in events if e.get("event") == event_filter]
492 state_filter = getattr(args, "state", None)
493 if state_filter:
494 events = [
495 e
496 for e in events
497 if (
498 e.get("state") == state_filter
499 or e.get("from") == state_filter
500 or e.get("to") == state_filter
501 )
502 ]
504 since_str = getattr(args, "since", None)
505 if since_str:
506 from datetime import timedelta
508 from little_loops.text_utils import parse_duration
510 cutoff = datetime.now() - timedelta(seconds=parse_duration(since_str))
511 events = [
512 e
513 for e in events
514 if datetime.fromisoformat(e["ts"].replace("Z", "+00:00")).replace(tzinfo=None) >= cutoff
515 ]
517 if as_json:
518 print_json(events[-tail:])
519 return 0
520 for event in events[-tail:]:
521 line = _format_history_event(event, verbose, w, full=full)
522 if line is not None:
523 print(line)
525 return 0
528# ---------------------------------------------------------------------------
529# FSM diagram renderer — delegated to layout module (re-exported above)
530# ---------------------------------------------------------------------------
533# ---------------------------------------------------------------------------
534# State overview table
535# ---------------------------------------------------------------------------
538def _compact_transitions(state: StateConfig) -> str:
539 """Return a compact transition string for the overview table."""
540 raw: list[tuple[str, str]] = []
541 for label, target in [
542 ("yes", state.on_yes),
543 ("no", state.on_no),
544 ("error", state.on_error),
545 ("partial", state.on_partial),
546 ("next", state.next),
547 ]:
548 if target:
549 raw.append((label, target))
550 if state.route:
551 for verdict, target in state.route.routes.items():
552 raw.append((verdict, target))
553 if state.route.default:
554 raw.append(("_", state.route.default))
555 if not raw:
556 return "\u2014"
557 # Group by target, preserving first-seen order
558 seen: list[str] = []
559 by_target: dict[str, list[str]] = {}
560 for label, target in raw:
561 if target not in by_target:
562 seen.append(target)
563 by_target[target] = []
564 by_target[target].append(label)
565 return ", ".join(f"{'/'.join(by_target[t])}\u2192{t}" for t in seen)
568def _print_state_overview_table(fsm: FSMLoop) -> None:
569 """Print a compact summary table of all states."""
570 rows: list[tuple[str, str, str, str]] = []
571 for name, state in fsm.states.items():
572 # State name column
573 state_col = f"\u2192 {name}" if name == fsm.initial else f" {name}"
575 # Type column
576 if state.terminal:
577 type_col = "\u2014"
578 elif state.action_type:
579 type_col = state.action_type
580 elif state.action:
581 type_col = "shell"
582 else:
583 type_col = "\u2014"
585 # Action preview column
586 if state.terminal:
587 action_col = "(terminal)"
588 elif state.action:
589 src_lines = [ln.rstrip() for ln in state.action.strip().splitlines() if ln.rstrip()]
590 action_col = src_lines[0] if src_lines else "\u2014"
591 else:
592 action_col = "\u2014"
594 # Transitions column
595 trans_col = _compact_transitions(state)
596 rows.append((state_col, type_col, action_col, trans_col))
598 if not rows:
599 return
601 tw = terminal_width()
602 headers = ("State", "Type", "Action Preview", "Transitions")
603 col0_w = max(len(headers[0]), max(len(r[0]) for r in rows))
604 col1_w = max(len(headers[1]), max(len(r[1]) for r in rows))
605 # Remaining width split between action preview and transitions
606 fixed = col0_w + col1_w + 10 # margins + separators
607 remaining = max(20, tw - fixed)
608 col2_w = min(50, max(len(headers[2]), max(len(r[2]) for r in rows)), remaining * 3 // 5)
609 col2_w = max(10, col2_w)
610 col3_w = max(10, remaining - col2_w)
612 print(f" {headers[0]:<{col0_w}} {headers[1]:<{col1_w}} {headers[2]:<{col2_w}} {headers[3]}")
613 dash = "\u2500"
614 print(f" {dash * col0_w} {dash * col1_w} {dash * col2_w} {dash * col3_w}")
615 for state_col, type_col, action_col, trans_col in rows:
616 if len(action_col) > col2_w:
617 action_col = action_col[: col2_w - 1] + "\u2026"
618 if len(trans_col) > col3_w:
619 trans_col = trans_col[: col3_w - 1] + "\u2026"
620 colored_type = colorize(type_col, "2") if type_col == "\u2014" else type_col
621 print(
622 f" {state_col:<{col0_w}} {colored_type:<{col1_w}} "
623 f"{action_col:<{col2_w}} {trans_col}"
624 )
627# ---------------------------------------------------------------------------
628# cmd_show
629# ---------------------------------------------------------------------------
631_EVALUATE_TYPE_DISPLAY: dict[str, str] = {
632 "llm": "LLM",
633 "llm_structured": "LLM (structured)",
634 "exit_code": "exit code",
635 "output_numeric": "numeric",
636 "output_contains": "contains",
637 "output_json": "JSON",
638 "convergence": "convergence",
639}
642def _humanize_evaluate_type(ev_type: str) -> str:
643 return _EVALUATE_TYPE_DISPLAY.get(ev_type, ev_type)
646def cmd_show(
647 loop_name: str,
648 args: argparse.Namespace,
649 loops_dir: Path,
650 logger: Logger,
651) -> int:
652 """Show loop details and structure."""
653 try:
654 path = resolve_loop_path(loop_name, loops_dir)
655 fsm, spec = load_loop_with_spec(loop_name, loops_dir, logger)
656 except FileNotFoundError as e:
657 logger.error(str(e))
658 return 1
659 except ValueError as e:
660 logger.error(f"Invalid loop: {e}")
661 return 1
663 if getattr(args, "json", False):
664 data = fsm.to_dict()
665 if getattr(args, "resolved", False):
666 for state in data.get("states", {}).values():
667 if "loop" in state:
668 try:
669 child_path = resolve_loop_path(state["loop"], loops_dir)
670 child_fsm, _ = load_and_validate(child_path)
671 state["_subloop"] = child_fsm.to_dict().get("states", {})
672 except (FileNotFoundError, ValueError):
673 pass
674 print_json(data)
675 return 0
677 tw = terminal_width()
679 # Compute stats for header
680 n_states = len(fsm.states)
681 n_transitions = sum(
682 bool(s.on_yes)
683 + bool(s.on_no)
684 + bool(s.on_error)
685 + bool(s.on_partial)
686 + bool(s.next)
687 + bool(s.on_maintain)
688 + (len(s.route.routes) + bool(s.route.default) if s.route else 0)
689 for s in fsm.states.values()
690 )
692 # --- Compact metadata header ---
693 # Line 1: ── name ───────── N states · M transitions ──
694 stats_parts: list[str] = []
695 stats_parts.append(f"{n_states} states")
696 stats_parts.append(f"{n_transitions} transitions")
697 stats_str = " \u00b7 ".join(stats_parts)
699 header_left = f"\u2500\u2500 {loop_name} "
700 header_right = f" {stats_str} \u2500\u2500"
701 dashes = "\u2500" * max(0, tw - len(header_left) - len(header_right))
702 print(f"{header_left}{dashes}{header_right}")
704 # Line 2: source · max: N iter · handoff: X [· optional fields]
705 config_parts: list[str] = [str(path), f"max: {fsm.max_iterations} iter"]
706 config_parts.append(f"handoff: {fsm.on_handoff}")
707 if fsm.timeout:
708 config_parts.append(f"timeout: {fsm.timeout}s")
709 if fsm.backoff:
710 config_parts.append(f"backoff: {fsm.backoff}s")
711 if fsm.maintain:
712 config_parts.append("maintain: yes")
713 if fsm.context:
714 config_parts.append(f"context: {', '.join(fsm.context.keys())}")
715 if fsm.scope:
716 config_parts.append(f"scope: {', '.join(fsm.scope)}")
717 llm = fsm.llm
718 llm_parts = []
719 if llm.model != "sonnet":
720 llm_parts.append(f"model={llm.model}")
721 if llm.max_tokens != 256:
722 llm_parts.append(f"max_tokens={llm.max_tokens}")
723 if llm.timeout != 30:
724 llm_parts.append(f"timeout={llm.timeout}s")
725 if llm_parts:
726 config_parts.append(f"llm: {', '.join(llm_parts)}")
727 if fsm.config is not None:
728 cfg_parts = []
729 if fsm.config.handoff_threshold is not None:
730 cfg_parts.append(f"handoff_threshold={fsm.config.handoff_threshold}")
731 if fsm.config.readiness_threshold is not None:
732 cfg_parts.append(f"readiness_threshold={fsm.config.readiness_threshold}")
733 if fsm.config.outcome_threshold is not None:
734 cfg_parts.append(f"outcome_threshold={fsm.config.outcome_threshold}")
735 if fsm.config.max_continuations is not None:
736 cfg_parts.append(f"max_continuations={fsm.config.max_continuations}")
737 if cfg_parts:
738 config_parts.append(f"config: {', '.join(cfg_parts)}")
739 imports = spec.get("import", [])
740 if imports:
741 config_parts.append(f"imports: {', '.join(imports)}")
742 print(" " + " \u00b7 ".join(config_parts))
744 # --- Description ---
745 description = spec.get("description", "").strip()
746 if description:
747 print()
748 print("Description:")
749 for line in description.splitlines():
750 print(f" {line}")
752 # --- ASCII FSM Diagram ---
753 verbose = getattr(args, "verbose", False)
754 from pathlib import Path
756 from little_loops.config import BRConfig
758 badges = BRConfig(Path.cwd()).loops.glyphs.to_dict()
759 print()
760 print("Diagram:")
761 diagram = _render_fsm_diagram(fsm, verbose=verbose, badges=badges)
762 if diagram:
763 print(diagram)
765 # --- State overview table ---
766 print()
767 _print_state_overview_table(fsm)
769 # --- States & Transitions (verbose only) ---
770 if verbose:
771 print()
772 print("States:")
773 first_state = True
774 for name, state in fsm.states.items():
775 if not first_state:
776 print()
777 first_state = False
779 # Improved state section header: ── name ──── MARKERS · type ──
780 right_parts = []
781 if name == fsm.initial:
782 right_parts.append("INITIAL")
783 if state.terminal:
784 right_parts.append("TERMINAL")
785 if state.action_type:
786 right_parts.append(state.action_type)
787 right_info = " \u00b7 ".join(right_parts)
788 inner_left = f"\u2500\u2500 {name} "
789 inner_right = f" {right_info} \u2500\u2500" if right_info else " \u2500\u2500"
790 fill = "\u2500" * max(0, tw - 2 - len(inner_left) - len(inner_right))
791 print(f" {inner_left}{fill}{inner_right}")
793 if state.action:
794 if verbose:
795 indented = "\n ".join(state.action.strip().splitlines())
796 print(f" action:\n {indented}")
797 elif state.action_type == "prompt":
798 lines_act = state.action.strip().splitlines()
799 preview = "\n ".join(lines_act[:3])
800 if len(lines_act) > 3 or len(state.action) > 200:
801 preview += " ..."
802 print(f" action:\n {preview}")
803 else: # shell, slash_command, or None
804 action_display = (
805 state.action[:70] + "..." if len(state.action) > 70 else state.action
806 )
807 print(f" action: {action_display}")
808 if state.evaluate:
809 ev = state.evaluate
810 print(f" evaluate: {_humanize_evaluate_type(ev.type)}")
811 if ev.prompt:
812 if verbose:
813 print(" prompt:")
814 for line in ev.prompt.strip().splitlines():
815 print(f" \u2502 {line}")
816 else:
817 ev_lines = ev.prompt.strip().splitlines()
818 preview = ev_lines[0][:100] + (
819 " ..." if len(ev_lines) > 1 or len(ev_lines[0]) > 100 else ""
820 )
821 print(f" prompt: {preview}")
822 if ev.min_confidence != 0.5:
823 print(f" min_confidence: {ev.min_confidence}")
824 if ev.operator:
825 print(f" operator: {ev.operator} {ev.target}")
826 if ev.pattern:
827 print(f" pattern: {ev.pattern}")
828 if state.capture:
829 print(f" capture: {state.capture}")
830 if state.timeout:
831 print(f" timeout: {state.timeout}s")
832 # Collect (label, target) pairs
833 raw_transitions: list[tuple[str, str]] = []
834 for label, target in [
835 ("yes", state.on_yes),
836 ("no", state.on_no),
837 ("error", state.on_error),
838 ("partial", state.on_partial),
839 ("next", state.next),
840 ("maintain", state.on_maintain),
841 ]:
842 if target:
843 raw_transitions.append((label, target))
844 if state.route:
845 for verdict, target in state.route.routes.items():
846 raw_transitions.append((verdict, target))
847 if state.route.default:
848 raw_transitions.append(("_", state.route.default))
849 # Group by target, preserving first-seen order
850 target_labels: dict[str, list[str]] = {}
851 seen_targets: list[str] = []
852 for label, target in raw_transitions:
853 if target not in target_labels:
854 target_labels[target] = []
855 seen_targets.append(target)
856 target_labels[target].append(label)
857 transitions = [
858 f"{_colorize_label('/'.join(target_labels[t]))} \u2500\u2500\u2192 {t}"
859 for t in seen_targets
860 ]
861 if transitions:
862 print(" Transitions:")
863 for t in transitions:
864 print(f" {t}")
866 # --- Commands ---
867 print()
868 print("Commands:")
869 if fsm.commands:
870 cmds = [(e.cmd, e.comment) for e in fsm.commands]
871 else:
872 cmds = [
873 (f"ll-loop run {loop_name}", "run"),
874 (f"ll-loop test {loop_name}", "single test iteration"),
875 (f"ll-loop stop {loop_name}", "stop a running loop"),
876 (f"ll-loop status {loop_name}", "check if running"),
877 (f"ll-loop history {loop_name}", "execution history"),
878 ]
879 col_width = max(len(c) for c, _ in cmds) + 2
880 for cmd, comment in cmds:
881 print(f" {cmd:<{col_width}} # {comment}")
883 return 0
886def cmd_fragments(
887 lib_path: str,
888 args: argparse.Namespace,
889 loops_dir: Path,
890 logger: Logger,
891) -> int:
892 """List fragments in a library file with their descriptions.
894 Resolves the library path relative to loops_dir or the built-in loops directory,
895 then prints a table of fragment names and their description fields.
896 """
897 import yaml
899 # Resolve path: absolute/direct → loops_dir-relative → builtin
900 p = Path(lib_path)
901 if not p.exists():
902 candidate = loops_dir / lib_path
903 if candidate.exists():
904 p = candidate
905 else:
906 builtin_candidate = get_builtin_loops_dir() / lib_path
907 if builtin_candidate.exists():
908 p = builtin_candidate
909 else:
910 logger.error(f"Fragment library not found: {lib_path}")
911 return 1
913 try:
914 with open(p) as f:
915 data = yaml.safe_load(f) or {}
916 except Exception as e:
917 logger.error(f"Failed to load library: {e}")
918 return 1
920 fragments: dict[str, Any] = data.get("fragments", {})
921 if not fragments:
922 print(f"No fragments defined in: {p}")
923 return 0
925 tw = terminal_width()
926 print(colorize(f"Fragments in {p.name} ({len(fragments)}):", "1"))
927 print()
929 max_name_len = max(len(name) for name in fragments)
930 name_col_w = max(max_name_len, 8)
931 desc_col_w = max(20, tw - name_col_w - 6)
933 header_name = "Fragment".ljust(name_col_w)
934 print(f" {colorize(header_name, '1')} Description")
935 print(f" {'─' * name_col_w} {'─' * desc_col_w}")
937 for name, frag in fragments.items():
938 raw_desc = frag.get("description", "") if isinstance(frag, dict) else ""
939 raw_desc = raw_desc.strip()
940 first_line = raw_desc.splitlines()[0] if raw_desc else ""
941 if first_line and len(first_line) > desc_col_w:
942 first_line = first_line[: desc_col_w - 1] + "\u2026"
943 desc_str = first_line if first_line else colorize("(no description)", "2")
944 padded_name = name.ljust(name_col_w)
945 print(f" {colorize(padded_name, '36;1')} {desc_str}")
947 return 0