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

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

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

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

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

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

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

161

162

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

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

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

166

167

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

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

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

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

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