Coverage for little_loops / cli / issues / refine_status.py: 0%

233 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:18 -0500

1"""ll-issues refine-status: Refinement depth table for active issues.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import json 

7from typing import TYPE_CHECKING 

8 

9from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, print_json, terminal_width 

10 

11if TYPE_CHECKING: 

12 from little_loops.config import BRConfig 

13 from little_loops.issue_parser import IssueInfo 

14 

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 

28 

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} 

36 

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] 

48 

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} 

59 

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} 

73 

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] 

86 

87# Columns that belong after the dynamic command block (all others go before) 

88_POST_CMD_STATIC: frozenset[str] = frozenset(["ready", "confidence", "total"]) 

89 

90# Columns that are always pinned — never elided regardless of terminal width 

91_PINNED_COLUMNS: frozenset[str] = frozenset(["id", "priority", "title"]) 

92 

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"] 

97 

98 

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) 

106 

107 

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) 

116 

117 

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" 

123 

124 

125def _col(text: str, width: int) -> str: 

126 """Left-justify text in a fixed-width column.""" 

127 return text.ljust(width)[:width] 

128 

129 

130def _rcol(text: str, width: int) -> str: 

131 """Right-justify text in a fixed-width column.""" 

132 return text.rjust(width)[:width] 

133 

134 

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 

151 

152 if not code: 

153 return padded 

154 

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 

161 

162 

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) 

184 

185 

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. 

195 

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) 

204 

205 def fits() -> bool: 

206 return _compute_min_total_width(pre, cmds, post, id_width) <= term_cols 

207 

208 if fits(): 

209 return pre, cmds, post 

210 

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) 

222 

223 # Drop remaining command columns rightmost-first 

224 while not fits() and cmds: 

225 cmds.pop() 

226 

227 return pre, cmds, post 

228 

229 

230def cmd_refine_status(config: BRConfig, args: argparse.Namespace) -> int: 

231 """Render a refinement depth table for all active issues. 

232 

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. 

236 

237 Args: 

238 config: Project configuration. 

239 args: Parsed arguments with optional .type and .format attributes. 

240 

241 Returns: 

242 Exit code (0 = success). 

243 """ 

244 from little_loops.issue_parser import find_issues, is_formatted, is_normalized 

245 

246 type_prefixes = {args.type} if getattr(args, "type", None) else None 

247 issues = find_issues(config, type_prefixes=type_prefixes) 

248 

249 if not issues: 

250 print("No active issues found.") 

251 return 0 

252 

253 # Derive dynamic column set: all distinct commands across all issues 

254 seen: dict[str, None] = {} 

255 for issue in issues: 

256 for cmd in issue.session_commands: 

257 seen[cmd] = None 

258 

259 def _canonical_sort_key(cmd: str) -> tuple[int, str]: 

260 try: 

261 return (_CANONICAL_CMD_ORDER.index(cmd), cmd) 

262 except ValueError: 

263 return (len(_CANONICAL_CMD_ORDER), cmd) 

264 

265 all_cmds: list[str] = [ 

266 cmd for cmd in sorted(seen.keys(), key=_canonical_sort_key) if cmd not in _SOURCE_CMDS 

267 ] 

268 

269 # Sort issues: descending by total commands touched, then ascending priority 

270 def _sort_key(issue: IssueInfo) -> tuple[int, int]: 

271 return (-len(issue.session_commands), issue.priority_int) 

272 

273 sorted_issues = sorted(issues, key=_sort_key) 

274 

275 # Dynamic ID column width: size to the longest issue_id present, minimum 8 

276 id_width = max((len(issue.issue_id) for issue in sorted_issues), default=7) + 1 

277 

278 use_json_array = getattr(args, "json", False) 

279 fmt = getattr(args, "format", "table") 

280 

281 if use_json_array: 

282 print_json( 

283 [ 

284 { 

285 "id": issue.issue_id, 

286 "priority": issue.priority, 

287 "title": issue.title, 

288 "source": issue.discovered_by, 

289 "commands": issue.session_commands, 

290 "confidence_score": issue.confidence_score, 

291 "outcome_confidence": issue.outcome_confidence, 

292 "total": len(issue.session_commands), 

293 "normalized": is_normalized(issue.path.name), 

294 "formatted": is_formatted(issue.path), 

295 "refine_count": issue.session_command_counts.get("/ll:refine-issue", 0), 

296 } 

297 for issue in sorted_issues 

298 ] 

299 ) 

300 return 0 

301 

302 if fmt == "json": 

303 for issue in sorted_issues: 

304 record = { 

305 "id": issue.issue_id, 

306 "priority": issue.priority, 

307 "title": issue.title, 

308 "source": issue.discovered_by, 

309 "commands": issue.session_commands, 

310 "confidence_score": issue.confidence_score, 

311 "outcome_confidence": issue.outcome_confidence, 

312 "total": len(issue.session_commands), 

313 "normalized": is_normalized(issue.path.name), 

314 "formatted": is_formatted(issue.path), 

315 "refine_count": issue.session_command_counts.get("/ll:refine-issue", 0), 

316 } 

317 print(json.dumps(record)) 

318 return 0 

319 

320 # --- Table rendering --- 

321 term_cols = terminal_width() 

322 

323 # Determine active static columns from config (empty list = use defaults) 

324 config_cols = config.refine_status.columns 

325 active_static = list(config_cols) if config_cols else list(_DEFAULT_STATIC_COLUMNS) 

326 

327 # Split active columns: pre-cmd (before dynamic command block) and post-cmd (after) 

328 pre_cmd = [c for c in active_static if c not in _POST_CMD_STATIC] 

329 post_cmd = [c for c in active_static if c in _POST_CMD_STATIC] 

330 

331 # Elide columns when the table would overflow the terminal. 

332 # JSON modes exit early above, so this path is table-only. 

333 elide_order = config.refine_status.elide_order or _DEFAULT_ELIDE_ORDER 

334 pre_cmd, all_cmds, post_cmd = _elide_columns( 

335 pre_cmd, all_cmds, post_cmd, term_cols, id_width, elide_order 

336 ) 

337 

338 # Compute title column width based on active columns and terminal size 

339 has_title = "title" in pre_cmd 

340 title_w = _MIN_TITLE_WIDTH 

341 if has_title: 

342 n_parts = len(pre_cmd) + len(all_cmds) + len(post_cmd) 

343 non_title_sum = ( 

344 sum( 

345 (id_width if c == "id" else _STATIC_COLUMN_SPECS[c][0]) 

346 if c in _STATIC_COLUMN_SPECS 

347 else _CMD_WIDTH 

348 for c in pre_cmd 

349 if c != "title" 

350 ) 

351 + len(all_cmds) * _CMD_WIDTH 

352 + sum( 

353 _STATIC_COLUMN_SPECS[c][0] if c in _STATIC_COLUMN_SPECS else _CMD_WIDTH 

354 for c in post_cmd 

355 ) 

356 ) 

357 title_w = max(_MIN_TITLE_WIDTH, term_cols - non_title_sum - 2 * (n_parts - 1)) 

358 

359 def _get_col_display_width(col: str) -> int: 

360 if col == "id": 

361 return id_width 

362 if col == "title": 

363 return title_w 

364 if col in _STATIC_COLUMN_SPECS: 

365 return _STATIC_COLUMN_SPECS[col][0] 

366 return _CMD_WIDTH 

367 

368 def _render_cell(col: str, value: str) -> str: 

369 w = _get_col_display_width(col) 

370 if col in _STATIC_COLUMN_SPECS: 

371 rjust = _STATIC_COLUMN_SPECS[col][2] 

372 return _rcol(value, w) if rjust else _col(value, w) 

373 return _col(value, w) 

374 

375 def _header_cell(col: str) -> str: 

376 if col in _STATIC_COLUMN_SPECS: 

377 hdr = _STATIC_COLUMN_SPECS[col][1] 

378 else: 

379 hdr = _truncate(col, _get_col_display_width(col)) 

380 return _render_cell(col, hdr) 

381 

382 def _cell_value(col: str, issue: IssueInfo) -> str: 

383 if col == "id": 

384 return issue.issue_id 

385 if col == "priority": 

386 return issue.priority 

387 if col == "title": 

388 return _truncate(issue.title, title_w) 

389 if col == "source": 

390 return _source_label(issue.discovered_by) 

391 if col == "norm": 

392 return "\u2713" if is_normalized(issue.path.name) else "\u2717" 

393 if col == "fmt": 

394 return "\u2713" if is_formatted(issue.path) else "\u2717" 

395 if col == "ready": 

396 return str(issue.confidence_score) if issue.confidence_score is not None else "\u2014" 

397 if col == "confidence": 

398 return ( 

399 str(issue.outcome_confidence) if issue.outcome_confidence is not None else "\u2014" 

400 ) 

401 if col == "total": 

402 return str(len(issue.session_commands)) 

403 return "\u2014" # unknown column: em-dash 

404 

405 def _build_row(issue: IssueInfo | None) -> str: 

406 parts: list[str] = [] 

407 cmd_set = set(issue.session_commands) if issue is not None else set() 

408 

409 for c in pre_cmd: 

410 if issue is None: 

411 parts.append(_header_cell(c)) 

412 else: 

413 plain = _cell_value(c, issue) 

414 parts.append(_apply_cell_color(c, _render_cell(c, plain), plain)) 

415 

416 for c in all_cmds: 

417 if issue is None: 

418 parts.append(_col(_cmd_label(c), _CMD_WIDTH)) 

419 else: 

420 if c == "/ll:refine-issue": 

421 cell = str(issue.session_command_counts.get(c, 0)) 

422 parts.append(_col(cell, _CMD_WIDTH)) 

423 else: 

424 hit = c in cmd_set 

425 raw = "\u2713" if hit else "\u2014" 

426 padded = _col(raw, _CMD_WIDTH) 

427 parts.append( 

428 colorize(raw, "32") + padded[len(raw) :] 

429 if hit 

430 else colorize(raw, "2") + padded[len(raw) :] 

431 ) 

432 

433 for c in post_cmd: 

434 if issue is None: 

435 parts.append(_header_cell(c)) 

436 else: 

437 plain = _cell_value(c, issue) 

438 parts.append(_apply_cell_color(c, _render_cell(c, plain), plain)) 

439 

440 return " ".join(parts) 

441 

442 header = _build_row(None) 

443 separator = "-" * len(header) 

444 

445 rows: list[str] = [header, separator] 

446 

447 for issue in sorted_issues: 

448 rows.append(_build_row(issue)) 

449 

450 print("\n".join(rows)) 

451 

452 issue_word = "issue" if len(sorted_issues) == 1 else "issues" 

453 scored = sum(1 for i in sorted_issues if i.confidence_score is not None) 

454 print(f"\n{len(sorted_issues)} {issue_word} ({scored} scored)") 

455 

456 if not getattr(args, "no_key", False): 

457 _print_key(all_cmds) 

458 

459 return 0 

460 

461 

462def _print_key(all_cmds: list[str]) -> None: 

463 """Print a legend mapping column headers to their full command names.""" 

464 print("\nKey:") 

465 print(f" {'source':<12} Origin command/workflow that created the issue") 

466 print(f" {'norm':<12} Filename follows naming convention (P[0-5]-TYPE-NNN-desc.md)") 

467 print(f" {'fmt':<12} Issue has all required sections per type template (structural check)") 

468 for cmd in all_cmds: 

469 label = _cmd_label(cmd) 

470 if cmd == "/ll:refine-issue": 

471 print(f" {label:<12} Times /ll:refine-issue was run") 

472 else: 

473 print(f" {label:<12} {cmd}") 

474 print(f" {'ready':<12} Readiness score (0\u2013100)") 

475 print(f" {'conf':<12} Outcome confidence score (0\u2013100)") 

476 print(f" {'total':<12} Number of /ll:* skills applied")