claude-dev-env 1.57.2 → 1.59.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 (77) 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-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +317 -54
  10. package/bin/install.test.mjs +478 -3
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/intent_only_ending_blocker.py +155 -0
  21. package/hooks/blocking/session_handoff_blocker.py +190 -0
  22. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  23. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  24. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  25. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  26. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  27. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  28. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  29. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  30. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  31. package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
  32. package/hooks/blocking/test_session_handoff_blocker.py +312 -0
  33. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  34. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  35. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  36. package/hooks/hooks.json +25 -0
  37. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  38. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  39. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  40. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  41. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  42. package/hooks/hooks_constants/messages.py +4 -0
  43. package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
  44. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  45. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  46. package/hooks/workflow/auto_formatter.py +26 -1
  47. package/hooks/workflow/test_auto_formatter.py +134 -0
  48. package/package.json +1 -1
  49. package/rules/conservative-action.md +1 -0
  50. package/rules/docstring-prose-matches-implementation.md +43 -0
  51. package/rules/hook-prose-matches-detector.md +26 -0
  52. package/rules/long-horizon-autonomy.md +43 -0
  53. package/rules/no-inline-destructive-literals.md +11 -0
  54. package/rules/workflow-substitution-slots.md +7 -0
  55. package/skills/autoconverge/SKILL.md +68 -6
  56. package/skills/autoconverge/reference/closing-report.md +44 -0
  57. package/skills/autoconverge/reference/convergence.md +7 -3
  58. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
  60. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
  62. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  63. package/skills/autoconverge/workflow/converge.mjs +106 -38
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
  66. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
  67. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
  68. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
  69. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
  70. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
  71. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
  72. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
  73. package/skills/autoconverge/workflow/render_report.py +903 -0
  74. package/skills/autoconverge/workflow/test_render_report.py +484 -0
  75. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  76. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  77. package/skills/update/SKILL.md +37 -5
@@ -0,0 +1,903 @@
1
+ """Render a convergence insights HTML report from an autoconverge workflow journal."""
2
+
3
+ import argparse
4
+ import html
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TextIO
12
+
13
+ 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,
20
+ GITHUB_PR_URL_TEMPLATE,
21
+ HTML_DOCTYPE,
22
+ HTML_HEAD_TEMPLATE,
23
+ HTML_STYLE_BLOCK,
24
+ JOURNAL_SIBLING_SUBAGENTS,
25
+ JOURNAL_SIBLING_WORKFLOWS,
26
+ LABEL_COPILOT_GATE,
27
+ LABEL_PREFIX_FIX,
28
+ LABEL_PREFIX_LENS,
29
+ LABEL_RESOLVE_HEAD,
30
+ SEVERITY_BADGE_CLASS_BY_LEVEL,
31
+ SEVERITY_CRITICAL_BUCKET,
32
+ SEVERITY_CRITICAL_LEVELS,
33
+ SEVERITY_MINOR_BUCKET,
34
+ STRUCTURED_OUTPUT_TOOL_NAME,
35
+ TEST_DEFINITION_PATTERN,
36
+ TEST_PATH_GLOBS,
37
+ THEME_FALLBACK,
38
+ THEME_PATH_SEGMENT_COUNT,
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RawFinding:
44
+ """A single finding from a lens or copilot-gate agent result, tagged with round context."""
45
+
46
+ file: str
47
+ line: int
48
+ severity: str
49
+ title: str
50
+ detail: str
51
+ round_number: int
52
+ sha: str
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class FixRecord:
57
+ """The structured result of a fix agent, with round and base-sha context attached."""
58
+
59
+ new_sha: str
60
+ pushed: bool
61
+ resolved_without_commit: bool
62
+ round_number: int
63
+ base_sha: str
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class PrMetadata:
68
+ """Owner, repo, number, and pre-built URL for the PR being reported on."""
69
+
70
+ owner: str
71
+ repo: str
72
+ number: int
73
+ url: str
74
+ final_sha: str
75
+ round_count: int
76
+
77
+
78
+ @dataclass
79
+ class RunData:
80
+ """Aggregated metrics parsed from a workflow journal and agent transcripts."""
81
+
82
+ generated_date: str
83
+ total_finding_count: int
84
+ critical_finding_count: int
85
+ minor_finding_count: int
86
+ 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]
92
+ fix_by_round: dict[int, FixRecord]
93
+
94
+
95
+ def _resolve_agents_dir(journal_path: Path) -> Path:
96
+ """Return the directory containing per-agent transcript files for this run.
97
+
98
+ Args:
99
+ journal_path: Absolute path to the wf_<runId>.json journal file.
100
+
101
+ Returns:
102
+ Path to the subagents/workflows/<runId>/ directory.
103
+ """
104
+ run_id = journal_path.stem
105
+ return (
106
+ journal_path.parent.parent
107
+ / JOURNAL_SIBLING_SUBAGENTS
108
+ / JOURNAL_SIBLING_WORKFLOWS
109
+ / run_id
110
+ )
111
+
112
+
113
+ def _extract_structured_output(transcript_path: Path) -> dict | None:
114
+ """Return the last StructuredOutput tool input from an agent transcript.
115
+
116
+ Args:
117
+ transcript_path: Path to an agent-<id>.jsonl file.
118
+
119
+ Returns:
120
+ The input dict of the last StructuredOutput tool_use, or None when absent.
121
+ """
122
+ last_input: dict | None = None
123
+ try:
124
+ with transcript_path.open(encoding="utf-8") as transcript_file:
125
+ for each_line in transcript_file:
126
+ last_input = _last_structured_input_in_line(each_line, last_input)
127
+ except OSError:
128
+ return None
129
+
130
+ return last_input
131
+
132
+
133
+ def _last_structured_input_in_line(
134
+ transcript_line: str, prior_input: dict | None
135
+ ) -> dict | None:
136
+ """Return the StructuredOutput input on a transcript line, else the prior input.
137
+
138
+ Args:
139
+ transcript_line: One raw JSON line from an agent transcript.
140
+ prior_input: The last StructuredOutput input seen before this line.
141
+
142
+ Returns:
143
+ The input dict of the last StructuredOutput tool_use on this line, or
144
+ prior_input when the line carries none.
145
+ """
146
+ try:
147
+ parsed = json.loads(transcript_line)
148
+ except (ValueError, json.JSONDecodeError):
149
+ return prior_input
150
+
151
+ message = parsed.get("message") if isinstance(parsed, dict) else None
152
+ content_list = message.get("content") if isinstance(message, dict) else None
153
+ if not isinstance(content_list, list):
154
+ return prior_input
155
+
156
+ latest_input = prior_input
157
+ for each_block in content_list:
158
+ if not isinstance(each_block, dict):
159
+ continue
160
+ if (
161
+ each_block.get("type") == "tool_use"
162
+ and each_block.get("name") == STRUCTURED_OUTPUT_TOOL_NAME
163
+ and isinstance(each_block.get("input"), dict)
164
+ ):
165
+ latest_input = each_block["input"]
166
+
167
+ return latest_input
168
+
169
+
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
+ def _build_dedup_key(file_path: str, line: int, title: str) -> tuple[str, int, str]:
223
+ """Return a deduplication key for a finding.
224
+
225
+ Args:
226
+ file_path: The file field from the finding.
227
+ line: The line number from the finding.
228
+ title: The title field from the finding.
229
+
230
+ Returns:
231
+ A tuple of (file, line, lowercased_title).
232
+ """
233
+ return (file_path, line, title.lower())
234
+
235
+
236
+ def _parse_finding_from_dict(raw: dict, round_number: int, sha: str) -> RawFinding:
237
+ """Construct a RawFinding from a raw agent result dict.
238
+
239
+ Args:
240
+ 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
+
244
+ Returns:
245
+ A RawFinding dataclass instance.
246
+ """
247
+ return RawFinding(
248
+ file=raw.get("file", ""),
249
+ line=raw.get("line", 0),
250
+ severity=raw.get("severity", "P2"),
251
+ title=raw.get("title", ""),
252
+ detail=raw.get("detail", ""),
253
+ round_number=round_number,
254
+ sha=sha,
255
+ )
256
+
257
+
258
+ def _parse_fix_record(
259
+ agent_result: dict, round_number: int, base_sha: str
260
+ ) -> FixRecord:
261
+ """Construct a FixRecord from a fix agent's structured output.
262
+
263
+ Args:
264
+ agent_result: The structured output dict from the fix agent.
265
+ round_number: The round this fix belongs to.
266
+ base_sha: The HEAD sha the round reviewed before fixing.
267
+
268
+ Returns:
269
+ A FixRecord dataclass instance.
270
+ """
271
+ return FixRecord(
272
+ new_sha=agent_result.get("newSha", ""),
273
+ pushed=bool(agent_result.get("pushed", False)),
274
+ resolved_without_commit=bool(agent_result.get("resolvedWithoutCommit", False)),
275
+ round_number=round_number,
276
+ base_sha=base_sha,
277
+ )
278
+
279
+
280
+ def _parse_progress_entries(
281
+ progress_entries: list[dict],
282
+ agents_dir: Path,
283
+ ) -> tuple[list[RawFinding], dict[int, FixRecord]]:
284
+ """Walk workflowProgress in order and collect findings and fix results by round.
285
+
286
+ Args:
287
+ progress_entries: The workflowProgress list from the journal.
288
+ agents_dir: Directory containing per-agent .jsonl transcript files.
289
+
290
+ Returns:
291
+ A tuple of (all_raw_findings, fix_by_round).
292
+ """
293
+ all_findings: list[RawFinding] = []
294
+ fix_by_round: dict[int, FixRecord] = {}
295
+ current_round = 0
296
+ current_round_base_sha = ""
297
+
298
+ for each_entry in progress_entries:
299
+ label: str = each_entry.get("label", "")
300
+ agent_id: str | None = each_entry.get("agentId")
301
+
302
+ if label == LABEL_RESOLVE_HEAD:
303
+ current_round += 1
304
+ current_round_base_sha = ""
305
+ continue
306
+
307
+ if agent_id is None:
308
+ continue
309
+
310
+ transcript_path = agents_dir / f"agent-{agent_id}.jsonl"
311
+ agent_result = _extract_structured_output(transcript_path)
312
+ if agent_result is None:
313
+ continue
314
+
315
+ is_lens = label.startswith(LABEL_PREFIX_LENS)
316
+ is_copilot = label == LABEL_COPILOT_GATE
317
+ is_fix = label.startswith(LABEL_PREFIX_FIX)
318
+
319
+ if is_lens or is_copilot:
320
+ sha = agent_result.get("sha", "")
321
+ if not current_round_base_sha and sha:
322
+ current_round_base_sha = sha
323
+ raw_findings: list[dict] = agent_result.get("findings", [])
324
+ for each_raw in raw_findings:
325
+ all_findings.append(
326
+ _parse_finding_from_dict(each_raw, current_round, sha)
327
+ )
328
+
329
+ if is_fix:
330
+ fix_by_round[current_round] = _parse_fix_record(
331
+ agent_result, current_round, current_round_base_sha
332
+ )
333
+
334
+ return all_findings, fix_by_round
335
+
336
+
337
+ def _dedup_findings(all_findings: list[RawFinding]) -> list[RawFinding]:
338
+ """Deduplicate findings globally by (file, line, lower title), keeping earliest round.
339
+
340
+ Args:
341
+ all_findings: All raw findings in discovery order.
342
+
343
+ Returns:
344
+ A list of distinct findings with the earliest occurrence retained.
345
+ """
346
+ seen_keys: set[tuple[str, int, str]] = set()
347
+ distinct: list[RawFinding] = []
348
+ for each_finding in all_findings:
349
+ dedup_key = _build_dedup_key(
350
+ each_finding.file, each_finding.line, each_finding.title
351
+ )
352
+ if dedup_key not in seen_keys:
353
+ seen_keys.add(dedup_key)
354
+ distinct.append(each_finding)
355
+ return distinct
356
+
357
+
358
+ def load_run_data(journal_path: Path, repo_path: Path) -> RunData:
359
+ """Parse a workflow journal and its agent transcripts into aggregated metrics.
360
+
361
+ Args:
362
+ journal_path: Path to the wf_<runId>.json journal file.
363
+ repo_path: Path to the git repository for counting tests added.
364
+
365
+ Returns:
366
+ A RunData instance with all counts and finding lists populated.
367
+ """
368
+ journal = json.loads(journal_path.read_text(encoding="utf-8"))
369
+ timestamp: str = journal.get("timestamp", "")
370
+ generated_date = timestamp[:10] if len(timestamp) >= 10 else ""
371
+
372
+ progress_entries: list[dict] = journal.get("workflowProgress", [])
373
+ agents_dir = _resolve_agents_dir(journal_path)
374
+
375
+ all_raw_findings, fix_by_round = _parse_progress_entries(
376
+ progress_entries, agents_dir
377
+ )
378
+ 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
399
+
400
+ fix_commit_count = sum(1 for each_fix in fix_by_round.values() if each_fix.pushed)
401
+
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
+ return RunData(
414
+ generated_date=generated_date,
415
+ total_finding_count=len(distinct_findings),
416
+ critical_finding_count=len(all_critical_findings),
417
+ minor_finding_count=len(all_minor_findings),
418
+ 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,
424
+ fix_by_round=fix_by_round,
425
+ )
426
+
427
+
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.
430
+
431
+ 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.
436
+
437
+ Returns:
438
+ An HTML string for one .bar-row.
439
+ """
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
+ )
451
+
452
+
453
+ def _render_chart_card(title: str, bar_rows_html: str) -> str:
454
+ """Return an HTML .chart-card wrapping the given bar rows.
455
+
456
+ Args:
457
+ title: The chart title displayed in uppercase.
458
+ bar_rows_html: Pre-rendered HTML for all bar rows.
459
+
460
+ Returns:
461
+ An HTML string for one .chart-card.
462
+ """
463
+ escaped_title = html.escape(title)
464
+ return (
465
+ f'<div class="chart-card">'
466
+ f'<div class="chart-title">{escaped_title}</div>'
467
+ f"{bar_rows_html}"
468
+ f"</div>"
469
+ )
470
+
471
+
472
+ def _render_severity_chart(critical_count: int, minor_count: int) -> str:
473
+ """Return a severity breakdown chart card.
474
+
475
+ Args:
476
+ critical_count: Total critical (P0/P1) findings.
477
+ minor_count: Total minor (P2) findings.
478
+
479
+ Returns:
480
+ An HTML .chart-card string.
481
+ """
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)
489
+
490
+
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.
495
+
496
+ Args:
497
+ round_count: Total number of rounds in the run.
498
+ finding_count_by_round: Mapping of round number to distinct finding count.
499
+
500
+ Returns:
501
+ An HTML .chart-card string.
502
+ """
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
508
+ )
509
+ for r in range(1, round_count + 1)
510
+ )
511
+ return _render_chart_card("Findings by round", rows)
512
+
513
+
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.
516
+
517
+ Args:
518
+ round_count: Total number of rounds in the run.
519
+ tests_added_by_round: Mapping of round number to tests added count.
520
+
521
+ Returns:
522
+ An HTML .chart-card string.
523
+ """
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
529
+ )
530
+ for r in range(1, round_count + 1)
531
+ )
532
+ return _render_chart_card("Tests added per round", rows)
533
+
534
+
535
+ def _render_theme_chart(finding_count_by_theme: dict[str, int]) -> str:
536
+ """Return a findings-by-theme chart card.
537
+
538
+ Args:
539
+ finding_count_by_theme: Mapping of theme string to distinct finding count.
540
+
541
+ Returns:
542
+ An HTML .chart-card string.
543
+ """
544
+ sorted_themes = sorted(
545
+ finding_count_by_theme.items(), key=lambda pair: pair[1], reverse=True
546
+ )
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
551
+ )
552
+ return _render_chart_card("Findings by theme", rows)
553
+
554
+
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.
557
+
558
+ Args:
559
+ finding: The raw finding being described.
560
+ fix_by_round: Mapping of round number to fix record.
561
+
562
+ Returns:
563
+ An HTML .bug-fix string describing how the finding was resolved.
564
+ """
565
+ round_number = finding.round_number
566
+ fix_record = fix_by_round.get(round_number)
567
+
568
+ if fix_record is None:
569
+ return '<div class="bug-fix"><b>Fix:</b> resolved during convergence.</div>'
570
+
571
+ if fix_record.resolved_without_commit:
572
+ return (
573
+ f'<div class="bug-fix"><b>Fix:</b> already resolved at HEAD in round {round_number}; '
574
+ f"threads closed.</div>"
575
+ )
576
+
577
+ if not fix_record.new_sha:
578
+ return '<div class="bug-fix"><b>Fix:</b> resolved during convergence.</div>'
579
+
580
+ new_sha_short = fix_record.new_sha[:8]
581
+ 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>"
584
+ )
585
+
586
+
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.
594
+
595
+ 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'.
600
+
601
+ Returns:
602
+ An HTML string for one .bug-card.
603
+ """
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
611
+
612
+ fix_block = _render_fix_block(finding, fix_by_round)
613
+
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>'
627
+ f"</div>"
628
+ )
629
+
630
+
631
+ def _render_stat(label: str, stat_value: int) -> str:
632
+ """Return an HTML .stat block for the summary stats row.
633
+
634
+ Args:
635
+ label: The label displayed below the number.
636
+ stat_value: The numeric value to display.
637
+
638
+ Returns:
639
+ An HTML string for one .stat element.
640
+ """
641
+ escaped_label = html.escape(label)
642
+ 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>"
647
+ )
648
+
649
+
650
+ def _render_finding_cards(
651
+ findings: list[RawFinding],
652
+ fix_by_round: dict[int, FixRecord],
653
+ card_class: str,
654
+ ) -> str:
655
+ """Return an HTML .bugs container with one .bug-card per finding.
656
+
657
+ 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'.
661
+
662
+ Returns:
663
+ An HTML string for the .bugs container, or empty string when findings is empty.
664
+ """
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)
670
+ )
671
+ return f'<div class="bugs">{cards}</div>'
672
+
673
+
674
+ def render_report_html(
675
+ run_data: RunData, pr_metadata: PrMetadata, generated_date: str
676
+ ) -> str:
677
+ """Render the convergence insights report as an HTML string.
678
+
679
+ Args:
680
+ run_data: Aggregated metrics from the workflow journal and transcripts.
681
+ pr_metadata: Owner, repo, number, URL, final sha, and round count for the PR.
682
+ generated_date: ISO date string derived from the journal timestamp.
683
+
684
+ Returns:
685
+ A complete HTML document string.
686
+ """
687
+ pr_number = pr_metadata.number
688
+ owner = html.escape(pr_metadata.owner)
689
+ repo = html.escape(pr_metadata.repo)
690
+ final_sha_short = pr_metadata.final_sha[:8]
691
+ round_count = pr_metadata.round_count
692
+
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
+ head_html = HTML_HEAD_TEMPLATE.format(
700
+ pr_number=pr_number,
701
+ style_block=HTML_STYLE_BLOCK,
702
+ )
703
+
704
+ subtitle = (
705
+ f'<p class="subtitle">{owner}/{repo} · {total_findings} findings '
706
+ f"across {round_count} rounds · {html.escape(generated_date)}</p>"
707
+ )
708
+
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
+ )
763
+
764
+ critical_cards = _render_finding_cards(
765
+ run_data.all_critical_findings, run_data.fix_by_round, "crit"
766
+ )
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
+ footer = (
796
+ f"<footer>{owner}/{repo} · PR #{pr_number} · "
797
+ f"generated {html.escape(generated_date)} from the autoconverge run journal.</footer>"
798
+ )
799
+
800
+ body_content = (
801
+ f"<h1>PR #{pr_number} Convergence Insights</h1>"
802
+ 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}"
810
+ f"{footer}"
811
+ )
812
+
813
+ return (
814
+ f"{HTML_DOCTYPE}\n"
815
+ f"<html lang='en'>\n"
816
+ f"{head_html}\n"
817
+ f"<body>\n"
818
+ f'<div class="container">\n'
819
+ f"{body_content}\n"
820
+ f"</div>\n"
821
+ f"</body>\n"
822
+ f"</html>"
823
+ )
824
+
825
+
826
+ def _parse_pr_arg(pr_arg: str, err_stream: TextIO) -> tuple[str, str, int] | None:
827
+ """Parse an 'owner/repo#number' string into its three components.
828
+
829
+ Args:
830
+ pr_arg: A string in the form 'owner/repo#number'.
831
+ err_stream: Stream to write error messages to.
832
+
833
+ Returns:
834
+ A tuple of (owner, repo, pr_number_int), or None on parse failure.
835
+ """
836
+ match = re.fullmatch(r"([^/]+)/([^#]+)#(\d+)", pr_arg)
837
+ if not match:
838
+ err_stream.write(
839
+ f"Invalid --pr format: {pr_arg!r}. Expected owner/repo#number.\n"
840
+ )
841
+ return None
842
+ return match.group(1), match.group(2), int(match.group(3))
843
+
844
+
845
+ 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.
847
+
848
+ Args:
849
+ out_stream: Stream to write the output file path to on success.
850
+ err_stream: Stream to write error messages to.
851
+
852
+ Returns:
853
+ Exit code (0 on success, 1 on argument error).
854
+ """
855
+ argument_parser = argparse.ArgumentParser(
856
+ description="Render autoconverge convergence insights HTML."
857
+ )
858
+ argument_parser.add_argument(
859
+ "--journal", required=True, help="Path to wf_<runId>.json"
860
+ )
861
+ argument_parser.add_argument("--out", required=True, help="Output HTML file path")
862
+ argument_parser.add_argument("--pr", required=True, help="owner/repo#number")
863
+ argument_parser.add_argument("--final-sha", required=True, help="Final commit SHA")
864
+ argument_parser.add_argument(
865
+ "--rounds", required=True, type=int, help="Total round count"
866
+ )
867
+ argument_parser.add_argument(
868
+ "--repo", default=".", help="Path to the git repository root"
869
+ )
870
+
871
+ parsed_args = argument_parser.parse_args()
872
+
873
+ journal_path = Path(parsed_args.journal).resolve()
874
+ out_path = Path(parsed_args.out)
875
+ repo_path = Path(parsed_args.repo).resolve()
876
+
877
+ parsed_pr = _parse_pr_arg(parsed_args.pr, err_stream)
878
+ if parsed_pr is None:
879
+ return 1
880
+
881
+ owner, repo, pr_number = parsed_pr
882
+ pr_url = GITHUB_PR_URL_TEMPLATE.format(owner=owner, repo=repo, number=pr_number)
883
+
884
+ pr_metadata = PrMetadata(
885
+ owner=owner,
886
+ repo=repo,
887
+ number=pr_number,
888
+ url=pr_url,
889
+ final_sha=parsed_args.final_sha,
890
+ round_count=parsed_args.rounds,
891
+ )
892
+
893
+ run_data = load_run_data(journal_path, repo_path)
894
+ html_content = render_report_html(run_data, pr_metadata, run_data.generated_date)
895
+
896
+ out_path.parent.mkdir(parents=True, exist_ok=True)
897
+ out_path.write_text(html_content, encoding="utf-8")
898
+ out_stream.write(str(out_path) + "\n")
899
+ return 0
900
+
901
+
902
+ if __name__ == "__main__":
903
+ sys.exit(main())