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

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_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 

29 

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} 

37 

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] 

49 

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} 

60 

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} 

79 

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] 

97 

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) 

110 

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

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

113 

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] 

130 

131 

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) 

139 

140 

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) 

149 

150 

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" 

156 

157 

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

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

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

161 

162 

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

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

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

166 

167 

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 

184 

185 if not code: 

186 return padded 

187 

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 

194 

195 

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) 

217 

218 

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. 

228 

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) 

237 

238 def fits() -> bool: 

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

240 

241 if fits(): 

242 return pre, cmds, post 

243 

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) 

255 

256 # Drop remaining command columns rightmost-first 

257 while not fits() and cmds: 

258 cmds.pop() 

259 

260 return pre, cmds, post 

261 

262 

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

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

265 

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. 

269 

270 Args: 

271 config: Project configuration. 

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

273 

274 Returns: 

275 Exit code (0 = success). 

276 """ 

277 from little_loops.issue_parser import find_issues, is_formatted, is_normalized 

278 

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) 

282 

283 if issue_id_filter: 

284 issues = [i for i in issues if i.issue_id == issue_id_filter] 

285 

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 

292 

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 

298 

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) 

304 

305 all_cmds: list[str] = [ 

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

307 ] 

308 

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) 

312 

313 sorted_issues = sorted(issues, key=_sort_key) 

314 

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 

317 

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

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

320 

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 

345 

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 

368 

369 # --- Table rendering --- 

370 term_cols = terminal_width() 

371 

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) 

375 

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] 

379 

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 ) 

386 

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)) 

407 

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 

416 

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) 

423 

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) 

430 

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 

471 

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() 

475 

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)) 

482 

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 ) 

499 

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)) 

506 

507 return " ".join(parts) 

508 

509 header = _build_row(None) 

510 separator = "-" * len(header) 

511 

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

513 

514 for issue in sorted_issues: 

515 rows.append(_build_row(issue)) 

516 

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

518 

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

522 

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

524 _print_key(all_cmds) 

525 

526 return 0 

527 

528 

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