claude-dev-env 1.58.0 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  9. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  10. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  11. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  12. package/bin/install.mjs +100 -27
  13. package/bin/install.test.mjs +133 -1
  14. package/docs/CODE_RULES.md +3 -3
  15. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  16. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  17. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  18. package/hooks/blocking/code_rules_duplicate_body.py +439 -0
  19. package/hooks/blocking/code_rules_enforcer.py +190 -21
  20. package/hooks/blocking/code_rules_magic_values.py +98 -0
  21. package/hooks/blocking/code_rules_shared.py +41 -0
  22. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  23. package/hooks/blocking/config/__init__.py +5 -0
  24. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  25. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  26. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  27. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  28. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  30. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  31. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  32. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  33. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  34. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  35. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  38. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  39. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  40. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  41. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  42. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  43. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  44. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  45. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  47. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  48. package/hooks/blocking/verification_verdict_store.py +446 -0
  49. package/hooks/blocking/verified_commit_gate.py +523 -0
  50. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  51. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  52. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  53. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  54. package/hooks/hooks.json +58 -1
  55. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  57. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  58. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  59. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  60. package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
  61. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  62. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  63. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  64. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  65. package/package.json +1 -1
  66. package/rules/docstring-prose-matches-implementation.md +43 -0
  67. package/rules/file-global-constants.md +7 -1
  68. package/rules/hook-prose-matches-detector.md +26 -0
  69. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  70. package/rules/no-inline-destructive-literals.md +11 -0
  71. package/rules/workflow-substitution-slots.md +7 -0
  72. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  73. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  74. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  75. package/skills/autoconverge/SKILL.md +67 -19
  76. package/skills/autoconverge/reference/closing-report.md +59 -17
  77. package/skills/autoconverge/reference/convergence.md +7 -3
  78. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  79. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  80. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  81. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  82. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  83. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  84. package/skills/autoconverge/workflow/converge.mjs +234 -42
  85. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  86. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  87. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  88. package/skills/autoconverge/workflow/render_report.py +488 -397
  89. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  90. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  91. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  92. package/skills/pr-converge/reference/per-tick.md +28 -8
  93. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  94. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  95. package/skills/rebase/SKILL.md +2 -4
  96. package/skills/update/SKILL.md +37 -5
  97. package/system-prompts/software-engineer.xml +2 -6
  98. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  99. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  100. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  101. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  102. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  103. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  104. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  105. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  106. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -1,65 +1,86 @@
1
- """Render a convergence insights HTML report from an autoconverge workflow journal."""
1
+ """Render a visual convergence summary HTML report from an autoconverge journal."""
2
2
 
3
3
  import argparse
4
4
  import html
5
5
  import json
6
6
  import re
7
- import subprocess
8
7
  import sys
9
8
  from dataclasses import dataclass
10
9
  from pathlib import Path
11
10
  from typing import TextIO
12
11
 
13
12
  from autoconverge_report_constants.render_report_constants import (
14
- BAR_COLOR_ROUND,
15
- BAR_COLOR_SEVERITY_CRITICAL,
16
- BAR_COLOR_SEVERITY_MINOR,
17
- BAR_COLOR_TESTS,
18
- BAR_COLOR_THEME,
19
- BAR_FILL_MAX_PERCENT,
13
+ CATEGORY_BUG,
14
+ CATEGORY_LABEL_BY_VALUE,
15
+ CATEGORY_SORT_ORDER,
16
+ CAUSE_MUTED_STYLE,
17
+ DEFAULT_FINDING_CATEGORY,
18
+ DEFAULT_FINDING_SEVERITY,
20
19
  GITHUB_PR_URL_TEMPLATE,
21
20
  HTML_DOCTYPE,
22
21
  HTML_HEAD_TEMPLATE,
23
22
  HTML_STYLE_BLOCK,
23
+ ISO_DATE_LENGTH,
24
+ ISSUE_CLASS_FIELD_AFTER_LINES,
25
+ ISSUE_CLASS_FIELD_BEFORE_LINES,
26
+ ISSUE_CLASS_FIELD_CATEGORY,
27
+ ISSUE_CLASS_FIELD_CAUSE,
28
+ ISSUE_CLASS_FIELD_COUNT,
29
+ ISSUE_CLASS_FIELD_MEDIUM,
30
+ ISSUE_CLASS_FIELD_PLAINNAME,
31
+ ISSUE_CLASS_FIELD_SEVERITY,
32
+ ISSUE_CLASS_FIELD_STATUS,
24
33
  JOURNAL_SIBLING_SUBAGENTS,
25
34
  JOURNAL_SIBLING_WORKFLOWS,
35
+ LABEL_CONVERGENCE_SUMMARY,
26
36
  LABEL_COPILOT_GATE,
27
37
  LABEL_PREFIX_FIX,
28
38
  LABEL_PREFIX_LENS,
29
39
  LABEL_RESOLVE_HEAD,
30
- SEVERITY_BADGE_CLASS_BY_LEVEL,
31
- SEVERITY_CRITICAL_BUCKET,
32
- SEVERITY_CRITICAL_LEVELS,
33
- SEVERITY_MINOR_BUCKET,
40
+ MEDIUM_CODE,
41
+ MEDIUM_TERMINAL,
42
+ SCENE_FIELD_CAPTION,
43
+ SCENE_FIELD_CONDITION,
44
+ SCENE_FIELD_RESULT,
45
+ SCENE_FIELD_TRIGGER,
46
+ SEVERITY_SORT_RANK,
47
+ SHORT_SHA_LENGTH,
48
+ STATUS_LABEL_BY_VALUE,
34
49
  STRUCTURED_OUTPUT_TOOL_NAME,
35
- TEST_DEFINITION_PATTERN,
36
- TEST_PATH_GLOBS,
37
- THEME_FALLBACK,
38
- THEME_PATH_SEGMENT_COUNT,
50
+ SUMMARY_FIELD_FIX_SCENES,
51
+ SUMMARY_FIELD_ISSUE_CLASSES,
52
+ SUMMARY_FIELD_PR_FIX,
53
+ SUMMARY_FIELD_PR_PROBLEM,
54
+ SUMMARY_FIELD_PROBLEM_SCENES,
55
+ SUMMARY_FIELD_VERDICT_LINE,
56
+ TIMELINE_AFTER_LABEL,
57
+ TIMELINE_AFTER_PILL,
58
+ TIMELINE_BEFORE_LABEL,
59
+ TIMELINE_BEFORE_PILL,
60
+ TIMELINE_TERMINAL_BAR_LABEL,
39
61
  )
40
62
 
41
63
 
42
64
  @dataclass(frozen=True)
43
65
  class RawFinding:
44
- """A single finding from a lens or copilot-gate agent result, tagged with round context."""
66
+ """A single finding from a lens or copilot-gate agent result."""
45
67
 
46
68
  file: str
47
69
  line: int
48
70
  severity: str
49
71
  title: str
50
72
  detail: str
51
- round_number: int
52
- sha: str
73
+ category: str
53
74
 
54
75
 
55
76
  @dataclass(frozen=True)
56
77
  class FixRecord:
57
- """The structured result of a fix agent, with round and base-sha context attached."""
78
+ """The structured result of a fix agent, with base-sha context attached."""
58
79
 
59
80
  new_sha: str
60
81
  pushed: bool
61
82
  resolved_without_commit: bool
62
- round_number: int
83
+ summary: str
63
84
  base_sha: str
64
85
 
65
86
 
@@ -81,15 +102,10 @@ class RunData:
81
102
 
82
103
  generated_date: str
83
104
  total_finding_count: int
84
- critical_finding_count: int
85
- minor_finding_count: int
86
105
  fix_commit_count: int
87
- tests_added_by_round: dict[int, int]
88
- finding_count_by_round: dict[int, int]
89
- finding_count_by_theme: dict[str, int]
90
- all_critical_findings: list[RawFinding]
91
- all_minor_findings: list[RawFinding]
106
+ all_distinct_findings: list[RawFinding]
92
107
  fix_by_round: dict[int, FixRecord]
108
+ convergence_summary: dict | None
93
109
 
94
110
 
95
111
  def _resolve_agents_dir(journal_path: Path) -> Path:
@@ -167,58 +183,6 @@ def _last_structured_input_in_line(
167
183
  return latest_input
168
184
 
169
185
 
170
- def _derive_theme(file_path: str) -> str:
171
- """Return the first two slash-separated segments of a file path.
172
-
173
- Args:
174
- file_path: A relative file path string from a finding.
175
-
176
- Returns:
177
- A theme string like 'src/exports', the whole path when fewer than two
178
- segments exist, or THEME_FALLBACK when the path is empty.
179
- """
180
- if not file_path:
181
- return THEME_FALLBACK
182
- segments = file_path.split("/")
183
- return "/".join(segments[:THEME_PATH_SEGMENT_COUNT])
184
-
185
-
186
- def _count_tests_added(base_sha: str, new_sha: str, repo_path: Path) -> int:
187
- """Count new test definitions introduced between two commits.
188
-
189
- Args:
190
- base_sha: The commit sha the round reviewed.
191
- new_sha: The sha produced by the fix commit.
192
- repo_path: Path to the git repository root.
193
-
194
- Returns:
195
- Number of newly added test-function definitions; 0 on any git error.
196
- """
197
- diff_command = [
198
- "git",
199
- "-C",
200
- str(repo_path),
201
- "diff",
202
- f"{base_sha}..{new_sha}",
203
- "--",
204
- *TEST_PATH_GLOBS,
205
- ]
206
- try:
207
- completed = subprocess.run(
208
- diff_command,
209
- capture_output=True,
210
- text=True,
211
- check=True,
212
- )
213
- except (subprocess.SubprocessError, OSError):
214
- return 0
215
-
216
- diff_text = completed.stdout
217
- test_def_pattern = re.compile(TEST_DEFINITION_PATTERN, re.MULTILINE)
218
-
219
- return len(test_def_pattern.findall(diff_text))
220
-
221
-
222
186
  def _build_dedup_key(file_path: str, line: int, title: str) -> tuple[str, int, str]:
223
187
  """Return a deduplication key for a finding.
224
188
 
@@ -233,13 +197,11 @@ def _build_dedup_key(file_path: str, line: int, title: str) -> tuple[str, int, s
233
197
  return (file_path, line, title.lower())
234
198
 
235
199
 
236
- def _parse_finding_from_dict(raw: dict, round_number: int, sha: str) -> RawFinding:
200
+ def _parse_finding_from_dict(raw: dict) -> RawFinding:
237
201
  """Construct a RawFinding from a raw agent result dict.
238
202
 
239
203
  Args:
240
204
  raw: The raw finding dict from the agent result.
241
- round_number: The round this finding belongs to.
242
- sha: The commit sha this finding was discovered on.
243
205
 
244
206
  Returns:
245
207
  A RawFinding dataclass instance.
@@ -247,22 +209,18 @@ def _parse_finding_from_dict(raw: dict, round_number: int, sha: str) -> RawFindi
247
209
  return RawFinding(
248
210
  file=raw.get("file", ""),
249
211
  line=raw.get("line", 0),
250
- severity=raw.get("severity", "P2"),
212
+ severity=raw.get("severity", DEFAULT_FINDING_SEVERITY),
251
213
  title=raw.get("title", ""),
252
214
  detail=raw.get("detail", ""),
253
- round_number=round_number,
254
- sha=sha,
215
+ category=raw.get("category", DEFAULT_FINDING_CATEGORY),
255
216
  )
256
217
 
257
218
 
258
- def _parse_fix_record(
259
- agent_result: dict, round_number: int, base_sha: str
260
- ) -> FixRecord:
219
+ def _parse_fix_record(agent_result: dict, base_sha: str) -> FixRecord:
261
220
  """Construct a FixRecord from a fix agent's structured output.
262
221
 
263
222
  Args:
264
223
  agent_result: The structured output dict from the fix agent.
265
- round_number: The round this fix belongs to.
266
224
  base_sha: The HEAD sha the round reviewed before fixing.
267
225
 
268
226
  Returns:
@@ -272,7 +230,7 @@ def _parse_fix_record(
272
230
  new_sha=agent_result.get("newSha", ""),
273
231
  pushed=bool(agent_result.get("pushed", False)),
274
232
  resolved_without_commit=bool(agent_result.get("resolvedWithoutCommit", False)),
275
- round_number=round_number,
233
+ summary=agent_result.get("summary", ""),
276
234
  base_sha=base_sha,
277
235
  )
278
236
 
@@ -322,13 +280,11 @@ def _parse_progress_entries(
322
280
  current_round_base_sha = sha
323
281
  raw_findings: list[dict] = agent_result.get("findings", [])
324
282
  for each_raw in raw_findings:
325
- all_findings.append(
326
- _parse_finding_from_dict(each_raw, current_round, sha)
327
- )
283
+ all_findings.append(_parse_finding_from_dict(each_raw))
328
284
 
329
285
  if is_fix:
330
286
  fix_by_round[current_round] = _parse_fix_record(
331
- agent_result, current_round, current_round_base_sha
287
+ agent_result, current_round_base_sha
332
288
  )
333
289
 
334
290
  return all_findings, fix_by_round
@@ -355,19 +311,45 @@ def _dedup_findings(all_findings: list[RawFinding]) -> list[RawFinding]:
355
311
  return distinct
356
312
 
357
313
 
358
- def load_run_data(journal_path: Path, repo_path: Path) -> RunData:
314
+ def _extract_convergence_summary(
315
+ progress_entries: list[dict], agents_dir: Path
316
+ ) -> dict | None:
317
+ """Return the convergence-summary StructuredOutput, or None when absent.
318
+
319
+ Args:
320
+ progress_entries: The workflowProgress list from the journal.
321
+ agents_dir: Directory containing per-agent .jsonl transcript files.
322
+
323
+ Returns:
324
+ The summarizer agent's last StructuredOutput input dict, or None when
325
+ no convergence-summary entry exists or its transcript is unreadable.
326
+ """
327
+ for each_entry in progress_entries:
328
+ if each_entry.get("label") != LABEL_CONVERGENCE_SUMMARY:
329
+ continue
330
+ agent_id = each_entry.get("agentId")
331
+ if not agent_id:
332
+ return None
333
+ transcript_path = agents_dir / f"agent-{agent_id}.jsonl"
334
+ return _extract_structured_output(transcript_path)
335
+ return None
336
+
337
+
338
+ def load_run_data(journal_path: Path) -> RunData:
359
339
  """Parse a workflow journal and its agent transcripts into aggregated metrics.
360
340
 
361
341
  Args:
362
342
  journal_path: Path to the wf_<runId>.json journal file.
363
- repo_path: Path to the git repository for counting tests added.
364
343
 
365
344
  Returns:
366
- A RunData instance with all counts and finding lists populated.
345
+ A RunData instance with finding lists, fix records, and the summary populated.
367
346
  """
347
+ iso_date_length = ISO_DATE_LENGTH
368
348
  journal = json.loads(journal_path.read_text(encoding="utf-8"))
369
349
  timestamp: str = journal.get("timestamp", "")
370
- generated_date = timestamp[:10] if len(timestamp) >= 10 else ""
350
+ generated_date = (
351
+ timestamp[:iso_date_length] if len(timestamp) >= iso_date_length else ""
352
+ )
371
353
 
372
354
  progress_entries: list[dict] = journal.get("workflowProgress", [])
373
355
  agents_dir = _resolve_agents_dir(journal_path)
@@ -376,305 +358,495 @@ def load_run_data(journal_path: Path, repo_path: Path) -> RunData:
376
358
  progress_entries, agents_dir
377
359
  )
378
360
  distinct_findings = _dedup_findings(all_raw_findings)
379
-
380
- all_critical_findings: list[RawFinding] = []
381
- all_minor_findings: list[RawFinding] = []
382
- finding_count_by_round: dict[int, int] = {}
383
- finding_count_by_theme: dict[str, int] = {}
384
-
385
- for each_finding in distinct_findings:
386
- severity = each_finding.severity
387
- round_number = each_finding.round_number
388
- theme = _derive_theme(each_finding.file)
389
-
390
- if severity in SEVERITY_CRITICAL_LEVELS:
391
- all_critical_findings.append(each_finding)
392
- else:
393
- all_minor_findings.append(each_finding)
394
-
395
- finding_count_by_round[round_number] = (
396
- finding_count_by_round.get(round_number, 0) + 1
397
- )
398
- finding_count_by_theme[theme] = finding_count_by_theme.get(theme, 0) + 1
361
+ convergence_summary = _extract_convergence_summary(progress_entries, agents_dir)
399
362
 
400
363
  fix_commit_count = sum(1 for each_fix in fix_by_round.values() if each_fix.pushed)
401
364
 
402
- tests_added_by_round: dict[int, int] = {}
403
- for each_round_number, each_fix in fix_by_round.items():
404
- if not each_fix.pushed:
405
- tests_added_by_round[each_round_number] = 0
406
- continue
407
- tests_added_by_round[each_round_number] = _count_tests_added(
408
- each_fix.base_sha,
409
- each_fix.new_sha,
410
- repo_path,
411
- )
412
-
413
365
  return RunData(
414
366
  generated_date=generated_date,
415
367
  total_finding_count=len(distinct_findings),
416
- critical_finding_count=len(all_critical_findings),
417
- minor_finding_count=len(all_minor_findings),
418
368
  fix_commit_count=fix_commit_count,
419
- tests_added_by_round=tests_added_by_round,
420
- finding_count_by_round=finding_count_by_round,
421
- finding_count_by_theme=finding_count_by_theme,
422
- all_critical_findings=all_critical_findings,
423
- all_minor_findings=all_minor_findings,
369
+ all_distinct_findings=distinct_findings,
424
370
  fix_by_round=fix_by_round,
371
+ convergence_summary=convergence_summary,
425
372
  )
426
373
 
427
374
 
428
- def _render_bar_row(label: str, bar_value: int, max_value: int, color: str) -> str:
429
- """Return an HTML .bar-row element for a single chart bar.
375
+ def _is_summary_structurally_valid(convergence_summary: object) -> bool:
376
+ """Return whether the summary carries a verdict string and an issue-class list.
430
377
 
431
378
  Args:
432
- label: The text label displayed on the left.
433
- bar_value: The numeric value for this bar.
434
- max_value: The maximum value across all bars in this chart.
435
- color: The CSS hex color for the bar fill.
379
+ convergence_summary: The parsed convergence summary, which may be None or
380
+ any non-dict JSON value an agent emits in place of the expected object.
436
381
 
437
382
  Returns:
438
- An HTML string for one .bar-row.
383
+ True when the summary is a dict whose verdictLine is a str and whose
384
+ issueClasses is a list, else False.
439
385
  """
440
- fill_width = round(bar_value / max(max_value, 1) * BAR_FILL_MAX_PERCENT, 1)
441
- escaped_label = html.escape(label)
442
- return (
443
- f'<div class="bar-row">'
444
- f'<span class="bar-label">{escaped_label}</span>'
445
- f'<div class="bar-track">'
446
- f'<div class="bar-fill" style="width:{fill_width}%;background:{color};"></div>'
447
- f"</div>"
448
- f'<span class="bar-value">{bar_value}</span>'
449
- f"</div>"
450
- )
386
+ if not isinstance(convergence_summary, dict):
387
+ return False
388
+ verdict_line = convergence_summary.get(SUMMARY_FIELD_VERDICT_LINE)
389
+ issue_classes = convergence_summary.get(SUMMARY_FIELD_ISSUE_CLASSES)
390
+ return isinstance(verdict_line, str) and isinstance(issue_classes, list)
451
391
 
452
392
 
453
- def _render_chart_card(title: str, bar_rows_html: str) -> str:
454
- """Return an HTML .chart-card wrapping the given bar rows.
393
+ def _coerce_count(raw_count: object) -> int:
394
+ """Return a non-negative integer count from an LLM-authored field, else zero.
455
395
 
456
396
  Args:
457
- title: The chart title displayed in uppercase.
458
- bar_rows_html: Pre-rendered HTML for all bar rows.
397
+ raw_count: The count value as read from the convergence summary, which an
398
+ agent may emit as null, a non-numeric string, or a valid number.
459
399
 
460
400
  Returns:
461
- An HTML string for one .chart-card.
401
+ The value parsed to an int, or 0 when it is null, non-numeric, or absent.
462
402
  """
463
- escaped_title = html.escape(title)
403
+ if isinstance(raw_count, bool):
404
+ return int(raw_count)
405
+ if isinstance(raw_count, int):
406
+ return raw_count
407
+ if isinstance(raw_count, (float, str)):
408
+ try:
409
+ return int(float(raw_count))
410
+ except (TypeError, ValueError):
411
+ return 0
412
+ return 0
413
+
414
+
415
+ def _pluralize(count: int, singular: str, plural: str) -> str:
416
+ """Return a 'count word' phrase choosing the singular or plural noun.
417
+
418
+ Args:
419
+ count: The quantity that decides the noun form.
420
+ singular: The noun to use when count is exactly one.
421
+ plural: The noun to use for every other count.
422
+
423
+ Returns:
424
+ A phrase like '1 fix commit' or '2 fix commits'.
425
+ """
426
+ noun = singular if count == 1 else plural
427
+ return f"{count} {noun}"
428
+
429
+
430
+ def _render_verdict_banner(
431
+ convergence_summary: dict, run_data: RunData, final_sha_short: str
432
+ ) -> str:
433
+ """Return the verdict banner with the verdict line and a Python-computed sub-line.
434
+
435
+ Args:
436
+ convergence_summary: A structurally valid convergence summary.
437
+ run_data: Aggregated metrics from the journal.
438
+ final_sha_short: First eight characters of the final commit sha.
439
+
440
+ Returns:
441
+ An HTML .verdict banner string with a check circle, vtext, and vsub.
442
+ """
443
+ verdict_line = str(convergence_summary.get(SUMMARY_FIELD_VERDICT_LINE, ""))
444
+ fix_commit_phrase = _pluralize(
445
+ run_data.fix_commit_count, "fix commit", "fix commits"
446
+ )
447
+ vsub = f"{fix_commit_phrase} &middot; final commit {html.escape(final_sha_short)}"
464
448
  return (
465
- f'<div class="chart-card">'
466
- f'<div class="chart-title">{escaped_title}</div>'
467
- f"{bar_rows_html}"
468
- f"</div>"
449
+ '<div class="verdict">'
450
+ '<div class="check">&#10003;</div>'
451
+ "<div>"
452
+ f'<div class="vtext">{html.escape(verdict_line)}</div>'
453
+ f'<div class="vsub">{vsub}</div>'
454
+ "</div>"
455
+ "</div>"
469
456
  )
470
457
 
471
458
 
472
- def _render_severity_chart(critical_count: int, minor_count: int) -> str:
473
- """Return a severity breakdown chart card.
459
+ def _render_scene_row(scene: dict, is_problem: bool) -> str:
460
+ """Return one .scene row plus its caption for a problem-or-fix scene.
474
461
 
475
462
  Args:
476
- critical_count: Total critical (P0/P1) findings.
477
- minor_count: Total minor (P2) findings.
463
+ scene: A scene dict with trigger, condition, result, and caption.
464
+ is_problem: True for a problem scene (bad result), False for a fix scene.
478
465
 
479
466
  Returns:
480
- An HTML .chart-card string.
467
+ An HTML .scene row followed by a .scene-cap caption line.
481
468
  """
482
- max_count = max(critical_count, minor_count, 1)
483
- rows = _render_bar_row(
484
- SEVERITY_CRITICAL_BUCKET, critical_count, max_count, BAR_COLOR_SEVERITY_CRITICAL
485
- ) + _render_bar_row(
486
- SEVERITY_MINOR_BUCKET, minor_count, max_count, BAR_COLOR_SEVERITY_MINOR
487
- )
488
- return _render_chart_card("Findings by severity", rows)
469
+ trigger = html.escape(str(scene.get(SCENE_FIELD_TRIGGER, "")))
470
+ condition = str(scene.get(SCENE_FIELD_CONDITION, "")).strip()
471
+ result_text = html.escape(str(scene.get(SCENE_FIELD_RESULT, "")))
472
+ caption = html.escape(str(scene.get(SCENE_FIELD_CAPTION, "")))
489
473
 
474
+ result_class = "res-bad" if is_problem else "res-good"
475
+ result_mark = "&#10007;" if is_problem else "&#10003;"
476
+
477
+ parts = [f'<span class="chip">{trigger}</span>']
478
+ if condition:
479
+ parts.append('<span class="arrow">&rarr;</span>')
480
+ parts.append(f'<span class="note">{html.escape(condition)}</span>')
481
+ parts.append('<span class="arrow">&rarr;</span>')
482
+ parts.append(f'<span class="{result_class}">{result_mark} {result_text}</span>')
483
+
484
+ scene_row = f'<div class="scene">{"".join(parts)}</div>'
485
+ caption_row = f'<div class="scene-cap">{caption}</div>'
486
+ return scene_row + caption_row
490
487
 
491
- def _render_round_findings_chart(
492
- round_count: int, finding_count_by_round: dict[int, int]
493
- ) -> str:
494
- """Return a per-round finding count chart card.
488
+
489
+ def _render_pf_card(convergence_summary: dict, is_problem: bool) -> str:
490
+ """Return a single problem-or-fix card drawing its scenes or a fallback caption.
495
491
 
496
492
  Args:
497
- round_count: Total number of rounds in the run.
498
- finding_count_by_round: Mapping of round number to distinct finding count.
493
+ convergence_summary: A structurally valid convergence summary.
494
+ is_problem: True for the problem card, False for the fix card.
499
495
 
500
496
  Returns:
501
- An HTML .chart-card string.
497
+ An HTML .pf card string with scene rows or a single fallback caption.
502
498
  """
503
- all_counts = [finding_count_by_round.get(r, 0) for r in range(1, round_count + 1)]
504
- max_count = max(all_counts + [1])
505
- rows = "".join(
506
- _render_bar_row(
507
- f"Round {r}", finding_count_by_round.get(r, 0), max_count, BAR_COLOR_ROUND
499
+ if is_problem:
500
+ card_class = "problem"
501
+ tag_label = "Problem"
502
+ scenes_field = SUMMARY_FIELD_PROBLEM_SCENES
503
+ fallback_field = SUMMARY_FIELD_PR_PROBLEM
504
+ else:
505
+ card_class = "fix"
506
+ tag_label = "Fix"
507
+ scenes_field = SUMMARY_FIELD_FIX_SCENES
508
+ fallback_field = SUMMARY_FIELD_PR_FIX
509
+
510
+ scenes = [
511
+ each_scene
512
+ for each_scene in convergence_summary.get(scenes_field, [])
513
+ if isinstance(each_scene, dict)
514
+ ]
515
+ if scenes:
516
+ body = "".join(
517
+ _render_scene_row(each_scene, is_problem) for each_scene in scenes
508
518
  )
509
- for r in range(1, round_count + 1)
519
+ else:
520
+ fallback_text = html.escape(str(convergence_summary.get(fallback_field, "")))
521
+ body = f'<div class="scene-cap">{fallback_text}</div>'
522
+
523
+ return (
524
+ f'<div class="pf {card_class}">'
525
+ f'<span class="pf-tag">{tag_label}</span>'
526
+ f"{body}"
527
+ "</div>"
510
528
  )
511
- return _render_chart_card("Findings by round", rows)
512
529
 
513
530
 
514
- def _render_tests_chart(round_count: int, tests_added_by_round: dict[int, int]) -> str:
515
- """Return a per-round tests-added chart card.
531
+ def _render_pf_grid(convergence_summary: dict) -> str:
532
+ """Return the 'What this PR does' grid with a problem card and a fix card.
533
+
534
+ Args:
535
+ convergence_summary: A structurally valid convergence summary.
536
+
537
+ Returns:
538
+ An HTML .pf-grid string with both cards.
539
+ """
540
+ problem_card = _render_pf_card(convergence_summary, is_problem=True)
541
+ fix_card = _render_pf_card(convergence_summary, is_problem=False)
542
+ return f'<div class="pf-grid">{problem_card}{fix_card}</div>'
543
+
544
+
545
+ def _render_panel_body(lines: list[str], medium: str) -> str:
546
+ """Return the inner HTML for a before-or-after panel body.
516
547
 
517
548
  Args:
518
- round_count: Total number of rounds in the run.
519
- tests_added_by_round: Mapping of round number to tests added count.
549
+ lines: The literal short lines to show, joined with line breaks.
550
+ medium: One of 'terminal', 'code', or 'text'.
520
551
 
521
552
  Returns:
522
- An HTML .chart-card string.
553
+ An HTML panel-body string escaped and joined with <br>.
523
554
  """
524
- all_counts = [tests_added_by_round.get(r, 0) for r in range(1, round_count + 1)]
525
- max_count = max(all_counts + [1])
526
- rows = "".join(
527
- _render_bar_row(
528
- f"Round {r}", tests_added_by_round.get(r, 0), max_count, BAR_COLOR_TESTS
555
+ escaped_lines = [html.escape(str(each_line)) for each_line in lines]
556
+ joined = "<br>".join(escaped_lines)
557
+ if medium == MEDIUM_TERMINAL:
558
+ return (
559
+ '<div class="terminal">'
560
+ '<div class="term-bar"><i class="r"></i><i class="y"></i><i class="g"></i>'
561
+ f"<span>{TIMELINE_TERMINAL_BAR_LABEL}</span></div>"
562
+ f'<div class="term-body">{joined}</div>'
563
+ "</div>"
529
564
  )
530
- for r in range(1, round_count + 1)
565
+ panel_class = "code-panel" if medium == MEDIUM_CODE else "text-panel"
566
+ return f'<div class="{panel_class}">{joined}</div>'
567
+
568
+
569
+ def _render_term_grid(issue_class: dict, medium: str) -> str:
570
+ """Return the before/after panel grid for one issue class.
571
+
572
+ Args:
573
+ issue_class: One issue-class dict from the summary.
574
+ medium: The medium that styles both panels.
575
+
576
+ Returns:
577
+ An HTML .term-grid string with a before panel and an after panel.
578
+ """
579
+ before_lines = list(issue_class.get(ISSUE_CLASS_FIELD_BEFORE_LINES, []))
580
+ after_lines = list(issue_class.get(ISSUE_CLASS_FIELD_AFTER_LINES, []))
581
+ before_body = _render_panel_body(before_lines, medium)
582
+ after_body = _render_panel_body(after_lines, medium)
583
+ return (
584
+ '<div class="term-grid">'
585
+ '<div class="term-wrap before">'
586
+ f'<div class="tlabel">{TIMELINE_BEFORE_LABEL} '
587
+ f'<span class="pill-x">{TIMELINE_BEFORE_PILL}</span></div>'
588
+ f"{before_body}"
589
+ "</div>"
590
+ '<div class="term-wrap after">'
591
+ f'<div class="tlabel">{TIMELINE_AFTER_LABEL} '
592
+ f'<span class="pill-c">{TIMELINE_AFTER_PILL}</span></div>'
593
+ f"{after_body}"
594
+ "</div>"
595
+ "</div>"
531
596
  )
532
- return _render_chart_card("Tests added per round", rows)
533
597
 
534
598
 
535
- def _render_theme_chart(finding_count_by_theme: dict[str, int]) -> str:
536
- """Return a findings-by-theme chart card.
599
+ def _render_cause_line(issue_class: dict) -> str:
600
+ """Return the cause line plus a muted severity/category/count/status parenthetical.
537
601
 
538
602
  Args:
539
- finding_count_by_theme: Mapping of theme string to distinct finding count.
603
+ issue_class: One issue-class dict from the summary.
540
604
 
541
605
  Returns:
542
- An HTML .chart-card string.
606
+ An HTML .cause string.
543
607
  """
544
- sorted_themes = sorted(
545
- finding_count_by_theme.items(), key=lambda pair: pair[1], reverse=True
608
+ cause = html.escape(str(issue_class.get(ISSUE_CLASS_FIELD_CAUSE, "")))
609
+ severity = str(issue_class.get(ISSUE_CLASS_FIELD_SEVERITY, DEFAULT_FINDING_SEVERITY))
610
+ category = str(issue_class.get(ISSUE_CLASS_FIELD_CATEGORY, CATEGORY_BUG))
611
+ count = _coerce_count(issue_class.get(ISSUE_CLASS_FIELD_COUNT, 0))
612
+ status = str(issue_class.get(ISSUE_CLASS_FIELD_STATUS, ""))
613
+
614
+ category_label = CATEGORY_LABEL_BY_VALUE.get(category, category)
615
+ status_label = STATUS_LABEL_BY_VALUE.get(status, status)
616
+ parenthetical = (
617
+ f"({html.escape(severity)} {html.escape(category_label)} "
618
+ f"&middot; &times;{count} &middot; {html.escape(status_label)})"
546
619
  )
547
- max_count = max((each_count for _, each_count in sorted_themes), default=1)
548
- rows = "".join(
549
- _render_bar_row(each_theme, each_count, max_count, BAR_COLOR_THEME)
550
- for each_theme, each_count in sorted_themes
620
+ return (
621
+ '<div class="cause">'
622
+ f"<b>Why it happened:</b> {cause} "
623
+ f'<span style="{CAUSE_MUTED_STYLE}">{parenthetical}</span>'
624
+ "</div>"
551
625
  )
552
- return _render_chart_card("Findings by theme", rows)
553
626
 
554
627
 
555
- def _render_fix_block(finding: RawFinding, fix_by_round: dict[int, FixRecord]) -> str:
556
- """Return the green fix resolution sub-block for a finding card.
628
+ def _issue_class_sort_key(issue_class: dict) -> tuple[int, int]:
629
+ """Return a sort key ordering bug classes first, then by severity.
557
630
 
558
631
  Args:
559
- finding: The raw finding being described.
560
- fix_by_round: Mapping of round number to fix record.
632
+ issue_class: One issue-class dict from the summary.
561
633
 
562
634
  Returns:
563
- An HTML .bug-fix string describing how the finding was resolved.
635
+ A tuple of (category rank, severity rank); lower sorts first.
564
636
  """
565
- round_number = finding.round_number
566
- fix_record = fix_by_round.get(round_number)
637
+ category = issue_class.get(ISSUE_CLASS_FIELD_CATEGORY, CATEGORY_BUG)
638
+ severity = issue_class.get(ISSUE_CLASS_FIELD_SEVERITY, DEFAULT_FINDING_SEVERITY)
639
+ category_rank = CATEGORY_SORT_ORDER.get(category, len(CATEGORY_SORT_ORDER))
640
+ severity_rank = SEVERITY_SORT_RANK.get(severity, len(SEVERITY_SORT_RANK))
641
+ return (category_rank, severity_rank)
567
642
 
568
- if fix_record is None:
569
- return '<div class="bug-fix"><b>Fix:</b> resolved during convergence.</div>'
570
643
 
571
- if fix_record.resolved_without_commit:
644
+ def _render_issue_class_heading(issue_class: dict) -> str:
645
+ """Return the per-class heading with the plain bug name and an occurrence count.
646
+
647
+ Args:
648
+ issue_class: One issue-class dict from the summary.
649
+
650
+ Returns:
651
+ An HTML .bug-head block with the plain bug name and a finding-count chip.
652
+ """
653
+ plain_name = html.escape(str(issue_class.get(ISSUE_CLASS_FIELD_PLAINNAME, "")))
654
+ count = _coerce_count(issue_class.get(ISSUE_CLASS_FIELD_COUNT, 0))
655
+ count_phrase = html.escape(_pluralize(count, "finding", "findings"))
656
+ return (
657
+ '<div class="bug-head">'
658
+ f'<span class="bug-name">{plain_name}</span>'
659
+ f'<span class="bug-count">{count_phrase}</span>'
660
+ "</div>"
661
+ )
662
+
663
+
664
+ def _render_issue_class_block(issue_class: dict) -> str:
665
+ """Return one issue-class block: a name heading, before/after panels, a cause line.
666
+
667
+ Args:
668
+ issue_class: One issue-class dict from the summary.
669
+
670
+ Returns:
671
+ An HTML fragment that opens with the .bug-head name heading, then a
672
+ .term-grid when panel lines exist, then the .cause line; the heading and
673
+ cause line alone when both before and after lines are empty.
674
+ """
675
+ heading = _render_issue_class_heading(issue_class)
676
+ medium = str(issue_class.get(ISSUE_CLASS_FIELD_MEDIUM, MEDIUM_TERMINAL))
677
+ before_lines = list(issue_class.get(ISSUE_CLASS_FIELD_BEFORE_LINES, []))
678
+ after_lines = list(issue_class.get(ISSUE_CLASS_FIELD_AFTER_LINES, []))
679
+
680
+ cause_line = _render_cause_line(issue_class)
681
+ if not before_lines and not after_lines:
682
+ return heading + cause_line
683
+
684
+ term_grid = _render_term_grid(issue_class, medium)
685
+ return heading + term_grid + cause_line
686
+
687
+
688
+ def _render_issue_class_panels(convergence_summary: dict) -> str:
689
+ """Return the per-issue-class before/after panels and cause lines.
690
+
691
+ Args:
692
+ convergence_summary: A structurally valid convergence summary.
693
+
694
+ Returns:
695
+ An HTML fragment with one block per issue class, ordered bug classes
696
+ first then by severity.
697
+ """
698
+ issue_classes = [
699
+ each_class
700
+ for each_class in convergence_summary.get(SUMMARY_FIELD_ISSUE_CLASSES, [])
701
+ if isinstance(each_class, dict)
702
+ ]
703
+ if not issue_classes:
572
704
  return (
573
- f'<div class="bug-fix"><b>Fix:</b> already resolved at HEAD in round {round_number}; '
574
- f"threads closed.</div>"
705
+ '<p class="subtitle">No issues were caught &mdash; '
706
+ "every review lens was clean.</p>"
575
707
  )
708
+ sorted_classes = sorted(issue_classes, key=_issue_class_sort_key)
709
+ return "".join(
710
+ _render_issue_class_block(each_class) for each_class in sorted_classes
711
+ )
576
712
 
577
- if not fix_record.new_sha:
578
- return '<div class="bug-fix"><b>Fix:</b> resolved during convergence.</div>'
579
713
 
580
- new_sha_short = fix_record.new_sha[:8]
714
+ def _render_caught_lead(
715
+ convergence_summary: dict, run_data: RunData, round_count: int
716
+ ) -> str:
717
+ """Return the run-stats lead line that opens the caught section.
718
+
719
+ Args:
720
+ convergence_summary: A structurally valid convergence summary.
721
+ run_data: Aggregated metrics from the journal.
722
+ round_count: Total number of convergence rounds.
723
+
724
+ Returns:
725
+ An HTML .subtitle line stating bug-class, finding, round, and fix counts.
726
+ """
727
+ issue_classes = [
728
+ each_class
729
+ for each_class in convergence_summary.get(SUMMARY_FIELD_ISSUE_CLASSES, [])
730
+ if isinstance(each_class, dict)
731
+ ]
732
+ class_phrase = _pluralize(len(issue_classes), "bug class", "bug classes")
733
+ finding_phrase = _pluralize(run_data.total_finding_count, "finding", "findings")
734
+ round_phrase = _pluralize(round_count, "round", "rounds")
735
+ fix_phrase = _pluralize(run_data.fix_commit_count, "fix commit", "fix commits")
581
736
  return (
582
- f'<div class="bug-fix"><b>Fix:</b> resolved in the round {round_number} fix commit '
583
- f"<code>{html.escape(new_sha_short)}</code>.</div>"
737
+ f'<p class="subtitle">{class_phrase} ({finding_phrase} in all), '
738
+ f"caught and fixed across {round_phrase} in {fix_phrase}.</p>"
584
739
  )
585
740
 
586
741
 
587
- def _render_bug_card(
588
- index: int,
589
- finding: RawFinding,
590
- fix_by_round: dict[int, FixRecord],
591
- card_class: str,
592
- ) -> str:
593
- """Return an HTML .bug-card element for one finding.
742
+ def _appendix_finding_sort_key(finding: RawFinding) -> tuple[int, int, str, int]:
743
+ """Return a sort key grouping appendix findings by category then severity.
594
744
 
595
745
  Args:
596
- index: 1-based display index for the card.
597
- finding: The raw finding to render.
598
- fix_by_round: Mapping of round number to fix record for the fix sub-block.
599
- card_class: Either 'crit' or 'minor'.
746
+ finding: One distinct finding.
600
747
 
601
748
  Returns:
602
- An HTML string for one .bug-card.
749
+ A tuple of (category rank, severity rank, file, line).
603
750
  """
604
- severity = finding.severity
605
- badge_class = SEVERITY_BADGE_CLASS_BY_LEVEL.get(severity, "b-p2")
606
- escaped_title = html.escape(finding.title)
607
- escaped_detail = html.escape(finding.detail)
608
- escaped_file = html.escape(finding.file)
609
- line_number = finding.line
610
- round_number = finding.round_number
751
+ category_rank = CATEGORY_SORT_ORDER.get(finding.category, len(CATEGORY_SORT_ORDER))
752
+ severity_rank = SEVERITY_SORT_RANK.get(finding.severity, len(SEVERITY_SORT_RANK))
753
+ return (category_rank, severity_rank, finding.file, finding.line)
611
754
 
612
- fix_block = _render_fix_block(finding, fix_by_round)
613
755
 
614
- return (
615
- f'<div class="bug-card {card_class}">'
616
- f'<div class="bug-head">'
617
- f'<span class="bug-num">#{index}</span>'
618
- f'<span class="bug-title">{escaped_title}</span>'
619
- f'<div class="badges">'
620
- f'<span class="badge {badge_class}">{html.escape(severity)}</span>'
621
- f'<span class="badge b-fixed">Fixed</span>'
622
- f"</div>"
623
- f"</div>"
624
- f'<div class="bug-impact">{escaped_detail}</div>'
625
- f"{fix_block}"
626
- f'<div class="bug-meta"><code>{escaped_file}</code>:{line_number} · round {round_number}</div>'
756
+ def _render_appendix(findings: list[RawFinding]) -> str:
757
+ """Return a collapsed <details> appendix of raw distinct findings.
758
+
759
+ Args:
760
+ findings: The distinct findings to list, grouped by category then severity.
761
+
762
+ Returns:
763
+ An HTML <details> string listing each finding as 'file:line — P# — title'.
764
+ """
765
+ if not findings:
766
+ return ""
767
+ sorted_findings = sorted(findings, key=_appendix_finding_sort_key)
768
+ items = "".join(
769
+ f'<div class="appendix-item">'
770
+ f"{html.escape(each_finding.file)}:{each_finding.line} &mdash; "
771
+ f"{html.escape(each_finding.severity)} &mdash; "
772
+ f"{html.escape(each_finding.title)}"
627
773
  f"</div>"
774
+ for each_finding in sorted_findings
775
+ )
776
+ return (
777
+ '<details class="appendix">'
778
+ f"<summary>Raw findings ({len(findings)})</summary>"
779
+ f'<div class="appendix-body">{items}</div>'
780
+ "</details>"
628
781
  )
629
782
 
630
783
 
631
- def _render_stat(label: str, stat_value: int) -> str:
632
- """Return an HTML .stat block for the summary stats row.
784
+ def _render_summary_body(
785
+ run_data: RunData, round_count: int, final_sha_short: str
786
+ ) -> str:
787
+ """Return the body for a run that carries a valid convergence summary.
633
788
 
634
789
  Args:
635
- label: The label displayed below the number.
636
- stat_value: The numeric value to display.
790
+ run_data: Aggregated metrics from the journal.
791
+ round_count: Total number of convergence rounds.
792
+ final_sha_short: First eight characters of the final commit sha.
637
793
 
638
794
  Returns:
639
- An HTML string for one .stat element.
795
+ An HTML body fragment: verdict banner, problem/fix cards, then a single
796
+ caught section that opens with a run-stats lead line and holds the
797
+ issue-class before/after panels, followed by the collapsed appendix.
640
798
  """
641
- escaped_label = html.escape(label)
799
+ convergence_summary = run_data.convergence_summary
800
+ if convergence_summary is None:
801
+ return _render_degraded_body(run_data, round_count, final_sha_short)
802
+
803
+ verdict_banner = _render_verdict_banner(
804
+ convergence_summary, run_data, final_sha_short
805
+ )
806
+ pf_grid = _render_pf_grid(convergence_summary)
807
+ caught_lead = _render_caught_lead(convergence_summary, run_data, round_count)
808
+ issue_panels = _render_issue_class_panels(convergence_summary)
809
+ appendix = _render_appendix(run_data.all_distinct_findings)
642
810
  return (
643
- f'<div class="stat">'
644
- f'<div class="stat-value">{stat_value}</div>'
645
- f'<div class="stat-label">{escaped_label}</div>'
646
- f"</div>"
811
+ f"{verdict_banner}"
812
+ f"<h2>What this PR does</h2>{pf_grid}"
813
+ f"<h2>What was caught &mdash; and how it looked</h2>{caught_lead}{issue_panels}"
814
+ f"{appendix}"
647
815
  )
648
816
 
649
817
 
650
- def _render_finding_cards(
651
- findings: list[RawFinding],
652
- fix_by_round: dict[int, FixRecord],
653
- card_class: str,
818
+ def _render_degraded_body(
819
+ run_data: RunData, round_count: int, final_sha_short: str
654
820
  ) -> str:
655
- """Return an HTML .bugs container with one .bug-card per finding.
821
+ """Return the minimal degraded body for a run with no valid summary.
656
822
 
657
823
  Args:
658
- findings: The list of raw findings to render.
659
- fix_by_round: Mapping of round number to fix record.
660
- card_class: Either 'crit' or 'minor'.
824
+ run_data: Aggregated metrics from the journal.
825
+ round_count: Total number of convergence rounds.
826
+ final_sha_short: First eight characters of the final commit sha.
661
827
 
662
828
  Returns:
663
- An HTML string for the .bugs container, or empty string when findings is empty.
829
+ An HTML body fragment: a plain run-stats note and the collapsed
830
+ raw-findings appendix, with no scene, table, rollup, or timeline markup.
664
831
  """
665
- if not findings:
666
- return ""
667
- cards = "".join(
668
- _render_bug_card(each_index + 1, each_finding, fix_by_round, card_class)
669
- for each_index, each_finding in enumerate(findings)
832
+ fix_commit_phrase = _pluralize(
833
+ run_data.fix_commit_count, "fix commit", "fix commits"
670
834
  )
671
- return f'<div class="bugs">{cards}</div>'
835
+ note = (
836
+ '<p class="subtitle">'
837
+ f"{run_data.total_finding_count} distinct findings across {round_count} "
838
+ f"rounds, resolved in {fix_commit_phrase}. "
839
+ f"Final commit <code>{html.escape(final_sha_short)}</code>."
840
+ "</p>"
841
+ )
842
+ appendix = _render_appendix(run_data.all_distinct_findings)
843
+ return f"{note}{appendix}"
672
844
 
673
845
 
674
846
  def render_report_html(
675
847
  run_data: RunData, pr_metadata: PrMetadata, generated_date: str
676
848
  ) -> str:
677
- """Render the convergence insights report as an HTML string.
849
+ """Render the convergence summary report as an HTML string.
678
850
 
679
851
  Args:
680
852
  run_data: Aggregated metrics from the workflow journal and transcripts.
@@ -684,129 +856,43 @@ def render_report_html(
684
856
  Returns:
685
857
  A complete HTML document string.
686
858
  """
859
+ short_sha_length = SHORT_SHA_LENGTH
687
860
  pr_number = pr_metadata.number
688
861
  owner = html.escape(pr_metadata.owner)
689
862
  repo = html.escape(pr_metadata.repo)
690
- final_sha_short = pr_metadata.final_sha[:8]
863
+ final_sha_short = pr_metadata.final_sha[:short_sha_length]
691
864
  round_count = pr_metadata.round_count
692
865
 
693
- total_findings = run_data.total_finding_count
694
- critical_count = run_data.critical_finding_count
695
- minor_count = run_data.minor_finding_count
696
- fix_commit_count = run_data.fix_commit_count
697
- tests_added_total = sum(run_data.tests_added_by_round.values())
698
-
699
866
  head_html = HTML_HEAD_TEMPLATE.format(
700
867
  pr_number=pr_number,
701
868
  style_block=HTML_STYLE_BLOCK,
702
869
  )
703
870
 
704
871
  subtitle = (
705
- f'<p class="subtitle">{owner}/{repo} · {total_findings} findings '
706
- f"across {round_count} rounds · {html.escape(generated_date)}</p>"
872
+ f'<p class="subtitle">{owner}/{repo} &middot; '
873
+ f"{run_data.total_finding_count} findings over {round_count} rounds "
874
+ f"&middot; {html.escape(generated_date)}</p>"
707
875
  )
708
876
 
709
- glance_caught = (
710
- f'<div class="glance-section"><strong>What was caught:</strong> autoconverge ran '
711
- f"{round_count} rounds and surfaced {total_findings} distinct findings — "
712
- f"{critical_count} critical, {minor_count} minor.</div>"
713
- )
714
- glance_resolution = (
715
- f'<div class="glance-section"><strong>Resolution:</strong> every finding was fixed '
716
- f"before the PR was marked ready; {fix_commit_count} fix commits landed.</div>"
717
- )
718
- glance_status = (
719
- f'<div class="glance-section"><strong>Status:</strong> the run converged on commit '
720
- f"{html.escape(final_sha_short)}.</div>"
721
- )
722
- at_a_glance = (
723
- f'<div class="at-a-glance">'
724
- f'<div class="glance-title">At a Glance</div>'
725
- f'<div class="glance-sections">'
726
- f"{glance_caught}{glance_resolution}{glance_status}"
727
- f"</div></div>"
728
- )
729
-
730
- nav_toc = (
731
- '<nav class="nav-toc">'
732
- '<a href="#numbers">The numbers</a>'
733
- '<a href="#critical">Critical findings</a>'
734
- '<a href="#minor">Minor findings</a>'
735
- '<a href="#status">Status</a>'
736
- "</nav>"
737
- )
738
-
739
- stats_row = (
740
- '<div class="stats-row">'
741
- + _render_stat("Findings", total_findings)
742
- + _render_stat("Critical", critical_count)
743
- + _render_stat("Minor", minor_count)
744
- + _render_stat("Rounds", round_count)
745
- + _render_stat("Fix commits", fix_commit_count)
746
- + _render_stat("Tests added", tests_added_total)
747
- + "</div>"
748
- )
749
-
750
- severity_chart = _render_severity_chart(critical_count, minor_count)
751
- round_chart = _render_round_findings_chart(
752
- round_count, run_data.finding_count_by_round
753
- )
754
- tests_chart = _render_tests_chart(round_count, run_data.tests_added_by_round)
755
- theme_chart = _render_theme_chart(run_data.finding_count_by_theme)
756
-
757
- charts_row_one = f'<div class="charts-row">{severity_chart}{round_chart}</div>'
758
- charts_row_two = f'<div class="charts-row">{tests_chart}{theme_chart}</div>'
759
-
760
- numbers_section = (
761
- f'<h2 id="numbers">The numbers</h2>{charts_row_one}{charts_row_two}'
762
- )
877
+ if _is_summary_structurally_valid(run_data.convergence_summary):
878
+ body_main = _render_summary_body(run_data, round_count, final_sha_short)
879
+ else:
880
+ body_main = _render_degraded_body(run_data, round_count, final_sha_short)
763
881
 
764
- critical_cards = _render_finding_cards(
765
- run_data.all_critical_findings, run_data.fix_by_round, "crit"
882
+ fix_commit_phrase = _pluralize(
883
+ run_data.fix_commit_count, "fix commit", "fix commits"
766
884
  )
767
- critical_intro = '<p class="section-intro">P0 and P1 findings caught and fixed during the run.</p>'
768
- critical_section = f'<h2 id="critical">Critical findings</h2>' + (
769
- f"{critical_intro}{critical_cards}"
770
- if run_data.all_critical_findings
771
- else '<p class="section-intro">No critical findings.</p>'
772
- )
773
-
774
- minor_intro = (
775
- '<p class="section-intro">P2 findings caught and fixed during the run.</p>'
776
- )
777
- minor_cards = _render_finding_cards(
778
- run_data.all_minor_findings, run_data.fix_by_round, "minor"
779
- )
780
- minor_section = f'<h2 id="minor">Minor findings</h2>{minor_intro}{minor_cards}'
781
-
782
- horizon_tip = (
783
- f'<div class="horizon-tip">Final commit <code>{html.escape(final_sha_short)}</code> '
784
- f"· {round_count} rounds · {fix_commit_count} fix commits.</div>"
785
- )
786
- status_section = (
787
- f'<h2 id="status">Status</h2>'
788
- f'<div class="horizon-card">'
789
- f'<div class="horizon-title">Converged</div>'
790
- f'<div class="horizon-possible">The run converged and the PR was marked ready.</div>'
791
- f"{horizon_tip}"
792
- f"</div>"
793
- )
794
-
795
885
  footer = (
796
- f"<footer>{owner}/{repo} · PR #{pr_number} · "
886
+ f"<footer>{owner}/{repo} &middot; PR #{pr_number} &middot; "
887
+ f"{run_data.total_finding_count} findings &middot; {round_count} rounds "
888
+ f"&middot; {fix_commit_phrase} &middot; "
797
889
  f"generated {html.escape(generated_date)} from the autoconverge run journal.</footer>"
798
890
  )
799
891
 
800
892
  body_content = (
801
- f"<h1>PR #{pr_number} Convergence Insights</h1>"
893
+ f"<h1>PR #{pr_number} Convergence Summary</h1>"
802
894
  f"{subtitle}"
803
- f"{at_a_glance}"
804
- f"{nav_toc}"
805
- f"{stats_row}"
806
- f"{numbers_section}"
807
- f"{critical_section}"
808
- f"{minor_section}"
809
- f"{status_section}"
895
+ f"{body_main}"
810
896
  f"{footer}"
811
897
  )
812
898
 
@@ -843,7 +929,7 @@ def _parse_pr_arg(pr_arg: str, err_stream: TextIO) -> tuple[str, str, int] | Non
843
929
 
844
930
 
845
931
  def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> int:
846
- """Parse CLI arguments, load run data, render HTML, write the output file, and emit the path.
932
+ """Parse CLI arguments, load run data, render HTML, write the file, emit the path.
847
933
 
848
934
  Args:
849
935
  out_stream: Stream to write the output file path to on success.
@@ -853,7 +939,7 @@ def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> in
853
939
  Exit code (0 on success, 1 on argument error).
854
940
  """
855
941
  argument_parser = argparse.ArgumentParser(
856
- description="Render autoconverge convergence insights HTML."
942
+ description="Render autoconverge convergence summary HTML."
857
943
  )
858
944
  argument_parser.add_argument(
859
945
  "--journal", required=True, help="Path to wf_<runId>.json"
@@ -865,14 +951,15 @@ def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> in
865
951
  "--rounds", required=True, type=int, help="Total round count"
866
952
  )
867
953
  argument_parser.add_argument(
868
- "--repo", default=".", help="Path to the git repository root"
954
+ "--summary-file",
955
+ default=None,
956
+ help="Path to a JSON file with the convergence summary to inject",
869
957
  )
870
958
 
871
959
  parsed_args = argument_parser.parse_args()
872
960
 
873
961
  journal_path = Path(parsed_args.journal).resolve()
874
962
  out_path = Path(parsed_args.out)
875
- repo_path = Path(parsed_args.repo).resolve()
876
963
 
877
964
  parsed_pr = _parse_pr_arg(parsed_args.pr, err_stream)
878
965
  if parsed_pr is None:
@@ -890,7 +977,11 @@ def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> in
890
977
  round_count=parsed_args.rounds,
891
978
  )
892
979
 
893
- run_data = load_run_data(journal_path, repo_path)
980
+ run_data = load_run_data(journal_path)
981
+ if parsed_args.summary_file:
982
+ run_data.convergence_summary = json.loads(
983
+ Path(parsed_args.summary_file).read_text(encoding="utf-8")
984
+ )
894
985
  html_content = render_report_html(run_data, pr_metadata, run_data.generated_date)
895
986
 
896
987
  out_path.parent.mkdir(parents=True, exist_ok=True)