Coverage for little_loops / cli / issues / refine_status.py: 14%
240 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -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_SCORE_WIDTH = 5 # "ready" col
21_CONF_WIDTH = 5 # "conf" col
22_TOTAL_WIDTH = 5 # "total"
23# Width of each command column
24_CMD_WIDTH = 6
25_NORM_WIDTH = 4 # "norm" / "✓" / "✗"
26_FMT_WIDTH = 4 # "fmt" / "✓" / "✗"
27_SOURCE_WIDTH = 7 # "source" header / "capture" value max
29# Commands that are excluded from dynamic columns (shown as static columns instead)
30_SOURCE_CMDS = {
31 "/ll:capture-issue",
32 "/ll:scan-codebase",
33 "/ll:audit-architecture",
34 "/ll:format-issue",
35}
37# Canonical workflow order for command columns
38_CANONICAL_CMD_ORDER = [
39 "/ll:capture-issue",
40 "/ll:scan-codebase",
41 "/ll:audit-architecture",
42 "/ll:format-issue",
43 "/ll:verify-issues",
44 "/ll:refine-issue",
45 "/ll:tradeoff-review-issues",
46 "/ll:map-dependencies",
47]
49_CMD_ALIASES: dict[str, str] = {
50 "/ll:capture-issue": "capture",
51 "/ll:scan-codebase": "scan",
52 "/ll:audit-architecture": "audit",
53 "/ll:format-issue": "format",
54 "/ll:verify-issues": "verify",
55 "/ll:refine-issue": "refine",
56 "/ll:tradeoff-review-issues": "tradeoff",
57 "/ll:map-dependencies": "map",
58}
60# Static column metadata: name -> (fixed_width, header_text, right_justify)
61# width=0 is a sentinel meaning the column width is computed dynamically (title only)
62_STATIC_COLUMN_SPECS: dict[str, tuple[int, str, bool]] = {
63 "id": (_ID_WIDTH, "ID", False),
64 "priority": (_PRI_WIDTH, "Pri", False),
65 "title": (0, "Title", False),
66 "source": (_SOURCE_WIDTH, "source", False),
67 "norm": (_NORM_WIDTH, "norm", False),
68 "fmt": (_FMT_WIDTH, "fmt", False),
69 "ready": (_SCORE_WIDTH, "ready", True),
70 "confidence": (_CONF_WIDTH, "conf", True),
71 "total": (_TOTAL_WIDTH, "total", True),
72}
74# Default column order when no config is provided
75_DEFAULT_STATIC_COLUMNS: list[str] = [
76 "id",
77 "priority",
78 "title",
79 "source",
80 "norm",
81 "fmt",
82 "ready",
83 "confidence",
84 "total",
85]
87# Columns that belong after the dynamic command block (all others go before)
88_POST_CMD_STATIC: frozenset[str] = frozenset(["ready", "confidence", "total"])
90# Columns that are always pinned — never elided regardless of terminal width
91_PINNED_COLUMNS: frozenset[str] = frozenset(["id", "priority", "title"])
93# Default column elision order: columns dropped first when table overflows.
94# Command columns not listed here are dropped rightmost-first after this list
95# is exhausted.
96_DEFAULT_ELIDE_ORDER: list[str] = ["source", "norm", "fmt", "confidence", "ready", "total"]
99def _cmd_label(cmd: str) -> str:
100 """Return display label for a command column header."""
101 if cmd in _CMD_ALIASES:
102 return _CMD_ALIASES[cmd]
103 # Fallback: strip /ll: prefix and truncate
104 short = cmd[4:] if cmd.startswith("/ll:") else cmd
105 return _truncate(short, _CMD_WIDTH)
108def _source_label(discovered_by: str | None) -> str:
109 """Return short display label for an issue's origin source."""
110 if not discovered_by:
111 return "\u2014" # em-dash
112 if discovered_by in _CMD_ALIASES:
113 return _CMD_ALIASES[discovered_by]
114 # Non-/ll: values like "github_sync" — truncate to fit
115 return _truncate(discovered_by, _SOURCE_WIDTH)
118def _truncate(text: str, width: int) -> str:
119 """Truncate text to width, replacing last char with ellipsis if needed."""
120 if len(text) <= width:
121 return text
122 return text[: width - 1].rstrip() + "\u2026"
125def _col(text: str, width: int) -> str:
126 """Left-justify text in a fixed-width column."""
127 return text.ljust(width)[:width]
130def _rcol(text: str, width: int) -> str:
131 """Right-justify text in a fixed-width column."""
132 return text.rjust(width)[:width]
135def _apply_cell_color(col: str, padded: str, plain: str) -> str:
136 """Colorize the visible content of a padded cell, preserving surrounding whitespace."""
137 if col == "id":
138 issue_type = plain.split("-")[0]
139 code = TYPE_COLOR.get(issue_type, "")
140 elif col == "priority":
141 code = PRIORITY_COLOR.get(plain, "")
142 elif col in ("norm", "fmt"):
143 if plain == "\u2713": # ✓
144 code = "32" # green
145 elif plain == "\u2717": # ✗
146 code = "31" # red
147 else:
148 code = ""
149 else:
150 return padded
152 if not code:
153 return padded
155 # Preserve leading spaces (rjust cells) and trailing spaces (ljust cells)
156 lstripped = padded.lstrip()
157 leading = padded[: len(padded) - len(lstripped)]
158 content = lstripped.rstrip()
159 trailing = lstripped[len(content) :]
160 return leading + colorize(content, code) + trailing
163def _compute_min_total_width(
164 pre_cmd: list[str], all_cmds: list[str], post_cmd: list[str], id_width: int
165) -> int:
166 """Compute the minimum table width with the title column at _MIN_TITLE_WIDTH."""
167 n_parts = len(pre_cmd) + len(all_cmds) + len(post_cmd)
168 if n_parts == 0:
169 return 0
170 col_sum = 0
171 for c in pre_cmd:
172 if c == "title":
173 col_sum += _MIN_TITLE_WIDTH
174 elif c == "id":
175 col_sum += id_width
176 elif c in _STATIC_COLUMN_SPECS:
177 col_sum += _STATIC_COLUMN_SPECS[c][0]
178 else:
179 col_sum += _CMD_WIDTH
180 col_sum += len(all_cmds) * _CMD_WIDTH
181 for c in post_cmd:
182 col_sum += _STATIC_COLUMN_SPECS[c][0] if c in _STATIC_COLUMN_SPECS else _CMD_WIDTH
183 return col_sum + 2 * (n_parts - 1)
186def _elide_columns(
187 pre_cmd: list[str],
188 all_cmds: list[str],
189 post_cmd: list[str],
190 term_cols: int,
191 id_width: int,
192 elide_order: list[str],
193) -> tuple[list[str], list[str], list[str]]:
194 """Drop columns until the table fits within term_cols.
196 Columns in *elide_order* are dropped first (in listed order). Pinned
197 columns (id, priority, title) are silently skipped even if they appear in
198 the list. After the list is exhausted, remaining command columns are
199 dropped rightmost-first.
200 """
201 pre = list(pre_cmd)
202 cmds = list(all_cmds)
203 post = list(post_cmd)
205 def fits() -> bool:
206 return _compute_min_total_width(pre, cmds, post, id_width) <= term_cols
208 if fits():
209 return pre, cmds, post
211 for col in elide_order:
212 if fits():
213 break
214 if col in _PINNED_COLUMNS:
215 continue
216 if col in pre:
217 pre.remove(col)
218 elif col in post:
219 post.remove(col)
220 elif col in cmds:
221 cmds.remove(col)
223 # Drop remaining command columns rightmost-first
224 while not fits() and cmds:
225 cmds.pop()
227 return pre, cmds, post
230def cmd_refine_status(config: BRConfig, args: argparse.Namespace) -> int:
231 """Render a refinement depth table for all active issues.
233 Each column represents a distinct /ll:* command found across Session Log
234 sections. Issues are sorted descending by refinement depth (Total), then
235 ascending by priority as a tiebreaker.
237 Args:
238 config: Project configuration.
239 args: Parsed arguments with optional .type and .format attributes.
241 Returns:
242 Exit code (0 = success).
243 """
244 from little_loops.issue_parser import find_issues, is_formatted, is_normalized
246 issue_id_filter = getattr(args, "issue_id", None)
247 type_prefixes = {args.type} if (not issue_id_filter and getattr(args, "type", None)) else None
248 issues = find_issues(config, type_prefixes=type_prefixes)
250 if issue_id_filter:
251 issues = [i for i in issues if i.issue_id == issue_id_filter]
253 if not issues:
254 if issue_id_filter:
255 print(f"Error: issue '{issue_id_filter}' not found in active issues.")
256 return 1
257 print("No active issues found.")
258 return 0
260 # Derive dynamic column set: all distinct commands across all issues
261 seen: dict[str, None] = {}
262 for issue in issues:
263 for cmd in issue.session_commands:
264 seen[cmd] = None
266 def _canonical_sort_key(cmd: str) -> tuple[int, str]:
267 try:
268 return (_CANONICAL_CMD_ORDER.index(cmd), cmd)
269 except ValueError:
270 return (len(_CANONICAL_CMD_ORDER), cmd)
272 all_cmds: list[str] = [
273 cmd for cmd in sorted(seen.keys(), key=_canonical_sort_key) if cmd not in _SOURCE_CMDS
274 ]
276 # Sort issues: descending by total commands touched, then ascending priority
277 def _sort_key(issue: IssueInfo) -> tuple[int, int]:
278 return (-len(issue.session_commands), issue.priority_int)
280 sorted_issues = sorted(issues, key=_sort_key)
282 # Dynamic ID column width: size to the longest issue_id present, minimum 8
283 id_width = max((len(issue.issue_id) for issue in sorted_issues), default=7) + 1
285 use_json_array = getattr(args, "json", False)
286 fmt = getattr(args, "format", "table")
288 if use_json_array:
289 records = [
290 {
291 "id": issue.issue_id,
292 "priority": issue.priority,
293 "title": issue.title,
294 "source": issue.discovered_by,
295 "commands": issue.session_commands,
296 "confidence_score": issue.confidence_score,
297 "outcome_confidence": issue.outcome_confidence,
298 "total": len(issue.session_commands),
299 "normalized": is_normalized(issue.path.name),
300 "formatted": is_formatted(issue.path),
301 "refine_count": issue.session_command_counts.get("/ll:refine-issue", 0),
302 }
303 for issue in sorted_issues
304 ]
305 print_json(records[0] if issue_id_filter else records)
306 return 0
308 if fmt == "json":
309 for issue in sorted_issues:
310 record = {
311 "id": issue.issue_id,
312 "priority": issue.priority,
313 "title": issue.title,
314 "source": issue.discovered_by,
315 "commands": issue.session_commands,
316 "confidence_score": issue.confidence_score,
317 "outcome_confidence": issue.outcome_confidence,
318 "total": len(issue.session_commands),
319 "normalized": is_normalized(issue.path.name),
320 "formatted": is_formatted(issue.path),
321 "refine_count": issue.session_command_counts.get("/ll:refine-issue", 0),
322 }
323 print(json.dumps(record))
324 return 0
326 # --- Table rendering ---
327 term_cols = terminal_width()
329 # Determine active static columns from config (empty list = use defaults)
330 config_cols = config.refine_status.columns
331 active_static = list(config_cols) if config_cols else list(_DEFAULT_STATIC_COLUMNS)
333 # Split active columns: pre-cmd (before dynamic command block) and post-cmd (after)
334 pre_cmd = [c for c in active_static if c not in _POST_CMD_STATIC]
335 post_cmd = [c for c in active_static if c in _POST_CMD_STATIC]
337 # Elide columns when the table would overflow the terminal.
338 # JSON modes exit early above, so this path is table-only.
339 elide_order = config.refine_status.elide_order or _DEFAULT_ELIDE_ORDER
340 pre_cmd, all_cmds, post_cmd = _elide_columns(
341 pre_cmd, all_cmds, post_cmd, term_cols, id_width, elide_order
342 )
344 # Compute title column width based on active columns and terminal size
345 has_title = "title" in pre_cmd
346 title_w = _MIN_TITLE_WIDTH
347 if has_title:
348 n_parts = len(pre_cmd) + len(all_cmds) + len(post_cmd)
349 non_title_sum = (
350 sum(
351 (id_width if c == "id" else _STATIC_COLUMN_SPECS[c][0])
352 if c in _STATIC_COLUMN_SPECS
353 else _CMD_WIDTH
354 for c in pre_cmd
355 if c != "title"
356 )
357 + len(all_cmds) * _CMD_WIDTH
358 + sum(
359 _STATIC_COLUMN_SPECS[c][0] if c in _STATIC_COLUMN_SPECS else _CMD_WIDTH
360 for c in post_cmd
361 )
362 )
363 title_w = max(_MIN_TITLE_WIDTH, term_cols - non_title_sum - 2 * (n_parts - 1))
365 def _get_col_display_width(col: str) -> int:
366 if col == "id":
367 return id_width
368 if col == "title":
369 return title_w
370 if col in _STATIC_COLUMN_SPECS:
371 return _STATIC_COLUMN_SPECS[col][0]
372 return _CMD_WIDTH
374 def _render_cell(col: str, value: str) -> str:
375 w = _get_col_display_width(col)
376 if col in _STATIC_COLUMN_SPECS:
377 rjust = _STATIC_COLUMN_SPECS[col][2]
378 return _rcol(value, w) if rjust else _col(value, w)
379 return _col(value, w)
381 def _header_cell(col: str) -> str:
382 if col in _STATIC_COLUMN_SPECS:
383 hdr = _STATIC_COLUMN_SPECS[col][1]
384 else:
385 hdr = _truncate(col, _get_col_display_width(col))
386 return _render_cell(col, hdr)
388 def _cell_value(col: str, issue: IssueInfo) -> str:
389 if col == "id":
390 return issue.issue_id
391 if col == "priority":
392 return issue.priority
393 if col == "title":
394 return _truncate(issue.title, title_w)
395 if col == "source":
396 return _source_label(issue.discovered_by)
397 if col == "norm":
398 return "\u2713" if is_normalized(issue.path.name) else "\u2717"
399 if col == "fmt":
400 return "\u2713" if is_formatted(issue.path) else "\u2717"
401 if col == "ready":
402 return str(issue.confidence_score) if issue.confidence_score is not None else "\u2014"
403 if col == "confidence":
404 return (
405 str(issue.outcome_confidence) if issue.outcome_confidence is not None else "\u2014"
406 )
407 if col == "total":
408 return str(len(issue.session_commands))
409 return "\u2014" # unknown column: em-dash
411 def _build_row(issue: IssueInfo | None) -> str:
412 parts: list[str] = []
413 cmd_set = set(issue.session_commands) if issue is not None else set()
415 for c in pre_cmd:
416 if issue is None:
417 parts.append(_header_cell(c))
418 else:
419 plain = _cell_value(c, issue)
420 parts.append(_apply_cell_color(c, _render_cell(c, plain), plain))
422 for c in all_cmds:
423 if issue is None:
424 parts.append(_col(_cmd_label(c), _CMD_WIDTH))
425 else:
426 if c == "/ll:refine-issue":
427 cell = str(issue.session_command_counts.get(c, 0))
428 parts.append(_col(cell, _CMD_WIDTH))
429 else:
430 hit = c in cmd_set
431 raw = "\u2713" if hit else "\u2014"
432 padded = _col(raw, _CMD_WIDTH)
433 parts.append(
434 colorize(raw, "32") + padded[len(raw) :]
435 if hit
436 else colorize(raw, "2") + padded[len(raw) :]
437 )
439 for c in post_cmd:
440 if issue is None:
441 parts.append(_header_cell(c))
442 else:
443 plain = _cell_value(c, issue)
444 parts.append(_apply_cell_color(c, _render_cell(c, plain), plain))
446 return " ".join(parts)
448 header = _build_row(None)
449 separator = "-" * len(header)
451 rows: list[str] = [header, separator]
453 for issue in sorted_issues:
454 rows.append(_build_row(issue))
456 print("\n".join(rows))
458 issue_word = "issue" if len(sorted_issues) == 1 else "issues"
459 scored = sum(1 for i in sorted_issues if i.confidence_score is not None)
460 print(f"\n{len(sorted_issues)} {issue_word} ({scored} scored)")
462 if not getattr(args, "no_key", False):
463 _print_key(all_cmds)
465 return 0
468def _print_key(all_cmds: list[str]) -> None:
469 """Print a legend mapping column headers to their full command names."""
470 print("\nKey:")
471 print(f" {'source':<12} Origin command/workflow that created the issue")
472 print(f" {'norm':<12} Filename follows naming convention (P[0-5]-TYPE-NNN-desc.md)")
473 print(f" {'fmt':<12} Issue has all required sections per type template (structural check)")
474 for cmd in all_cmds:
475 label = _cmd_label(cmd)
476 if cmd == "/ll:refine-issue":
477 print(f" {label:<12} Times /ll:refine-issue was run")
478 else:
479 print(f" {label:<12} {cmd}")
480 print(f" {'ready':<12} Readiness score (0\u2013100)")
481 print(f" {'conf':<12} Outcome confidence score (0\u2013100)")
482 print(f" {'total':<12} Number of /ll:* skills applied")