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

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

249 

250 if issue_id_filter: 

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

252 

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 

259 

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 

265 

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) 

271 

272 all_cmds: list[str] = [ 

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

274 ] 

275 

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) 

279 

280 sorted_issues = sorted(issues, key=_sort_key) 

281 

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 

284 

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

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

287 

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 

307 

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 

325 

326 # --- Table rendering --- 

327 term_cols = terminal_width() 

328 

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) 

332 

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] 

336 

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 ) 

343 

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

364 

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 

373 

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) 

380 

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) 

387 

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 

410 

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

414 

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

421 

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 ) 

438 

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

445 

446 return " ".join(parts) 

447 

448 header = _build_row(None) 

449 separator = "-" * len(header) 

450 

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

452 

453 for issue in sorted_issues: 

454 rows.append(_build_row(issue)) 

455 

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

457 

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

461 

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

463 _print_key(all_cmds) 

464 

465 return 0 

466 

467 

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