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

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

2

3 from __future__ import annotations

4

5 import argparse

6 import json

7 from typing import TYPE_CHECKING

8

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

10

11 if 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

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

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

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

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

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

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

128

129

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

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

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

133

134

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

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

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

230 def 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

462 def _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")