claude-dev-env 1.59.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 (62) hide show
  1. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  2. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  3. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  4. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  6. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  7. package/hooks/blocking/code_rules_enforcer.py +30 -15
  8. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  9. package/hooks/blocking/config/__init__.py +5 -0
  10. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  11. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  12. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  13. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  14. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  15. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  18. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  19. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  20. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  21. package/hooks/blocking/verification_verdict_store.py +446 -0
  22. package/hooks/blocking/verified_commit_gate.py +523 -0
  23. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  24. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  25. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  26. package/hooks/hooks.json +43 -1
  27. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  28. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  29. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  30. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  31. package/package.json +1 -1
  32. package/rules/file-global-constants.md +7 -1
  33. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  34. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  35. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  36. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  37. package/skills/autoconverge/SKILL.md +54 -17
  38. package/skills/autoconverge/reference/closing-report.md +59 -17
  39. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  40. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  41. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  43. package/skills/autoconverge/workflow/converge.mjs +128 -6
  44. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  45. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  46. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  47. package/skills/autoconverge/workflow/render_report.py +488 -397
  48. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  49. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  50. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  51. package/skills/pr-converge/reference/per-tick.md +28 -8
  52. package/skills/rebase/SKILL.md +2 -4
  53. package/system-prompts/software-engineer.xml +2 -6
  54. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  55. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  56. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  57. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  58. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  59. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  60. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  61. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  62. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -0,0 +1,263 @@
1
+ """Tests for preflight_worktree against real git working trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from types import ModuleType
10
+
11
+ import pytest
12
+
13
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
14
+ if str(_SCRIPTS_DIR) not in sys.path:
15
+ sys.path.insert(0, str(_SCRIPTS_DIR))
16
+
17
+
18
+ def _load_preflight() -> ModuleType:
19
+ module_path = _SCRIPTS_DIR / "preflight_worktree.py"
20
+ spec = importlib.util.spec_from_file_location("preflight_worktree", module_path)
21
+ assert spec is not None
22
+ assert spec.loader is not None
23
+ module = importlib.util.module_from_spec(spec)
24
+ sys.modules["preflight_worktree"] = module
25
+ spec.loader.exec_module(module)
26
+ return module
27
+
28
+
29
+ preflight = _load_preflight()
30
+
31
+ GIT_TIMEOUT = 30
32
+ CLAUDE_REMOTE = "https://github.com/jl-cmd/claude-code-config.git"
33
+ GIT_CEILING_DIRECTORIES = "GIT_CEILING_DIRECTORIES"
34
+
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def _isolate_git_from_ancestor_repos(
38
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
39
+ ) -> None:
40
+ monkeypatch.setenv(GIT_CEILING_DIRECTORIES, str(tmp_path))
41
+
42
+
43
+ def _git(repo_dir: Path, *args: str) -> None:
44
+ subprocess.run(
45
+ ["git", "-C", str(repo_dir), *args],
46
+ check=True,
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=GIT_TIMEOUT,
50
+ )
51
+
52
+
53
+ def _init_repo(repo_dir: Path, origin_url: str | None) -> Path:
54
+ repo_dir.mkdir(parents=True, exist_ok=True)
55
+ subprocess.run(
56
+ ["git", "init", "-q", str(repo_dir)],
57
+ check=True,
58
+ capture_output=True,
59
+ text=True,
60
+ timeout=GIT_TIMEOUT,
61
+ )
62
+ if origin_url is not None:
63
+ _git(repo_dir, "remote", "add", "origin", origin_url)
64
+ return repo_dir
65
+
66
+
67
+ @pytest.mark.parametrize(
68
+ ("remote_url", "expected_owner", "expected_repo"),
69
+ [
70
+ (
71
+ "https://github.com/jl-cmd/claude-code-config.git",
72
+ "jl-cmd",
73
+ "claude-code-config",
74
+ ),
75
+ (
76
+ "https://github.com/jl-cmd/claude-code-config",
77
+ "jl-cmd",
78
+ "claude-code-config",
79
+ ),
80
+ ("git@github.com:JonEcho/llm-settings.git", "jonecho", "llm-settings"),
81
+ (
82
+ "ssh://git@github.com/jl-cmd/Claude-Code-Config",
83
+ "jl-cmd",
84
+ "claude-code-config",
85
+ ),
86
+ ("https://github.com/jl-cmd/repo/", "jl-cmd", "repo"),
87
+ ],
88
+ )
89
+ def test_parse_repo_identity_accepts_every_github_remote_form(
90
+ remote_url: str, expected_owner: str, expected_repo: str
91
+ ) -> None:
92
+ identity = preflight.parse_repo_identity(remote_url)
93
+ assert identity is not None
94
+ assert identity.owner == expected_owner
95
+ assert identity.repo == expected_repo
96
+
97
+
98
+ @pytest.mark.parametrize(
99
+ "remote_url",
100
+ ["https://gitlab.com/foo/bar.git", "", "not-a-url"],
101
+ )
102
+ def test_parse_repo_identity_rejects_non_github_remotes(remote_url: str) -> None:
103
+ assert preflight.parse_repo_identity(remote_url) is None
104
+
105
+
106
+ def test_classify_same_repo_when_origin_matches_pr(tmp_path: Path) -> None:
107
+ repo_dir = _init_repo(tmp_path / "session", CLAUDE_REMOTE)
108
+ pr_identity = preflight.RepoIdentity("jl-cmd", "claude-code-config")
109
+ verdict = preflight.classify_environment(repo_dir, pr_identity)
110
+ assert verdict.outcome == preflight.OUTCOME_SAME_REPO
111
+ assert verdict.cwd_identity == pr_identity
112
+ assert verdict.has_healthy_worktree_machinery is True
113
+
114
+
115
+ def test_classify_different_repo_when_origin_is_another_github_repo(
116
+ tmp_path: Path,
117
+ ) -> None:
118
+ repo_dir = _init_repo(
119
+ tmp_path / "session", "https://github.com/jonecho/llm-settings.git"
120
+ )
121
+ pr_identity = preflight.RepoIdentity("jl-cmd", "claude-code-config")
122
+ verdict = preflight.classify_environment(repo_dir, pr_identity)
123
+ assert verdict.outcome == preflight.OUTCOME_DIFFERENT_REPO
124
+ assert verdict.cwd_identity == preflight.RepoIdentity("jonecho", "llm-settings")
125
+
126
+
127
+ def test_classify_different_repo_when_origin_is_non_github(tmp_path: Path) -> None:
128
+ repo_dir = _init_repo(tmp_path / "session", "https://gitlab.com/foo/bar.git")
129
+ pr_identity = preflight.RepoIdentity("jl-cmd", "claude-code-config")
130
+ verdict = preflight.classify_environment(repo_dir, pr_identity)
131
+ assert verdict.outcome == preflight.OUTCOME_DIFFERENT_REPO
132
+ assert verdict.cwd_identity is None
133
+
134
+
135
+ def test_classify_re_rooted_when_origin_is_absent(tmp_path: Path) -> None:
136
+ repo_dir = _init_repo(tmp_path / "session", None)
137
+ pr_identity = preflight.RepoIdentity("jl-cmd", "claude-code-config")
138
+ verdict = preflight.classify_environment(repo_dir, pr_identity)
139
+ assert verdict.outcome == preflight.OUTCOME_RE_ROOTED
140
+
141
+
142
+ def test_classify_re_rooted_when_directory_is_not_a_work_tree(
143
+ tmp_path: Path,
144
+ ) -> None:
145
+ plain_dir = tmp_path / "home"
146
+ plain_dir.mkdir()
147
+ pr_identity = preflight.RepoIdentity("jl-cmd", "claude-code-config")
148
+ verdict = preflight.classify_environment(plain_dir, pr_identity)
149
+ assert verdict.outcome == preflight.OUTCOME_RE_ROOTED
150
+ assert verdict.has_healthy_worktree_machinery is False
151
+
152
+
153
+ @pytest.mark.parametrize(
154
+ ("outcome", "is_healthy", "mode", "expected_exit"),
155
+ [
156
+ ("same_repo", True, "strict", 0),
157
+ ("same_repo", True, "classify", 0),
158
+ ("same_repo", False, "strict", 1),
159
+ ("same_repo", False, "classify", 1),
160
+ ("different_repo", True, "strict", 1),
161
+ ("different_repo", True, "classify", 0),
162
+ ("re_rooted", False, "strict", 1),
163
+ ("re_rooted", False, "classify", 1),
164
+ ],
165
+ )
166
+ def test_decide_exit_code_matrix(
167
+ outcome: str, is_healthy: bool, mode: str, expected_exit: int
168
+ ) -> None:
169
+ verdict = preflight.PreflightVerdict(
170
+ outcome=outcome,
171
+ cwd_identity=None,
172
+ has_healthy_worktree_machinery=is_healthy,
173
+ )
174
+ assert preflight.decide_exit_code(verdict, mode) == expected_exit
175
+
176
+
177
+ def test_build_report_lines_marks_outcome_and_routes_cross_repo() -> None:
178
+ verdict = preflight.PreflightVerdict(
179
+ outcome=preflight.OUTCOME_DIFFERENT_REPO,
180
+ cwd_identity=preflight.RepoIdentity("jonecho", "llm-settings"),
181
+ has_healthy_worktree_machinery=True,
182
+ )
183
+ pr_identity = preflight.RepoIdentity("jl-cmd", "claude-code-config")
184
+ classify_lines = preflight.build_report_lines(
185
+ verdict, "classify", Path("/tmp/x"), pr_identity
186
+ )
187
+ assert classify_lines[0] == "PREFLIGHT_OUTCOME=different_repo"
188
+ assert any("ROUTE" in line for line in classify_lines)
189
+ strict_lines = preflight.build_report_lines(
190
+ verdict, "strict", Path("/tmp/x"), pr_identity
191
+ )
192
+ assert any("ABORT" in line for line in strict_lines)
193
+
194
+
195
+ def test_build_report_lines_aborts_on_broken_worktree_machinery() -> None:
196
+ verdict = preflight.PreflightVerdict(
197
+ outcome=preflight.OUTCOME_SAME_REPO,
198
+ cwd_identity=preflight.RepoIdentity("jl-cmd", "claude-code-config"),
199
+ has_healthy_worktree_machinery=False,
200
+ )
201
+ pr_identity = preflight.RepoIdentity("jl-cmd", "claude-code-config")
202
+ lines = preflight.build_report_lines(verdict, "strict", Path("/tmp/x"), pr_identity)
203
+ assert lines[0] == "PREFLIGHT_OUTCOME=same_repo"
204
+ assert any("git worktree prune" in line for line in lines)
205
+
206
+
207
+ def test_main_strict_passes_in_matching_repo(
208
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
209
+ ) -> None:
210
+ repo_dir = _init_repo(tmp_path / "session", CLAUDE_REMOTE)
211
+ monkeypatch.chdir(repo_dir)
212
+ exit_code = preflight.main(
213
+ ["--owner", "jl-cmd", "--repo", "claude-code-config", "--mode", "strict"]
214
+ )
215
+ captured = capsys.readouterr()
216
+ assert exit_code == preflight.EXIT_PREFLIGHT_OK
217
+ assert "PREFLIGHT_OUTCOME=same_repo" in captured.out
218
+
219
+
220
+ def test_main_strict_aborts_when_session_rooted_in_other_repo(
221
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
222
+ ) -> None:
223
+ repo_dir = _init_repo(
224
+ tmp_path / "session", "https://github.com/jonecho/llm-settings.git"
225
+ )
226
+ monkeypatch.chdir(repo_dir)
227
+ exit_code = preflight.main(
228
+ ["--owner", "jl-cmd", "--repo", "claude-code-config", "--mode", "strict"]
229
+ )
230
+ captured = capsys.readouterr()
231
+ assert exit_code == preflight.EXIT_PREFLIGHT_ABORT
232
+ assert "PREFLIGHT_OUTCOME=different_repo" in captured.out
233
+ assert "ABORT" in captured.out
234
+
235
+
236
+ def test_main_classify_routes_cross_repo_without_abort(
237
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
238
+ ) -> None:
239
+ repo_dir = _init_repo(
240
+ tmp_path / "session", "https://github.com/jonecho/llm-settings.git"
241
+ )
242
+ monkeypatch.chdir(repo_dir)
243
+ exit_code = preflight.main(
244
+ ["--owner", "jl-cmd", "--repo", "claude-code-config", "--mode", "classify"]
245
+ )
246
+ captured = capsys.readouterr()
247
+ assert exit_code == preflight.EXIT_PREFLIGHT_OK
248
+ assert "PREFLIGHT_OUTCOME=different_repo" in captured.out
249
+ assert "ROUTE" in captured.out
250
+
251
+
252
+ def test_main_classify_aborts_when_re_rooted(
253
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
254
+ ) -> None:
255
+ plain_dir = tmp_path / "home"
256
+ plain_dir.mkdir()
257
+ monkeypatch.chdir(plain_dir)
258
+ exit_code = preflight.main(
259
+ ["--owner", "jl-cmd", "--repo", "claude-code-config", "--mode", "classify"]
260
+ )
261
+ captured = capsys.readouterr()
262
+ assert exit_code == preflight.EXIT_PREFLIGHT_ABORT
263
+ assert "PREFLIGHT_OUTCOME=re_rooted" in captured.out
@@ -43,7 +43,17 @@ PR's owner.
43
43
  ready, mark it draft first (`gh pr ready <n> --repo <o>/<r> --undo`) so the
44
44
  loop owns the ready transition.
45
45
 
46
- 3. **Grant project permissions.**
46
+ 3. **Verify the worktree is the PR's repo (strict pre-flight).** Run
47
+ `python "$HOME/.claude/skills/_shared/pr-loop/scripts/preflight_worktree.py" --owner <owner> --repo <repo> --mode strict`.
48
+ It confirms the working directory is a checkout of the PR's own repo and
49
+ that `git worktree` machinery is healthy, so `EnterWorktree` can create and
50
+ enter the branch worktree. A non-zero exit prints a `PREFLIGHT_OUTCOME` line
51
+ and an `ABORT` line: report that line and stop. Autoconverge runs inside the
52
+ PR's own repo, so a working directory rooted in a different repo (for
53
+ example, `claude-code-config` while the PR lives in `llm-settings`) or in no
54
+ git checkout at all cannot continue.
55
+
56
+ 4. **Grant project permissions.**
47
57
  `python "$HOME/.claude/skills/bugteam/scripts/grant_project_claude_permissions.py"`
48
58
 
49
59
  ## Run the workflow
@@ -87,23 +97,44 @@ round records nothing resumable and replays dirty.
87
97
  here; this step builds the one-shot closing report and the seam (marker comment +
88
98
  gist URL) a future live-dashboard reuses.
89
99
 
90
- a. **Resolve the journal path.** Glob
100
+ a. **Resolve a seed journal path.** Glob
91
101
  `~/.claude/projects/**/workflows/wf_<runId>.json` (where `runId` is the run id
92
- the `Workflow` result returned) and take the match.
102
+ the `Workflow` result returned) and take the match. It seeds the merge, which
103
+ finds every other autoconverge journal for the same PR.
93
104
 
94
- b. **Build the report.**
105
+ b. **Merge the PR's runs and build the summary prompt.**
106
+ ```
107
+ python "<skill>/workflow/aggregate_runs.py" \
108
+ --journal "<seed journal>" \
109
+ --pr <owner>/<repo>#<n> \
110
+ --work-dir "$CLAUDE_JOB_DIR/tmp/autoconverge-agg-<prNumber>" \
111
+ --out-prompt "$CLAUDE_JOB_DIR/tmp/autoconverge-summary-prompt-<prNumber>.txt" \
112
+ [--standards-note "<standardsNote>"] [--copilot-note "<copilotNote>"]
113
+ ```
114
+ It prints a JSON line with `combinedJournal`, `roundCount`, `finalSha`, and
115
+ `findingCount`. Pass `--standards-note`/`--copilot-note` only when the workflow
116
+ returned those notes.
117
+
118
+ c. **Write the summary.** Spawn a `convergence-summary` agent (a `general-purpose`
119
+ subagent) on the text of the prompt file from step b. The agent answers with the
120
+ `prProblem`/`prFix`/`problemScenes`/`fixScenes`/`verdictLine`/`issueClasses` JSON
121
+ object; write that object to
122
+ `$CLAUDE_JOB_DIR/tmp/autoconverge-summary-<prNumber>.json`.
123
+
124
+ d. **Build the report.**
95
125
  ```
96
126
  python "<skill>/workflow/render_report.py" \
97
- --journal "<journal>" \
127
+ --journal "<combinedJournal>" \
128
+ --summary-file "<summary json>" \
98
129
  --out "$CLAUDE_JOB_DIR/tmp/autoconverge-report-<prNumber>.html" \
99
130
  --pr <owner>/<repo>#<n> \
100
131
  --final-sha <finalSha> \
101
- --rounds <rounds> \
102
- --repo <worktree>
132
+ --rounds <roundCount>
103
133
  ```
104
- Capture the output path from stdout.
134
+ Use the `combinedJournal`, `finalSha`, and `roundCount` from step b. Capture the
135
+ output path from stdout.
105
136
 
106
- c. **Publish as a secret gist** by reusing `doc-gist` (do not reimplement gist
137
+ e. **Publish as a secret gist** by reusing `doc-gist` (do not reimplement gist
107
138
  creation):
108
139
  ```
109
140
  python "$HOME/.claude/skills/doc-gist/scripts/gist_upload.py" \
@@ -114,17 +145,21 @@ round records nothing resumable and replays dirty.
114
145
  Capture the htmlpreview URL from stdout. The gist is secret by default; pass
115
146
  no public flag.
116
147
 
117
- d. **Post one idempotent PR comment.** List the PR's issue comments; if one
148
+ f. **Post one idempotent PR comment.** List the PR's issue comments; if one
118
149
  carries the marker `<!-- autoconverge-report -->`, edit it in place, otherwise
119
150
  create a new one. The body begins with `<!-- autoconverge-report -->`, then
120
- the htmlpreview link, headline counts (findings by severity, rounds, tests
121
- added), and the full finding list as `file:line P# title` grouped by
122
- severity. Honor the gh-body-file rule: write a BOM-free temp file and pass
123
- `--body-file` to `gh issue comment`/`gh issue comment edit`, or use the
151
+ the htmlpreview link, then a plain-language summary that mirrors the report:
152
+ lead with the one-sentence `verdictLine`; then the plain Problem and Fix
153
+ sentences (`prProblem`, `prFix`); then the issue-class list one bullet per
154
+ class as `plainName (×count, status)`. Place the raw finding list as
155
+ `file:line — P# — title` inside a collapsed
156
+ `<details><summary>Raw findings</summary>…</details>` block so the comment leads
157
+ with the human summary. Honor the gh-body-file rule: write a BOM-free temp file
158
+ and pass `--body-file` to `gh issue comment`/`gh issue comment edit`, or use the
124
159
  GitHub MCP `add_issue_comment` tool (body as a structured parameter, no
125
160
  `--body` flag).
126
161
 
127
- e. **Open the report in Chrome.**
162
+ g. **Open the report in Chrome.**
128
163
  ```
129
164
  Start-Process chrome -ArgumentList '--new-window', '<report path>'
130
165
  ```
@@ -187,6 +222,8 @@ suite (`python -m pytest`) and keep scratch work in ephemeral temp dirs.
187
222
 
188
223
  - `SKILL.md` — this hub.
189
224
  - `workflow/converge.mjs` — the convergence workflow script.
190
- - `workflow/render_report.py` — builds the closing convergence insights HTML report.
191
- - `workflow/autoconverge_report_constants/`named constants for the report builder.
225
+ - `workflow/aggregate_runs.py` — merges every autoconverge journal for a PR into one journal and returns its deduped findings, fix summaries, round count, and final SHA.
226
+ - `workflow/convergence_summary.py`builds the convergence-summary agent prompt over a PR's merged findings.
227
+ - `workflow/render_report.py` — builds the closing convergence insights HTML report, taking the summary from `--summary-file`.
228
+ - `workflow/autoconverge_report_constants/` — named constants for the report builder and the summary prompt.
192
229
  - `reference/` — convergence definition, stop conditions, gotchas, closing report.
@@ -4,35 +4,77 @@ When an autoconverge run converges (the workflow returns `converged: true`), the
4
4
 
5
5
  ## Data source
6
6
 
7
- The report reads two file types written by the workflow during the run:
7
+ A PR's full convergence is the union of every autoconverge run it took. `aggregate_runs.py` finds all of them and merges them into one run tree the report reads:
8
8
 
9
- - **Run journal** — `~/.claude/projects/**/workflows/wf_<runId>.json` — the PR args, round log lines, `workflowProgress` array (one entry per agent step), and the final result.
10
- - **Agent transcripts** — `~/.claude/projects/**/subagents/workflows/<runId>/agent-<agentId>.jsonl` — one file per agent; each line is a JSON object; the renderer extracts the last `StructuredOutput` tool_use from each file.
9
+ - **Run journals** — `~/.claude/projects/**/workflows/wf_<runId>.json` — each carries the PR args, round log lines, a `workflowProgress` array (one entry per agent step), and the run result. `aggregate_runs.py` picks every journal whose `args` owner/repo/prNumber match the PR and whose `workflowName` is `autoconverge`, orders them by timestamp, and concatenates their `workflowProgress` into one merged journal under the work directory.
10
+ - **Agent transcripts** — `~/.claude/projects/**/subagents/workflows/<runId>/agent-<agentId>.jsonl` — one file per agent; each line is a JSON object. The merge copies every referenced transcript into the merged run tree, so the report reader extracts the last `StructuredOutput` tool_use from each exactly as it would for a single run.
11
11
 
12
- `converge.mjs` is not modified. The report is a pure reader of files the workflow already writes.
12
+ The merged journal's round count is the number of `resolve-head` steps across every run, and its final SHA is the latest run's result SHA.
13
13
 
14
- ## Building the report
14
+ ## Convergence summary
15
+
16
+ The plain-language account of what the PR does and what the run caught is written at teardown, over the merged findings. `convergence_summary.py` builds the agent prompt from the deduped findings and the per-round fix summaries `aggregate_runs.py` returns; the teardown spawns a `convergence-summary` agent on that prompt; the agent's JSON answer is the summary. `render_report.py --summary-file <path>` reads that JSON and draws from it, so the summary needs no journal transcript of its own.
17
+
18
+ The summary carries the structured visual data the report draws:
19
+
20
+ - `prProblem` and `prFix` — one plain sentence each: the problem this PR solves and the change that solves it.
21
+ - `problemScenes` and `fixScenes` — short cause→effect scenes. Each scene has a `trigger`, an optional `condition`, a `result`, and a one-line `caption`. A fix scene mirrors a problem scene with a good result.
22
+ - `verdictLine` — one factual sentence: converged, the distinct issue-class count, all fixed or deferred.
23
+ - `issueClasses` — one entry per distinct kind of problem, each with a `plainName`, a `count`, a `severity`, a `category`, a `status`, a plain `cause`, a `medium` (`terminal`, `code`, or `text`) that tells the report how to draw the before/after panels, and the literal `beforeLines` and `afterLines` shown in those panels. There is one class per kind, however many kinds there are — kinds are never folded together or dropped to hit a number.
24
+
25
+ Python owns the counts, severity, and file:line; the summary agent owns the plain-language narrative. The report draws the same HTML each time it runs over the same merged journal and summary.
15
26
 
16
- ```
17
- python "<skill>/workflow/render_report.py" \
18
- --journal "<journal path>" \
19
- --out "<output path>.html" \
20
- --pr <owner>/<repo>#<n> \
21
- --final-sha <sha> \
22
- --rounds <N> \
23
- --repo <worktree path>
24
- ```
27
+ ## What the report draws
25
28
 
26
- The script reads the journal and transcripts, computes aggregated metrics (findings by severity, round, and theme; fix commits; tests added per round), and writes a self-contained HTML report. It prints the output path to stdout and exits 0 on success.
29
+ The report body, in order:
30
+
31
+ 1. A title and a subtitle (owner/repo, component when it can be derived, finding count over round count, date).
32
+ 2. A **verdict banner** — a check circle, the `verdictLine`, and a Python-computed line giving the fix-commit count and the final short SHA.
33
+ 3. **What this PR does** — a problem card and a fix card. Each card draws its scenes as a trigger chip → optional condition → result, each with its caption. When a scene list is empty, the card falls back to drawing `prProblem` or `prFix` as a single caption line.
34
+ 4. **What was caught — and how it looked** — a lead line stating the bug-class count, the total finding count, the round count, and the fix-commit count, then one block per issue class: a name heading with a finding-count chip, a before panel and an after panel drawn in the style its `medium` names (a dark terminal window, a light code panel, or plain lines), and a cause line that states the plain cause plus a muted note with severity, category, count, and status. When both line lists are empty, the block drops to its heading and cause line.
35
+ 5. A footer (owner/repo, PR, findings, rounds, fix commits, generated date).
36
+
37
+ A collapsed `<details>` raw-findings appendix (`file:line — P# — title`) closes the body and keeps the engineer grounding one click away.
38
+
39
+ When the summary is absent, the report falls back to a minimal layout: the title, the subtitle, a plain run-stats note, and the collapsed raw-findings appendix.
40
+
41
+ ## Building the report
27
42
 
28
- All aggregation is deterministic: `generated_date` comes from the journal `timestamp`, not the system clock, so the same inputs always produce the same HTML.
43
+ Three steps run at teardown, each a separate process:
44
+
45
+ 1. **Merge the runs and build the summary prompt.**
46
+ ```
47
+ python "<skill>/workflow/aggregate_runs.py" \
48
+ --journal "<seed journal path>" \
49
+ --pr <owner>/<repo>#<n> \
50
+ --work-dir "<work dir>" \
51
+ --out-prompt "<prompt path>.txt" \
52
+ [--standards-note "<note>"] [--copilot-note "<note>"]
53
+ ```
54
+ `--journal` is any one of the PR's journals — the seed that locates the rest. The script prints a JSON line carrying `combinedJournal`, `roundCount`, `finalSha`, and `findingCount`.
55
+
56
+ 2. **Write the summary.** Spawn a `convergence-summary` agent on the prompt file's text. Its JSON answer — the `prProblem`/`prFix`/`problemScenes`/`fixScenes`/`verdictLine`/`issueClasses` object — goes to a `.json` file.
57
+
58
+ 3. **Draw the report.**
59
+ ```
60
+ python "<skill>/workflow/render_report.py" \
61
+ --journal "<combinedJournal>" \
62
+ --summary-file "<summary json path>" \
63
+ --out "<output path>.html" \
64
+ --pr <owner>/<repo>#<n> \
65
+ --final-sha <finalSha> \
66
+ --rounds <roundCount>
67
+ ```
68
+ The script reads the merged journal and transcripts, counts findings by severity and fix commits, takes the summary from `--summary-file`, draws the visual report, and writes a self-contained HTML file. It prints the output path to stdout and exits 0 on success.
69
+
70
+ Counting is deterministic: `generated_date` comes from the journal `timestamp`, not the system clock, so the same merged journal and summary always produce the same HTML.
29
71
 
30
72
  ## Publishing
31
73
 
32
74
  After rendering, the main session:
33
75
 
34
76
  1. **Uploads the HTML as a secret gist** using `doc-gist/scripts/gist_upload.py --no-open`. Captures the htmlpreview URL from stdout.
35
- 2. **Posts one idempotent PR comment** marked with `<!-- autoconverge-report -->`. If a comment with that marker already exists on the PR, it is edited in place; otherwise a new comment is created. The comment body has the gist URL and a summary of findings by severity, rounds, and tests added, followed by the full finding list grouped by severity (`file:line — P# — title`). Write the body to a BOM-free temp file and pass `--body-file` to `gh issue comment` (never `--body`), or use the GitHub MCP `add_issue_comment` tool.
77
+ 2. **Posts one idempotent PR comment** marked with `<!-- autoconverge-report -->`. If a comment with that marker already exists on the PR, it is edited in place; otherwise a new comment is created. The body leads with the gist URL, then the one-sentence `verdictLine`, then the plain Problem and Fix sentences (`prProblem`, `prFix`), then the issue-class list — one bullet per class as `plainName (×count, status)` — and closes with the full finding list (`file:line — P# — title`) inside a collapsed `<details>` block. Write the body to a BOM-free temp file and pass `--body-file` to `gh issue comment` (never `--body`), or use the GitHub MCP `add_issue_comment` tool.
36
78
  3. **Opens the report** with `Start-Process chrome -ArgumentList '--new-window', '<report path>'`. A missing Chrome does not abort teardown.
37
79
 
38
80
  ## Live-dashboard seam