1"""Output parsing utilities for little-loops.

2

3 Provides parsing functions for Claude CLI command outputs,

4 used by both issue_manager (ll-auto) and worker_pool (ll-parallel).

5"""

6

7 from __future__ import annotations

8

9 import json

10 import re

11 from typing import Any

12

13# Regex patterns for standardized output parsing

14# Support #, ##, and ### headers with flexible spacing and optional formatting

15# Handles: ## VERDICT, ###VERDICT, ## **VERDICT**, ## VERDICT

16 SECTION_PATTERN = re.compile(

17 r"^#{1,3}\s*\**(\w+)\**\s*$",

18 re.MULTILINE,

19)

20 TABLE_ROW_PATTERN = re.compile(r"\|\s*(\w+)\s*\|\s*(\w+)\s*\|\s*(.+?)\s*\|")

21 STATUS_PATTERN = re.compile(r"^- (\w+): (\w+)", re.MULTILINE)

22

23# Valid verdicts for ready-issue

24 VALID_VERDICTS = ("READY", "CORRECTED", "NOT_READY", "NEEDS_REVIEW", "CLOSE", "BLOCKED")

25

26

27 def extract_tagged_json(raw: str, tag: str) -> tuple[list | dict | None, str | None]:

28 """Extract and parse a tagged JSON line from LLM output.

29

30 Scans for the last line starting with ``tag:`` and parses the JSON. On

31 failure attempts bounded structural repair (balancing trailing ``]``/``}``).

32

33 Args:

34 raw: Full LLM output text to search

35 tag: Tag prefix (without colon), e.g. ``"ASSUMPTIONS_JSON"``

36

37 Returns:

38 ``(data, None)`` on clean parse, ``(data, warning)`` on successful

39 repair, ``(None, error_msg)`` on unrecoverable failure. Never swallows

40 — callers must surface the error when ``data is None``.

41 """

42 prefix = tag + ":"

43 found: str | None = None

44 for line in reversed(raw.split("\n")):

45 stripped = line.strip()

46 if stripped.startswith(prefix):

47 found = stripped[len(prefix):].strip()

48 break

49

50 if found is None:

51 return None, f"No {tag}: line found in output"

52

53 try:

54 return json.loads(found), None

55 except (json.JSONDecodeError, ValueError):

56 pass

57

58 # Attempt repair: balance unmatched square brackets and curly braces.

59 # Close inner braces before outer brackets (inner-to-outer order).

60 open_sq = found.count("[") - found.count("]")

61 open_cu = found.count("{") - found.count("}")

62 if open_sq > 0 or open_cu > 0:

63 repaired = found + ("}" * open_cu) + ("]" * open_sq)

64 try:

65 warning = f"Repaired malformed {tag}: JSON (added {open_cu} '}}', {open_sq} ']')"

66 return json.loads(repaired), warning

67 except (json.JSONDecodeError, ValueError):

68 pass

69

70 tail = found[-200:] if len(found) > 200 else found

71 return None, f"Unrecoverable JSON parse failure for {tag}: — tail: {tail!r}"

72

73

74 def _clean_verdict_content(content: str) -> str:

75 """Clean verdict content by removing common formatting artifacts.

76

77 Handles:

78 - Code block markers (``` and `)

79 - Markdown bold/italic (** and *)

80 - Template brackets ([])

81 - Leading/trailing whitespace

82 - Colons after verdict

83

84 Args:

85 content: Raw verdict content from output

86

87 Returns:

88 Cleaned content ready for verdict extraction

89 """

90 # Remove code fence markers (``` or ```)

91 content = re.sub(r"^```\w*\s*", "", content)

92 content = re.sub(r"\s*```$", "", content)

93 # Remove inline code backticks

94 content = content.replace("`", "")

95 # Remove markdown bold/italic

96 content = content.replace("**", "").replace("*", "")

97 # Remove template brackets

98 content = content.strip("[]")

99 return content.strip()

100

101

102 def _extract_verdict_from_text(text: str) -> str | None:

103 """Extract a valid verdict from arbitrary text.

104

105 Searches for valid verdict keywords in the text, handling various

106 formats like "READY", "The verdict is READY", "NOT_READY", etc.

107

108 Args:

109 text: Text that may contain a verdict

110

111 Returns:

112 Valid verdict string or None if not found

113 """

114 text_upper = text.upper()

115

116 # Check each valid verdict (check NOT_READY before READY to avoid partial match)

117 # Order matters: check longer/compound verdicts first

118 for verdict in ("NOT_READY", "NEEDS_REVIEW", "CORRECTED", "BLOCKED", "READY", "CLOSE"):

119 # Match verdict as a word boundary (not part of another word)

120 # Handle both underscore and space variants

121 patterns = [

122 rf"\b{verdict}\b",

123 rf"\b{verdict.replace('_', ' ')}\b", # NOT READY, NEEDS REVIEW

124 rf"\b{verdict.replace('_', '-')}\b", # NOT-READY, NEEDS-REVIEW

125 ]

126 for pattern in patterns:

127 if re.search(pattern, text_upper):

128 # Normalize to underscore format

129 return verdict

130

131 # Try common Claude phrasings that map to verdicts

132 # Note: Using re.IGNORECASE since patterns are lowercase

133 phrasing_map = [

134 # Patterns for READY

135 (r"\bissue\s+is\s+ready\b", "READY"),

136 (r"\bready\s+for\s+implementation\b", "READY"),

137 (r"\bimplementation[\s-]ready\b", "READY"),

138 (r"\bapproved\s+for\s+implementation\b", "READY"),

139 (r"\bproceed\s+(to|with)\s+implementation\b", "READY"),

140 # Patterns for CLOSE

141 (r"\bshould\s+be\s+closed\b", "CLOSE"),

142 (r"\bclose\s+this\s+issue\b", "CLOSE"),

143 (r"\bmark\s+as\s+closed\b", "CLOSE"),

144 (r"\balready\s+fixed\b", "CLOSE"),

145 (r"\binvalid\s+reference\b", "CLOSE"),

146 (r"\bmove.*to.*completed\b", "CLOSE"), # "move this issue to the completed directory"

147 (r"\bclosure\s+status\b", "CLOSE"), # "closure status"

148 # Patterns for NOT_READY

149 (r"\bnot\s+ready\b", "NOT_READY"), # General "not ready" pattern

150 (r"\bneeds?\s+more\s+work\b", "NOT_READY"),

151 (r"\brequires?\s+clarification\b", "NOT_READY"),

152 (r"\bmissing\s+information\b", "NOT_READY"),

153 # Patterns for CORRECTED

154 (r"\bcorrections?\s+made\b", "CORRECTED"),

155 (r"\bupdated?\s+and\s+ready\b", "CORRECTED"),

156 (r"\bfixed?\s+and\s+ready\b", "CORRECTED"),

157 ]

158

159 for pattern, verdict in phrasing_map:

160 if re.search(pattern, text, re.IGNORECASE):

161 return verdict

162

163 return None

164

165

166 def parse_sections(output: str) -> dict[str, str]:

167 """Parse output into sections by ## SECTION_NAME headers.

168

169 The standardized slash command output format uses ## SECTION_NAME

170 headers (uppercase with underscores) to delimit sections.

171

172 Args:

173 output: The stdout from a slash command

174

175 Returns:

176 dict mapping section names to their content

177 """

178 sections: dict[str, str] = {}

179 current_section = "PREAMBLE"

180 current_content: list[str] = []

181

182 for line in output.split("\n"):

183 match = SECTION_PATTERN.match(line)

184 if match:

185 # Save previous section

186 sections[current_section] = "\n".join(current_content).strip()

187 current_section = match.group(1)

188 current_content = []

189 else:

190 current_content.append(line)

191

192 # Save final section

193 sections[current_section] = "\n".join(current_content).strip()

194 return sections

195

196

197 def parse_validation_table(section_content: str) -> dict[str, dict[str, str]]:

198 """Parse a validation table from section content.

199

200 Expects format:

201 | Check | Status | Details |

202 |-------|--------|---------|

203 | Format | PASS | ... |

204

205 Args:

206 section_content: Content of the VALIDATION section

207

208 Returns:

209 dict mapping check names to {status, details}

210 """

211 results: dict[str, dict[str, str]] = {}

212 for match in TABLE_ROW_PATTERN.finditer(section_content):

213 check_name = match.group(1)

214 # Skip header row indicators

215 if check_name.lower() in ("check", "---", ""):

216 continue

217 results[check_name] = {

218 "status": match.group(2).upper(),

219 "details": match.group(3).strip(),

220 }

221 return results

222

223

224 def parse_status_lines(section_content: str) -> dict[str, str]:

225 """Parse status lines from section content.

226

227 Expects format:

228 - tests: PASS

229 - lint: PASS

230

231 Args:

232 section_content: Content of a section with status lines

233

234 Returns:

235 dict mapping item names to status values

236 """

237 results: dict[str, str] = {}

238 for match in STATUS_PATTERN.finditer(section_content):

239 results[match.group(1)] = match.group(2).upper()

240 return results

241

242

243 def parse_ready_issue_output(output: str) -> dict[str, Any]:

244 """Extract verdict and concerns from ready-issue output.

245

246 The ready-issue command outputs structured sections with a VERDICT

247 section containing READY, CORRECTED, NOT_READY, NEEDS_REVIEW, or CLOSE.

248

249 Supports both old format (VERDICT: READY) and new standardized format

250 (## VERDICT\\nREADY) for backwards compatibility.

251

252 Args:

253 output: The stdout from the ready-issue command

254

255 Returns:

256 dict with keys:

257 - verdict: str ("READY", "CORRECTED", "NOT_READY", "NEEDS_REVIEW",

258 "CLOSE", "BLOCKED", or "UNKNOWN")

259 - concerns: list[str] of concern messages

260 - is_ready: bool indicating if issue is ready for implementation

261 - was_corrected: bool indicating if corrections were made

262 - should_close: bool indicating if issue should be closed

263 - close_reason: str|None (e.g., "already_fixed", "invalid_ref")

264 - close_status: str|None (e.g., "Closed - Already Fixed")

265 - corrections: list[str] of corrections made

266 - validated_file_path: str|None path to the file that was validated

267 - sections: dict of parsed sections (if standardized format)

268 - validation: dict of validation results (if standardized format)

269 """

270 # Try new standardized format first

271 sections = parse_sections(output)

272 verdict = "UNKNOWN"

273 concerns: list[str] = []

274 corrections: list[str] = []

275 validation: dict[str, dict[str, str]] = {}

276 close_reason: str | None = None

277 close_status: str | None = None

278 validated_file_path: str | None = None

279

280 # Strategy 1: Check for VERDICT section (new format with # or ## header)

281 if "VERDICT" in sections:

282 verdict_section = sections["VERDICT"].strip()

283

284 # Try each non-empty line until we find a verdict

285 for line in verdict_section.split("\n"):

286 line = line.strip()

287 if not line:

288 continue

289

290 # Clean the line of formatting artifacts

291 cleaned = _clean_verdict_content(line)

292 if not cleaned:

293 continue

294

295 # Try to extract verdict from cleaned line

296 extracted = _extract_verdict_from_text(cleaned)

297 if extracted:

298 verdict = extracted

299 break

300

301 # Strategy 2: Old format (VERDICT: READY) anywhere in output

302 if verdict == "UNKNOWN":

303 verdict_match = re.search(

304 r"VERDICT:\s*(READY|CORRECTED|NOT[_\s-]?READY|NEEDS[_\s-]?REVIEW|CLOSE|BLOCKED)",

305 output,

306 re.IGNORECASE,

307 )

308 if verdict_match:

309 verdict = verdict_match.group(1).upper().replace(" ", "_").replace("-", "_")

310

311 # Strategy 3: Look for verdict keywords near "verdict" mentions

312 if verdict == "UNKNOWN":

313 # Find lines containing "verdict" and check for verdict keywords

314 for line in output.split("\n"):

315 if "verdict" in line.lower():

316 extracted = _extract_verdict_from_text(line)

317 if extracted:

318 verdict = extracted

319 break

320

321 # Strategy 4: Scan entire output for standalone verdict keywords

322 # (last resort - may have false positives but better than UNKNOWN)

323 if verdict == "UNKNOWN":

324 extracted = _extract_verdict_from_text(output)

325 if extracted:

326 verdict = extracted

327

328 # Strategy 5: Clean the entire output and retry extraction

329 # Handles cases where formatting artifacts (bold, backticks) break word boundaries

330 if verdict == "UNKNOWN":

331 cleaned_output = _clean_verdict_content(output)

332 extracted = _extract_verdict_from_text(cleaned_output)

333 if extracted:

334 verdict = extracted

335

336 # Parse CORRECTIONS_MADE section if present (moved before Strategy 6)

337 if "CORRECTIONS_MADE" in sections:

338 corrections_content = sections["CORRECTIONS_MADE"]

339 for line in corrections_content.split("\n"):

340 line = line.strip()

341 if line.startswith("- ") and line != "- None":

342 corrections.append(line[2:])

343

344 # Strategy 6: Infer from READY_FOR section

345 # If "READY_FOR" section exists with "Implementation: Yes", infer verdict

346 if verdict == "UNKNOWN" and "READY_FOR" in sections:

347 ready_for_content = sections["READY_FOR"]

348 # Check for "implementation" + "yes" pattern (handles bold markers, colons, etc.)

349 # Handles: "Implementation: Yes", "**Implementation:** Yes", etc.

350 if re.search(r"implementation[\s:\*]*yes", ready_for_content, re.IGNORECASE):

351 # If corrections were made, verdict is CORRECTED; otherwise READY

352 verdict = "CORRECTED" if corrections else "READY"

353

354 # Parse CONCERNS section (new format)

355 if "CONCERNS" in sections:

356 concern_content = sections["CONCERNS"]

357 for line in concern_content.split("\n"):

358 line = line.strip()

359 if line.startswith("- ") and line != "- None":

360 concerns.append(line[2:]) # Remove "- " prefix

361

362 # Fall back to old concern detection

363 if not concerns:

364 for line in output.split("\n"):

365 line_stripped = line.strip()

366 if any(

367 indicator in line_stripped

368 for indicator in ["WARNING", "Concern:", "Issue:", "Missing:"]

369 ):

370 concerns.append(line_stripped)

371

372 # Parse CLOSE_REASON section if present (for CLOSE verdict)

373 if "CLOSE_REASON" in sections:

374 close_reason_content = sections["CLOSE_REASON"]

375 # Look for "- Reason: <value>" line

376 for line in close_reason_content.split("\n"):

377 # Strip whitespace and bold markers (**) that Claude sometimes adds

378 line = line.strip().replace("**", "")

379 if line.lower().startswith("- reason:"):

380 reason_value = line.split(":", 1)[1].strip().lower()

381 # Also strip backticks that may wrap the value

382 close_reason = reason_value.strip("`").strip()

383 break

384 # Also handle "Reason: <value>" without dash

385 if line.lower().startswith("reason:"):

386 reason_value = line.split(":", 1)[1].strip().lower()

387 close_reason = reason_value.strip("`").strip()

388 break

389

390 # Parse CLOSE_STATUS section if present

391 if "CLOSE_STATUS" in sections:

392 close_status_content = sections["CLOSE_STATUS"].strip()

393 # Take first non-empty line as the status

394 for line in close_status_content.split("\n"):

395 line = line.strip()

396 if line and not line.startswith("#"):

397 close_status = line

398 break

399

400 # Parse VALIDATED_FILE section if present (for path validation)

401 if "VALIDATED_FILE" in sections:

402 validated_file_content = sections["VALIDATED_FILE"].strip()

403 # Take first non-empty line as the file path

404 for line in validated_file_content.split("\n"):

405 line = line.strip()

406 # Skip empty lines, comments, and template placeholders

407 if line and not line.startswith("#") and not line.startswith("["):

408 # Strip markdown backticks that Claude sometimes wraps paths in

409 validated_file_path = line.strip("`")

410 break

411

412 # Parse VALIDATION section if present

413 if "VALIDATION" in sections:

414 validation = parse_validation_table(sections["VALIDATION"])

415

416 # Determine flags based on verdict

417 is_ready = verdict in ("READY", "CORRECTED")

418 was_corrected = verdict == "CORRECTED" or len(corrections) > 0

419 should_close = verdict == "CLOSE"

420 is_blocked = verdict == "BLOCKED"

421

422 return {

423 "verdict": verdict,

424 "concerns": concerns,

425 "is_ready": is_ready,

426 "was_corrected": was_corrected,

427 "should_close": should_close,

428 "is_blocked": is_blocked,

429 "close_reason": close_reason,

430 "close_status": close_status,

431 "corrections": corrections,

432 "validated_file_path": validated_file_path,

433 "sections": sections,

434 "validation": validation,

435 }

436

437

438 def parse_manage_issue_output(output: str) -> dict[str, Any]:

439 """Extract structured data from manage-issue output.

440

441 The manage-issue command outputs structured sections with metadata,

442 files changed, commits, verification results, and final status.

443

444 Args:

445 output: The stdout from the manage-issue command

446

447 Returns:

448 dict with keys:

449 - status: str ("COMPLETED", "FAILED", "BLOCKED", or "UNKNOWN")

450 - files_changed: list[str] of modified files

451 - files_created: list[str] of created files

452 - commits: list[str] of commit hashes/messages

453 - verification: dict of verification results

454 - ooda_impact: dict of OODA impact status

455 - sections: dict of all parsed sections

456 """

457 sections = parse_sections(output)

458 status = "UNKNOWN"

459 files_changed: list[str] = []

460 files_created: list[str] = []

461 commits: list[str] = []

462 verification: dict[str, str] = {}

463 ooda_impact: dict[str, str] = {}

464

465 # Parse RESULT section for status

466 if "RESULT" in sections:

467 status_match = re.search(r"Status:\s*(\w+)", sections["RESULT"])

468 if status_match:

469 status = status_match.group(1).upper()

470

471 # Parse FILES_CHANGED section

472 if "FILES_CHANGED" in sections:

473 for line in sections["FILES_CHANGED"].split("\n"):

474 line = line.strip()

475 if line.startswith("- ") and line != "- None":

476 files_changed.append(line[2:])

477

478 # Parse FILES_CREATED section

479 if "FILES_CREATED" in sections:

480 for line in sections["FILES_CREATED"].split("\n"):

481 line = line.strip()

482 if line.startswith("- ") and line != "- None":

483 files_created.append(line[2:])

484

485 # Parse COMMITS section

486 if "COMMITS" in sections:

487 for line in sections["COMMITS"].split("\n"):

488 line = line.strip()

489 if line.startswith("- ") and line != "- None":

490 commits.append(line[2:])

491

492 # Parse VERIFICATION section

493 if "VERIFICATION" in sections:

494 verification = parse_status_lines(sections["VERIFICATION"])

495

496 # Parse OODA_IMPACT section

497 if "OODA_IMPACT" in sections:

498 for line in sections["OODA_IMPACT"].split("\n"):

499 line = line.strip()

500 if line.startswith("- "):

501 parts = line[2:].split(":", 1)

502 if len(parts) == 2:

503 ooda_impact[parts[0].strip()] = parts[1].strip().upper()

504

505 return {

506 "status": status,

507 "files_changed": files_changed,

508 "files_created": files_created,

509 "commits": commits,

510 "verification": verification,

511 "ooda_impact": ooda_impact,

512 "sections": sections,

513 }