Coverage for little_loops / cli / issues / refine_status.py: 97%
255 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
1"""ll-issues refine-status: Refinement depth table for active issues."""
3from __future__ import annotations
5import argparse
6import json
7from typing import TYPE_CHECKING
9from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, print_json, terminal_width
11if TYPE_CHECKING:
12 from little_loops.config import BRConfig
13 from little_loops.issue_parser import IssueInfo
15# Minimum width for the title column (before terminal-width trimming)
16_MIN_TITLE_WIDTH = 20
17# Fixed column widths for non-title columns
18_ID_WIDTH = 8 # "BUG-525 "
19_PRI_WIDTH = 4 # "P2 "
20_SIZE_WIDTH = 10 # "Very Large" (longest valid value)
21_SCORE_WIDTH = 5 # "ready" col
22_CONF_WIDTH = 5 # "conf" col
23_TOTAL_WIDTH = 5 # "total"
24# Width of each command column
25_CMD_WIDTH = 6
26_NORM_WIDTH = 4 # "norm" / "✓" / "✗"
27_FMT_WIDTH = 4 # "fmt" / "✓" / "✗"
28_SOURCE_WIDTH = 7 # "source" header / "capture" value max
30# Commands that are excluded from dynamic columns (shown as static columns instead)
31_SOURCE_CMDS = {
32 "/ll:capture-issue",
33 "/ll:scan-codebase",
34 "/ll:audit-architecture",
35 "/ll:format-issue",
36}
38# Canonical workflow order for command columns
39_CANONICAL_CMD_ORDER = [
40 "/ll:capture-issue",
41 "/ll:scan-codebase",
42 "/ll:audit-architecture",
43 "/ll:format-issue",
44 "/ll:verify-issues",
45 "/ll:refine-issue",
46 "/ll:tradeoff-review-issues",
47 "/ll:map-dependencies",
48]
50_CMD_ALIASES: dict[str, str] = {
51 "/ll:capture-issue": "capture",
52 "/ll:scan-codebase": "scan",
53 "/ll:audit-architecture": "audit",
54 "/ll:format-issue": "format",
55 "/ll:verify-issues": "verify",
56 "/ll:refine-issue": "refine",
57 "/ll:tradeoff-review-issues": "tradeoff",
58 "/ll:map-dependencies": "map",
59}
61# Static column metadata: name -> (fixed_width, header_text, right_justify)
62# width=0 is a sentinel meaning the column width is computed dynamically (title only)
63_STATIC_COLUMN_SPECS: dict[str, tuple[int, str, bool]] = {
64 "id": (_ID_WIDTH, "ID", False),
65 "priority": (_PRI_WIDTH, "Pri", False),
66 "size": (_SIZE_WIDTH, "size", False),
67 "title": (0, "Title", False),
68 "source": (_SOURCE_WIDTH, "source", False),
69 "norm": (_NORM_WIDTH, "norm", False),
70 "fmt": (_FMT_WIDTH, "fmt", False),
71 "ready": (_SCORE_WIDTH, "ready", True),
72 "confidence": (_CONF_WIDTH, "conf", True),
73 "score_complexity": (_SCORE_WIDTH, "cmplx", True),
74 "score_test_coverage": (_SCORE_WIDTH, "tcov", True),
75 "score_ambiguity": (_SCORE_WIDTH, "ambig", True),
76 "score_change_surface": (_SCORE_WIDTH, "chsrf", True),
77 "total": (_TOTAL_WIDTH, "total", True),
78}
80# Default column order when no config is provided
81_DEFAULT_STATIC_COLUMNS: list[str] = [
82 "id",
83 "priority",
84 "size",
85 "title",
86 "source",
87 "norm",
88 "fmt",
89 "ready",
90 "confidence",
91 "score_complexity",
92 "score_test_coverage",
93 "score_ambiguity",
94 "score_change_surface",
95 "total",
96]
98# Columns that belong after the dynamic command block (all others go before)
99_POST_CMD_STATIC: frozenset[str] = frozenset(
100 [
101 "ready",
102 "confidence",
103 "score_complexity",
104 "score_test_coverage",
105 "score_ambiguity",
106 "score_change_surface",
107 "total",
108 ]
109)
111# Columns that are always pinned — never elided regardless of terminal width
112_PINNED_COLUMNS: frozenset[str] = frozenset(["id", "priority", "title"])
114# Default column elision order: columns dropped first when table overflows.
115# Command columns not listed here are dropped rightmost-first after this list
116# is exhausted.
117_DEFAULT_ELIDE_ORDER: list[str] = [
118 "source",
119 "norm",
120 "fmt",
121 "size",
122 "score_change_surface",
123 "score_ambiguity",
124 "score_test_coverage",
125 "score_complexity",
126 "confidence",
127 "ready",
128 "total",
129]
132def _cmd_label(cmd: str) -> str:
133 """Return display label for a command column header."""
134 if cmd in _CMD_ALIASES:
135 return _CMD_ALIASES[cmd]
136 # Fallback: strip /ll: prefix and truncate
137 short = cmd[4:] if cmd.startswith("/ll:") else cmd
138 return _truncate(short, _CMD_WIDTH)
141def _source_label(discovered_by: str | None) -> str:
142 """Return short display label for an issue's origin source."""
143 if not discovered_by:
144 return "\u2014" # em-dash
145 if discovered_by in _CMD_ALIASES:
146 return _CMD_ALIASES[discovered_by]
147 # Non-/ll: values like "github_sync" — truncate to fit
148 return _truncate(discovered_by, _SOURCE_WIDTH)
151def _truncate(text: str, width: int) -> str:
152 """Truncate text to width, replacing last char with ellipsis if needed."""
153 if len(text) <= width:
154 return text
155 return text[: width - 1].rstrip() + "\u2026"
158def _col(text: str, width: int) -> str:
159 """Left-justify text in a fixed-width column."""
160 return text.ljust(width)[:width]
163def _rcol(text: str, width: int) -> str:
164 """Right-justify text in a fixed-width column."""
165 return text.rjust(width)[:width]
168def _apply_cell_color(col: str, padded: str, plain: str) -> str:
169 """Colorize the visible content of a padded cell, preserving surrounding whitespace."""
170 if col == "id":
171 issue_type = plain.split("-")[0]
172 code = TYPE_COLOR.get(issue_type, "")
173 elif col == "priority":
174 code = PRIORITY_COLOR.get(plain, "")
175 elif col in ("norm", "fmt"):
176 if plain == "\u2713": # ✓
177 code = "32" # green
178 elif plain == "\u2717": # ✗
179 code = "31" # red
180 else:
181 code = ""
182 else:
183 return padded
185 if not code:
186 return padded
188 # Preserve leading spaces (rjust cells) and trailing spaces (ljust cells)
189 lstripped = padded.lstrip()
190 leading = padded[: len(padded) - len(lstripped)]
191 content = lstripped.rstrip()
192 trailing = lstripped[len(content) :]
193 return leading + colorize(content, code) + trailing
196def _compute_min_total_width(
197 pre_cmd: list[str], all_cmds: list[str], post_cmd: list[str], id_width: int
198) -> int:
199 """Compute the minimum table width with the title column at _MIN_TITLE_WIDTH."""
200 n_parts = len(pre_cmd) + len(all_cmds) + len(post_cmd)
201 if n_parts == 0:
202 return 0
203 col_sum = 0
204 for c in pre_cmd:
205 if c == "title":
206 col_sum += _MIN_TITLE_WIDTH
207 elif c == "id":
208 col_sum += id_width
209 elif c in _STATIC_COLUMN_SPECS:
210 col_sum += _STATIC_COLUMN_SPECS[c][0]
211 else:
212 col_sum += _CMD_WIDTH
213 col_sum += len(all_cmds) * _CMD_WIDTH
214 for c in post_cmd:
215 col_sum += _STATIC_COLUMN_SPECS[c][0] if c in _STATIC_COLUMN_SPECS else _CMD_WIDTH
216 return col_sum + 2 * (n_parts - 1)
219def _elide_columns(
220 pre_cmd: list[str],
221 all_cmds: list[str],
222 post_cmd: list[str],
223 term_cols: int,
224 id_width: int,
225 elide_order: list[str],
226) -> tuple[list[str], list[str], list[str]]:
227 """Drop columns until the table fits within term_cols.
229 Columns in *elide_order* are dropped first (in listed order). Pinned
230 columns (id, priority, title) are silently skipped even if they appear in
231 the list. After the list is exhausted, remaining command columns are
232 dropped rightmost-first.
233 """
234 pre = list(pre_cmd)
235 cmds = list(all_cmds)
236 post = list(post_cmd)
238 def fits() -> bool:
239 return _compute_min_total_width(pre, cmds, post, id_width) <= term_cols
241 if fits():
242 return pre, cmds, post
244 for col in elide_order:
245 if fits():
246 break
247 if col in _PINNED_COLUMNS:
248 continue
249 if col in pre:
250 pre.remove(col)
251 elif col in post:
252 post.remove(col)
253 elif col in cmds:
254 cmds.remove(col)
256 # Drop remaining command columns rightmost-first
257 while not fits() and cmds:
258 cmds.pop()
260 return pre, cmds, post
263def cmd_refine_status(config: BRConfig, args: argparse.Namespace) -> int:
264 """Render a refinement depth table for all active issues.
266 Each column represents a distinct /ll:* command found across Session Log
267 sections. Issues are sorted descending by refinement depth (Total), then
268 ascending by priority as a tiebreaker.
270 Args:
271 config: Project configuration.
272 args: Parsed arguments with optional .type and .format attributes.
274 Returns:
275 Exit code (0 = success).
276 """
277 from little_loops.issue_parser import find_issues, is_formatted, is_normalized
279 issue_id_filter = getattr(args, "issue_id", None)
280 type_prefixes = {args.type} if (not issue_id_filter and getattr(args, "type", None)) else None
281 issues = find_issues(config, type_prefixes=type_prefixes)
283 if issue_id_filter:
284 issues = [i for i in issues if i.issue_id == issue_id_filter]
286 if not issues:
287 if issue_id_filter:
288 print(f"Error: issue '{issue_id_filter}' not found in active issues.")
289 return 1
290 print("No active issues found.")
291 return 0
293 # Derive dynamic column set: all distinct commands across all issues
294 seen: dict[str, None] = {}
295 for issue in issues:
296 for cmd in issue.session_commands:
297 seen[cmd] = None
299 def _canonical_sort_key(cmd: str) -> tuple[int, str]:
300 try:
301 return (_CANONICAL_CMD_ORDER.index(cmd), cmd)
302 except ValueError:
303 return (len(_CANONICAL_CMD_ORDER), cmd)
305 all_cmds: list[str] = [
306 cmd for cmd in sorted(seen.keys(), key=_canonical_sort_key) if cmd not in _SOURCE_CMDS
307 ]
309 # Sort issues: descending by total commands touched, then ascending priority
310 def _sort_key(issue: IssueInfo) -> tuple[int, int]:
311 return (-len(issue.session_commands), issue.priority_int)
313 sorted_issues = sorted(issues, key=_sort_key)
315 # Dynamic ID column width: size to the longest issue_id present, minimum 8
316 id_width = max((len(issue.issue_id) for issue in sorted_issues), default=7) + 1
318 use_json_array = getattr(args, "json", False)
319 fmt = getattr(args, "format", "table")
321 if use_json_array:
322 records = [
323 {
324 "id": issue.issue_id,
325 "priority": issue.priority,
326 "title": issue.title,
327 "source": issue.discovered_by,
328 "commands": issue.session_commands,
329 "confidence_score": issue.confidence_score,
330 "outcome_confidence": issue.outcome_confidence,
331 "score_complexity": issue.score_complexity,
332 "score_test_coverage": issue.score_test_coverage,
333 "score_ambiguity": issue.score_ambiguity,
334 "score_change_surface": issue.score_change_surface,
335 "size": issue.size,
336 "total": len(issue.session_commands),
337 "normalized": is_normalized(issue.path.name),
338 "formatted": is_formatted(issue.path),
339 "refine_count": issue.session_command_counts.get("/ll:refine-issue", 0),
340 }
341 for issue in sorted_issues
342 ]
343 print_json(records[0] if issue_id_filter else records)
344 return 0
346 if fmt == "json":
347 for issue in sorted_issues:
348 record = {
349 "id": issue.issue_id,
350 "priority": issue.priority,
351 "title": issue.title,
352 "source": issue.discovered_by,
353 "commands": issue.session_commands,
354 "confidence_score": issue.confidence_score,
355 "outcome_confidence": issue.outcome_confidence,
356 "score_complexity": issue.score_complexity,
357 "score_test_coverage": issue.score_test_coverage,
358 "score_ambiguity": issue.score_ambiguity,
359 "score_change_surface": issue.score_change_surface,
360 "size": issue.size,
361 "total": len(issue.session_commands),
362 "normalized": is_normalized(issue.path.name),
363 "formatted": is_formatted(issue.path),
364 "refine_count": issue.session_command_counts.get("/ll:refine-issue", 0),
365 }
366 print(json.dumps(record))
367 return 0
369 # --- Table rendering ---
370 term_cols = terminal_width()
372 # Determine active static columns from config (empty list = use defaults)
373 config_cols = config.refine_status.columns
374 active_static = list(config_cols) if config_cols else list(_DEFAULT_STATIC_COLUMNS)
376 # Split active columns: pre-cmd (before dynamic command block) and post-cmd (after)
377 pre_cmd = [c for c in active_static if c not in _POST_CMD_STATIC]
378 post_cmd = [c for c in active_static if c in _POST_CMD_STATIC]
380 # Elide columns when the table would overflow the terminal.
381 # JSON modes exit early above, so this path is table-only.
382 elide_order = config.refine_status.elide_order or _DEFAULT_ELIDE_ORDER
383 pre_cmd, all_cmds, post_cmd = _elide_columns(
384 pre_cmd, all_cmds, post_cmd, term_cols, id_width, elide_order
385 )
387 # Compute title column width based on active columns and terminal size
388 has_title = "title" in pre_cmd
389 title_w = _MIN_TITLE_WIDTH
390 if has_title:
391 n_parts = len(pre_cmd) + len(all_cmds) + len(post_cmd)
392 non_title_sum = (
393 sum(
394 (id_width if c == "id" else _STATIC_COLUMN_SPECS[c][0])
395 if c in _STATIC_COLUMN_SPECS
396 else _CMD_WIDTH
397 for c in pre_cmd
398 if c != "title"
399 )
400 + len(all_cmds) * _CMD_WIDTH
401 + sum(
402 _STATIC_COLUMN_SPECS[c][0] if c in _STATIC_COLUMN_SPECS else _CMD_WIDTH
403 for c in post_cmd
404 )
405 )
406 title_w = max(_MIN_TITLE_WIDTH, term_cols - non_title_sum - 2 * (n_parts - 1))
408 def _get_col_display_width(col: str) -> int:
409 if col == "id":
410 return id_width
411 if col == "title":
412 return title_w
413 if col in _STATIC_COLUMN_SPECS:
414 return _STATIC_COLUMN_SPECS[col][0]
415 return _CMD_WIDTH
417 def _render_cell(col: str, value: str) -> str:
418 w = _get_col_display_width(col)
419 if col in _STATIC_COLUMN_SPECS:
420 rjust = _STATIC_COLUMN_SPECS[col][2]
421 return _rcol(value, w) if rjust else _col(value, w)
422 return _col(value, w)
424 def _header_cell(col: str) -> str:
425 if col in _STATIC_COLUMN_SPECS:
426 hdr = _STATIC_COLUMN_SPECS[col][1]
427 else:
428 hdr = _truncate(col, _get_col_display_width(col))
429 return _render_cell(col, hdr)
431 def _cell_value(col: str, issue: IssueInfo) -> str:
432 if col == "id":
433 return issue.issue_id
434 if col == "priority":
435 return issue.priority
436 if col == "title":
437 return _truncate(issue.title, title_w)
438 if col == "source":
439 return _source_label(issue.discovered_by)
440 if col == "norm":
441 return "\u2713" if is_normalized(issue.path.name) else "\u2717"
442 if col == "fmt":
443 return "\u2713" if is_formatted(issue.path) else "\u2717"
444 if col == "ready":
445 return str(issue.confidence_score) if issue.confidence_score is not None else "\u2014"
446 if col == "size":
447 return issue.size if issue.size else "\u2014"
448 if col == "confidence":
449 return (
450 str(issue.outcome_confidence) if issue.outcome_confidence is not None else "\u2014"
451 )
452 if col == "score_complexity":
453 return str(issue.score_complexity) if issue.score_complexity is not None else "\u2014"
454 if col == "score_test_coverage":
455 return (
456 str(issue.score_test_coverage)
457 if issue.score_test_coverage is not None
458 else "\u2014"
459 )
460 if col == "score_ambiguity":
461 return str(issue.score_ambiguity) if issue.score_ambiguity is not None else "\u2014"
462 if col == "score_change_surface":
463 return (
464 str(issue.score_change_surface)
465 if issue.score_change_surface is not None
466 else "\u2014"
467 )
468 if col == "total":
469 return str(len(issue.session_commands))
470 return "\u2014" # unknown column: em-dash
472 def _build_row(issue: IssueInfo | None) -> str:
473 parts: list[str] = []
474 cmd_set = set(issue.session_commands) if issue is not None else set()
476 for c in pre_cmd:
477 if issue is None:
478 parts.append(_header_cell(c))
479 else:
480 plain = _cell_value(c, issue)
481 parts.append(_apply_cell_color(c, _render_cell(c, plain), plain))
483 for c in all_cmds:
484 if issue is None:
485 parts.append(_col(_cmd_label(c), _CMD_WIDTH))
486 else:
487 if c == "/ll:refine-issue":
488 cell = str(issue.session_command_counts.get(c, 0))
489 parts.append(_col(cell, _CMD_WIDTH))
490 else:
491 hit = c in cmd_set
492 raw = "\u2713" if hit else "\u2014"
493 padded = _col(raw, _CMD_WIDTH)
494 parts.append(
495 colorize(raw, "32") + padded[len(raw) :]
496 if hit
497 else colorize(raw, "2") + padded[len(raw) :]
498 )
500 for c in post_cmd:
501 if issue is None:
502 parts.append(_header_cell(c))
503 else:
504 plain = _cell_value(c, issue)
505 parts.append(_apply_cell_color(c, _render_cell(c, plain), plain))
507 return " ".join(parts)
509 header = _build_row(None)
510 separator = "-" * len(header)
512 rows: list[str] = [header, separator]
514 for issue in sorted_issues:
515 rows.append(_build_row(issue))
517 print("\n".join(rows))
519 issue_word = "issue" if len(sorted_issues) == 1 else "issues"
520 scored = sum(1 for i in sorted_issues if i.confidence_score is not None)
521 print(f"\n{len(sorted_issues)} {issue_word} ({scored} scored)")
523 if not getattr(args, "no_key", False):
524 _print_key(all_cmds)
526 return 0
529def _print_key(all_cmds: list[str]) -> None:
530 """Print a legend mapping column headers to their full command names."""
531 print("\nKey:")
532 print(f" {'source':<12} Origin command/workflow that created the issue")
533 print(f" {'norm':<12} Filename follows naming convention (P[0-5]-TYPE-NNN-desc.md)")
534 print(f" {'fmt':<12} Issue has all required sections per type template (structural check)")
535 for cmd in all_cmds:
536 label = _cmd_label(cmd)
537 if cmd == "/ll:refine-issue":
538 print(f" {label:<12} Times /ll:refine-issue was run")
539 else:
540 print(f" {label:<12} {cmd}")
541 print(f" {'ready':<12} Readiness score (0\u2013100)")
542 print(f" {'conf':<12} Outcome confidence score (0\u2013100)")
543 print(f" {'cmplx':<12} Outcome criterion A \u2013 Complexity (0\u201325)")
544 print(f" {'tcov':<12} Outcome criterion B \u2013 Test Coverage (0\u201325)")
545 print(f" {'ambig':<12} Outcome criterion C \u2013 Ambiguity (0\u201325)")
546 print(
547 f" {'chsrf':<12} Outcome criterion D \u2013 Change Surface / Fanout Verifiability (0\u201325)"
548 )
549 print(f" {'total':<12} Number of /ll:* skills applied")