Coverage for little_loops / output_parsing.py: 8%
211 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
1"""Output parsing utilities for little-loops.
3Provides parsing functions for Claude CLI command outputs,
4used by both issue_manager (ll-auto) and worker_pool (ll-parallel).
5"""
7from __future__ import annotations
9import json
10import re
11from typing import Any
13# Regex patterns for standardized output parsing
14# Support #, ##, and ### headers with flexible spacing and optional formatting
15# Handles: ## VERDICT, ###VERDICT, ## **VERDICT**, ## VERDICT
16SECTION_PATTERN = re.compile(
17 r"^#{1,3}\s*\**(\w+)\**\s*$",
18 re.MULTILINE,
19)
20TABLE_ROW_PATTERN = re.compile(r"\|\s*(\w+)\s*\|\s*(\w+)\s*\|\s*(.+?)\s*\|")
21STATUS_PATTERN = re.compile(r"^- (\w+): (\w+)", re.MULTILINE)
23# Valid verdicts for ready-issue
24VALID_VERDICTS = ("READY", "CORRECTED", "NOT_READY", "NEEDS_REVIEW", "CLOSE", "BLOCKED")
27def extract_tagged_json(raw: str, tag: str) -> tuple[list | dict | None, str | None]:
28 """Extract and parse a tagged JSON line from LLM output.
30 Scans for the last line starting with ``tag:`` and parses the JSON. On
31 failure attempts bounded structural repair (balancing trailing ``]``/``}``).
33 Args:
34 raw: Full LLM output text to search
35 tag: Tag prefix (without colon), e.g. ``"ASSUMPTIONS_JSON"``
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
50 if found is None:
51 return None, f"No {tag}: line found in output"
53 try:
54 return json.loads(found), None
55 except (json.JSONDecodeError, ValueError):
56 pass
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
70 tail = found[-200:] if len(found) > 200 else found
71 return None, f"Unrecoverable JSON parse failure for {tag}: — tail: {tail!r}"
74def _clean_verdict_content(content: str) -> str:
75 """Clean verdict content by removing common formatting artifacts.
77 Handles:
78 - Code block markers (``` and `)
79 - Markdown bold/italic (** and *)
80 - Template brackets ([])
81 - Leading/trailing whitespace
82 - Colons after verdict
84 Args:
85 content: Raw verdict content from output
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()
102def _extract_verdict_from_text(text: str) -> str | None:
103 """Extract a valid verdict from arbitrary text.
105 Searches for valid verdict keywords in the text, handling various
106 formats like "READY", "The verdict is READY", "NOT_READY", etc.
108 Args:
109 text: Text that may contain a verdict
111 Returns:
112 Valid verdict string or None if not found
113 """
114 text_upper = text.upper()
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
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 ]
159 for pattern, verdict in phrasing_map:
160 if re.search(pattern, text, re.IGNORECASE):
161 return verdict
163 return None
166def parse_sections(output: str) -> dict[str, str]:
167 """Parse output into sections by ## SECTION_NAME headers.
169 The standardized slash command output format uses ## SECTION_NAME
170 headers (uppercase with underscores) to delimit sections.
172 Args:
173 output: The stdout from a slash command
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] = []
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)
192 # Save final section
193 sections[current_section] = "\n".join(current_content).strip()
194 return sections
197def parse_validation_table(section_content: str) -> dict[str, dict[str, str]]:
198 """Parse a validation table from section content.
200 Expects format:
201 | Check | Status | Details |
202 |-------|--------|---------|
203 | Format | PASS | ... |
205 Args:
206 section_content: Content of the VALIDATION section
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
224def parse_status_lines(section_content: str) -> dict[str, str]:
225 """Parse status lines from section content.
227 Expects format:
228 - tests: PASS
229 - lint: PASS
231 Args:
232 section_content: Content of a section with status lines
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
243def parse_ready_issue_output(output: str) -> dict[str, Any]:
244 """Extract verdict and concerns from ready-issue output.
246 The ready-issue command outputs structured sections with a VERDICT
247 section containing READY, CORRECTED, NOT_READY, NEEDS_REVIEW, or CLOSE.
249 Supports both old format (VERDICT: READY) and new standardized format
250 (## VERDICT\\nREADY) for backwards compatibility.
252 Args:
253 output: The stdout from the ready-issue command
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
280 # Strategy 1: Check for VERDICT section (new format with # or ## header)
281 if "VERDICT" in sections:
282 verdict_section = sections["VERDICT"].strip()
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
290 # Clean the line of formatting artifacts
291 cleaned = _clean_verdict_content(line)
292 if not cleaned:
293 continue
295 # Try to extract verdict from cleaned line
296 extracted = _extract_verdict_from_text(cleaned)
297 if extracted:
298 verdict = extracted
299 break
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("-", "_")
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
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
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
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:])
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"
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
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)
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
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
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
412 # Parse VALIDATION section if present
413 if "VALIDATION" in sections:
414 validation = parse_validation_table(sections["VALIDATION"])
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"
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 }
438def parse_manage_issue_output(output: str) -> dict[str, Any]:
439 """Extract structured data from manage-issue output.
441 The manage-issue command outputs structured sections with metadata,
442 files changed, commits, verification results, and final status.
444 Args:
445 output: The stdout from the manage-issue command
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] = {}
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()
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:])
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:])
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:])
492 # Parse VERIFICATION section
493 if "VERIFICATION" in sections:
494 verification = parse_status_lines(sections["VERIFICATION"])
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()
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 }