claude-dev-env 1.80.0 → 1.81.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 (56) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  6. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  7. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  9. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  10. package/audit-rubrics/CLAUDE.md +3 -2
  11. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  12. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  13. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  14. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  15. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  16. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  17. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  18. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  19. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  20. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  21. package/docs/CODE_RULES.md +2 -2
  22. package/hooks/blocking/CLAUDE.md +2 -0
  23. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  24. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  25. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  26. package/hooks/blocking/code_rules_docstrings.py +93 -0
  27. package/hooks/blocking/code_rules_enforcer.py +54 -4
  28. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  29. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  30. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  31. package/hooks/blocking/code_rules_unused_imports.py +0 -3
  32. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  33. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  34. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  35. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  36. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  37. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  38. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  39. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
  40. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  41. package/hooks/hooks_constants/CLAUDE.md +2 -0
  42. package/hooks/hooks_constants/blocking_check_limits.py +9 -0
  43. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  44. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  45. package/package.json +1 -1
  46. package/rules/docstring-prose-matches-implementation.md +2 -1
  47. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  48. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  49. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  50. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  51. package/skills/autoconverge/SKILL.md +2 -2
  52. package/skills/autoconverge/reference/convergence.md +3 -3
  53. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  54. package/skills/autoconverge/workflow/converge.contract.test.mjs +2 -2
  55. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +245 -1
  56. package/skills/autoconverge/workflow/converge.mjs +129 -23
@@ -3,7 +3,8 @@
3
3
  Builds <context> and <scope> from CLI args; <bug_categories>,
4
4
  <rubric_reference>, and <constraints> come from the shared constants in
5
5
  skills_pr_loop_constants; <comment_posting> and <output_format> are built
6
- inline.
6
+ inline. <pr_description> carries the PR body text read from --pr-body-file,
7
+ and stays empty when that argument is absent or the file cannot be read.
7
8
 
8
9
  Usage:
9
10
  python scripts/build_audit_prompt.py --owner jl-cmd --repo claude-code-config --pr-number 422 --loop 1 --head-ref feat/branch --base-ref main --worktree-path <PATH> --run-temp-dir <PATH>
@@ -28,6 +29,38 @@ from skills_pr_loop_constants.path_resolver_constants import (
28
29
  )
29
30
 
30
31
 
32
+ def read_pr_body_text(pr_body_file: Path | None) -> str | None:
33
+ """Read the PR description body from a file when given and readable.
34
+
35
+ Args:
36
+ pr_body_file: Path to a file holding the PR description body, or None.
37
+
38
+ Returns:
39
+ The file's text when the path is given and readable, otherwise None.
40
+ """
41
+ if pr_body_file is None:
42
+ return None
43
+ try:
44
+ return pr_body_file.read_text(encoding="utf-8")
45
+ except OSError:
46
+ return None
47
+
48
+
49
+ def _append_pr_description(root: Element, pr_body_text: str | None) -> None:
50
+ """Append a <pr_description> element carrying the PR body text.
51
+
52
+ Args:
53
+ root: Root element the <pr_description> child is appended to.
54
+ pr_body_text: PR description body text, or None when unavailable.
55
+
56
+ Returns:
57
+ None.
58
+ """
59
+ pr_description = SubElement(root, "pr_description")
60
+ if pr_body_text:
61
+ pr_description.text = pr_body_text
62
+
63
+
31
64
  def build_audit_prompt_xml(
32
65
  *,
33
66
  owner: str,
@@ -38,6 +71,7 @@ def build_audit_prompt_xml(
38
71
  base_ref: str,
39
72
  worktree_path: Path,
40
73
  run_temp_dir: Path,
74
+ pr_body_text: str | None = None,
41
75
  ) -> Element:
42
76
  """Build the complete AUDIT spawn prompt XML.
43
77
 
@@ -50,6 +84,7 @@ def build_audit_prompt_xml(
50
84
  base_ref: Base branch ref.
51
85
  worktree_path: Path to the git worktree.
52
86
  run_temp_dir: Path to the run temp directory.
87
+ pr_body_text: PR description body text, or None when unavailable.
53
88
 
54
89
  Returns:
55
90
  Root <spawn_prompt> element.
@@ -72,6 +107,8 @@ def build_audit_prompt_xml(
72
107
  f"bugs, and anti-patterns. Work in {worktree_path.as_posix()}."
73
108
  )
74
109
 
110
+ _append_pr_description(root, pr_body_text)
111
+
75
112
  bug_categories = SubElement(root, "bug_categories")
76
113
  for each_category_id, each_category_label in ALL_AUDIT_CATEGORY_ENTRIES:
77
114
  cat_elem = SubElement(bug_categories, "category", {"id": each_category_id})
@@ -110,6 +147,7 @@ def emit_audit_prompt(
110
147
  base_ref: str,
111
148
  worktree_path: Path,
112
149
  run_temp_dir: Path,
150
+ pr_body_text: str | None = None,
113
151
  ) -> str:
114
152
  """Build and serialize the AUDIT spawn prompt to a pretty-printed XML string.
115
153
 
@@ -122,6 +160,7 @@ def emit_audit_prompt(
122
160
  base_ref: Base branch ref.
123
161
  worktree_path: Path to the git worktree.
124
162
  run_temp_dir: Path to the run temp directory.
163
+ pr_body_text: PR description body text, or None when unavailable.
125
164
 
126
165
  Returns:
127
166
  Pretty-printed XML string.
@@ -135,6 +174,7 @@ def emit_audit_prompt(
135
174
  base_ref=base_ref,
136
175
  worktree_path=worktree_path,
137
176
  run_temp_dir=run_temp_dir,
177
+ pr_body_text=pr_body_text,
138
178
  )
139
179
  return emit_pretty_xml(root)
140
180
 
@@ -157,6 +197,7 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
157
197
  parser.add_argument("--base-ref", required=True)
158
198
  parser.add_argument("--worktree-path", type=Path, required=True)
159
199
  parser.add_argument("--run-temp-dir", type=Path, required=True)
200
+ parser.add_argument("--pr-body-file", type=Path, default=None)
160
201
  return parser.parse_args(all_argv)
161
202
 
162
203
 
@@ -179,6 +220,7 @@ def main(all_arguments: list[str]) -> int:
179
220
  base_ref=arguments.base_ref,
180
221
  worktree_path=arguments.worktree_path,
181
222
  run_temp_dir=arguments.run_temp_dir,
223
+ pr_body_text=read_pr_body_text(arguments.pr_body_file),
182
224
  )
183
225
  sys.stdout.write(xml_output)
184
226
  return 0
@@ -7,7 +7,7 @@ Python package of named constants for the `pr-loop` shared scripts. All constant
7
7
  | File | Role |
8
8
  |---|---|
9
9
  | `__init__.py` | Package marker. |
10
- | `path_resolver_constants.py` | Path template strings and format constants: workspace directory naming, worktree directory name, diff patch filename pattern, outcome XML filename patterns, loop state filename, fix status values, audit constraint texts, audit category entries, fix execution steps, fix constraint texts, and XML serialization settings. |
10
+ | `path_resolver_constants.py` | Path template strings and format constants: workspace directory naming, worktree directory name, diff patch filename pattern, outcome XML filename patterns, fix status values, audit constraint texts, audit category entries, fix execution steps, and fix constraint texts. |
11
11
  | `preflight_constants.py` | Constants used by `preflight_worktree.py` for exit codes and output marker strings. |
12
12
 
13
13
  ## Usage
@@ -15,5 +15,5 @@ Python package of named constants for the `pr-loop` shared scripts. All constant
15
15
  Scripts import directly from the package:
16
16
 
17
17
  ```python
18
- from skills_pr_loop_constants.path_resolver_constants import LOOP_STATE_FILENAME
18
+ from skills_pr_loop_constants.path_resolver_constants import PER_PR_WORKSPACE_TEMPLATE
19
19
  ```
@@ -9,7 +9,6 @@ WORKTREE_DIRNAME = "worktree"
9
9
  DIFF_PATCH_TEMPLATE = "loop-{loop}.patch"
10
10
  OUTCOME_XML_TEMPLATE = ".bugteam-pr{number}-loop{loop}.outcomes.xml"
11
11
  FIX_OUTCOME_XML_TEMPLATE = ".bugteam-pr{number}-loop{loop}.fix-outcomes.xml"
12
- LOOP_STATE_FILENAME = "loop-state.json"
13
12
  SLUGIFY_SAFE_CHARS = re.compile(r"[^A-Za-z0-9._-]")
14
13
  SLUGIFY_REPLACEMENT = "-"
15
14
  MULTI_PR_SLUG_TEMPLATE = "{owner}-{repo}-pr-{number}"
@@ -47,6 +46,7 @@ ALL_AUDIT_CATEGORY_ENTRIES = [
47
46
  ("N", "Test-name scenario verifier"),
48
47
  ("O", "Docstring / fixture-prose vs implementation drift"),
49
48
  ("P", "Name / regex / word-list vs behavior-contract precision"),
49
+ ("Q", "Cross-surface claim consistency (terminology, PR-description claims, message-vs-guard)"),
50
50
  ]
51
51
 
52
52
  AUDIT_RUBRIC_REFERENCE_TEXT = (
@@ -77,9 +77,6 @@ ALL_FIX_CONSTRAINT_TEXTS = [
77
77
  "forward slashes (e.g. C:/Users/...), even on Windows.",
78
78
  ]
79
79
 
80
- XML_PRETTY_INDENT = " "
81
- XML_SERIALIZE_ENCODING = "unicode"
82
- XML_OUTPUT_ENCODING = "utf-8"
83
80
  ALL_PYTHON_ONEXC_VERSION = (3, 12)
84
81
 
85
82
  ALL_FINDING_BODY_ELEMENT_KEYS: tuple[str, ...] = (
@@ -1,4 +1,4 @@
1
- """Tests pinning build_audit_prompt's emitted A-P category taxonomy."""
1
+ """Tests pinning build_audit_prompt's emitted A-Q category taxonomy."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -18,7 +18,7 @@ from skills_pr_loop_constants.path_resolver_constants import (
18
18
  )
19
19
 
20
20
  _CATEGORY_RUBRICS_DIR = _SCRIPTS_DIR.parents[3] / "audit-rubrics" / "category_rubrics"
21
- _HEADING_PATTERN = re.compile(r"^# Category ([A-P]) — (.+)$")
21
+ _HEADING_PATTERN = re.compile(r"^# Category ([A-Q]) — (.+)$")
22
22
 
23
23
 
24
24
  def _load_build_audit_prompt() -> ModuleType:
@@ -84,12 +84,12 @@ def test_context_and_scope_render_paths_with_forward_slashes() -> None:
84
84
  assert "C:/Users/jon/AppData/Local/Temp/bugteam-pr-376/worktree" in scope.text
85
85
 
86
86
 
87
- def test_bug_categories_carry_ids_a_through_p_in_order() -> None:
87
+ def test_bug_categories_carry_ids_a_through_q_in_order() -> None:
88
88
  root = _build_audit_root()
89
89
  bug_categories = root.find("bug_categories")
90
90
  assert bug_categories is not None
91
91
  all_emitted_ids = [each_category.get("id") for each_category in bug_categories]
92
- all_expected_ids = list("ABCDEFGHIJKLMNOP")
92
+ all_expected_ids = list("ABCDEFGHIJKLMNOPQ")
93
93
  assert all_emitted_ids == all_expected_ids
94
94
 
95
95
 
@@ -161,3 +161,99 @@ def test_prompt_skeleton_sub_bucket_counts_match_rubric_rows() -> None:
161
161
  f"{each_letter}{each_walk_match.group(1)} but rubric has "
162
162
  f"{sub_bucket_row_count} rows"
163
163
  )
164
+
165
+
166
+ def test_bug_categories_include_category_q() -> None:
167
+ root = _build_audit_root()
168
+ bug_categories = root.find("bug_categories")
169
+ assert bug_categories is not None
170
+ label_by_id = {
171
+ each_category.get("id"): each_category.text for each_category in bug_categories
172
+ }
173
+ assert label_by_id["Q"] == (
174
+ "Cross-surface claim consistency "
175
+ "(terminology, PR-description claims, message-vs-guard)"
176
+ )
177
+
178
+
179
+ def test_pr_description_carries_body_text_when_supplied() -> None:
180
+ root = build_audit_prompt.build_audit_prompt_xml(
181
+ owner="jl-cmd",
182
+ repo="claude-code-config",
183
+ pr_number=422,
184
+ loop=1,
185
+ head_ref="feat/branch",
186
+ base_ref="main",
187
+ worktree_path=Path("/tmp/bugteam-pr-422/worktree"),
188
+ run_temp_dir=Path("/tmp/bugteam-pr-422"),
189
+ pr_body_text="## Summary\nCloses the gate.",
190
+ )
191
+ pr_description = root.find("pr_description")
192
+ assert pr_description is not None
193
+ assert pr_description.text == "## Summary\nCloses the gate."
194
+
195
+
196
+ def test_pr_description_empty_when_body_absent() -> None:
197
+ root = _build_audit_root()
198
+ pr_description = root.find("pr_description")
199
+ assert pr_description is not None
200
+ assert pr_description.text is None
201
+
202
+
203
+ def test_read_pr_body_text_returns_file_contents(tmp_path: Path) -> None:
204
+ body_file = tmp_path / "pr-body.md"
205
+ body_file.write_text("body from disk", encoding="utf-8")
206
+ assert build_audit_prompt.read_pr_body_text(body_file) == "body from disk"
207
+
208
+
209
+ def test_read_pr_body_text_returns_none_when_path_absent() -> None:
210
+ assert build_audit_prompt.read_pr_body_text(None) is None
211
+
212
+
213
+ def test_read_pr_body_text_returns_none_when_file_missing(tmp_path: Path) -> None:
214
+ missing_file = tmp_path / "does-not-exist.md"
215
+ assert build_audit_prompt.read_pr_body_text(missing_file) is None
216
+
217
+
218
+ def test_emit_audit_prompt_embeds_pr_body_text_in_pr_description() -> None:
219
+ xml_text = build_audit_prompt.emit_audit_prompt(
220
+ owner="jl-cmd",
221
+ repo="claude-code-config",
222
+ pr_number=422,
223
+ loop=1,
224
+ head_ref="feat/branch",
225
+ base_ref="main",
226
+ worktree_path=Path("/tmp/bugteam-pr-422/worktree"),
227
+ run_temp_dir=Path("/tmp/bugteam-pr-422"),
228
+ pr_body_text="body via emit",
229
+ )
230
+ assert "body via emit" in xml_text
231
+
232
+
233
+ def test_parse_arguments_defaults_pr_body_file_to_none() -> None:
234
+ arguments = build_audit_prompt.parse_arguments([
235
+ "--owner", "jl-cmd",
236
+ "--repo", "claude-code-config",
237
+ "--pr-number", "422",
238
+ "--loop", "1",
239
+ "--head-ref", "feat/branch",
240
+ "--base-ref", "main",
241
+ "--worktree-path", "/tmp/wt",
242
+ "--run-temp-dir", "/tmp/rt",
243
+ ])
244
+ assert arguments.pr_body_file is None
245
+
246
+
247
+ def test_parse_arguments_reads_pr_body_file_path() -> None:
248
+ arguments = build_audit_prompt.parse_arguments([
249
+ "--owner", "jl-cmd",
250
+ "--repo", "claude-code-config",
251
+ "--pr-number", "422",
252
+ "--loop", "1",
253
+ "--head-ref", "feat/branch",
254
+ "--base-ref", "main",
255
+ "--worktree-path", "/tmp/wt",
256
+ "--run-temp-dir", "/tmp/rt",
257
+ "--pr-body-file", "/tmp/body.md",
258
+ ])
259
+ assert arguments.pr_body_file == Path("/tmp/body.md")
@@ -312,10 +312,10 @@ matches.
312
312
  (hooks/rules that block those violation classes at Write/Edit time), resolves
313
313
  any bot threads with a deferral note, and reports the deferral in
314
314
  `standardsNote`.
315
- - **Copilot gate:** request a Copilot review, poll up to three times; findings
315
+ - **Copilot gate:** request a Copilot review, poll up to the configured cap; findings
316
316
  route back into Converge. When Copilot is down or out of quota — it posts an
317
317
  out-of-usage notice (the requester hit their quota) on the HEAD, or surfaces no
318
- review at all after the cap — the gate logs a notice and the run marks the PR
318
+ review at all after the configured cap — the gate logs a notice and the run marks the PR
319
319
  ready with the Copilot gate bypassed. `copilotNote` records the bypass.
320
320
  - **Convergence check:** `check_convergence.py` is the authoritative gate; on a
321
321
  full pass the workflow marks `draft=false`.
@@ -79,11 +79,11 @@ tracks CONVERGE passes only and is never the cap.
79
79
  **COPILOT** gate:
80
80
 
81
81
  - Request a Copilot review on HEAD (skipping a duplicate request), then poll up
82
- to three times, 360 seconds apart.
82
+ to the configured cap, 360 seconds apart.
83
83
  - Copilot findings → fix them and return to CONVERGE on the new HEAD.
84
84
  - Copilot clean or approved → move to the convergence check.
85
- - Copilot down or out of quota (an out-of-usage notice, or no review after three
86
- polls) → log a notice and move to the convergence check with the Copilot gate
85
+ - Copilot down or out of quota (an out-of-usage notice, or no review after the
86
+ configured cap) → log a notice and move to the convergence check with the Copilot gate
87
87
  bypassed.
88
88
 
89
89
  **Convergence check**:
@@ -47,7 +47,7 @@ skill still runs teardown (revoke permissions, final report).
47
47
  Bugbot gate is bypassed.
48
48
  - **Copilot down or out of quota** — when Copilot posts an out-of-usage notice on
49
49
  the current HEAD (the user who requested the review reached their quota limit)
50
- rather than a code review, or surfaces no review at all after three polls, the
50
+ rather than a code review, or surfaces no review at all after the configured cap, the
51
51
  Copilot gate returns `down: true`. The run logs a notice, runs the convergence
52
52
  check with `--copilot-down` (the Copilot review gate and the
53
53
  pending-requested-reviews gate bypassed), and marks the PR ready. `copilotNote`
@@ -385,8 +385,8 @@ test('spawnStandardsFollowUp reports whether a hardening PR opened on every path
385
385
  const body = lensPromptBody('spawnStandardsFollowUp');
386
386
  const falseReturns = body.match(/hardeningPrOpened:\s*false/g) || [];
387
387
  assert.ok(
388
- falseReturns.length >= 2,
389
- 'expected both skip paths (no hardening staged, verify failed) to return hardeningPrOpened:false',
388
+ falseReturns.length >= 3,
389
+ 'expected every skip path (hardening PR already opened, no hardening staged, verify failed) to return hardeningPrOpened:false',
390
390
  );
391
391
  assert.match(
392
392
  body,
@@ -129,6 +129,24 @@ test('a Copilot no-show after the poll cap returns a down result rather than a b
129
129
  );
130
130
  });
131
131
 
132
+ test('the Copilot gate accepts a COMMENTED review with no inline findings as a clean pass', () => {
133
+ const copilotPrompt = functionBody('runCopilotGate');
134
+ assert.match(
135
+ copilotPrompt,
136
+ /COMMENTED with no inline findings/,
137
+ 'expected the clean-pass branch to accept a COMMENTED review with no inline findings, matching the canonical ALL_COPILOT_CLEAN_REVIEW_STATES rule where a Copilot COMMENTED review is a clean state',
138
+ );
139
+ });
140
+
141
+ test('the Copilot gate polls long enough to catch a normal-latency Copilot review', () => {
142
+ const budgetMatch = convergeSource.match(/copilotMaxPolls:\s*(\d+)/);
143
+ assert.notEqual(budgetMatch, null, 'expected copilotMaxPolls in CONFIG');
144
+ assert.ok(
145
+ Number(budgetMatch[1]) >= 6,
146
+ `expected copilotMaxPolls >= 6 (>= ~36 min at 360s per attempt) so a normal-latency Copilot review lands before the no-show fallback triggers, got ${budgetMatch[1]}`,
147
+ );
148
+ });
149
+
132
150
  test('runConvergenceCheck wires the --copilot-down flag from the copilotDown context', () => {
133
151
  const checkConvergenceBody = functionBody('runConvergenceCheck');
134
152
  assert.match(
@@ -295,7 +313,7 @@ test('a copilotDisabled run reaches FINALIZE with --copilot-down', () => {
295
313
  const bypassBlock = convergeSource.slice(bypassStart, bypassStart + 800);
296
314
  assert.match(bypassBlock, /copilotDown = true/, 'expected the bypass to set copilotDown');
297
315
  assert.match(bypassBlock, /phase = 'FINALIZE'/, 'expected the bypass to advance to FINALIZE');
298
- const convergenceCheckBody = functionBody('resumeConvergenceCheckAgent');
316
+ const convergenceCheckBody = functionBody('runConvergenceCheck');
299
317
  assert.match(
300
318
  convergenceCheckBody,
301
319
  /context\.copilotDown \? ' --copilot-down' : ''/,
@@ -303,3 +321,229 @@ test('a copilotDisabled run reaches FINALIZE with --copilot-down', () => {
303
321
  );
304
322
  });
305
323
 
324
+ function loadStandardsFollowUpDecision() {
325
+ return new Function(
326
+ `${functionBody('shouldOpenStandardsFollowUp')}\n` +
327
+ 'return shouldOpenStandardsFollowUp;',
328
+ )();
329
+ }
330
+
331
+ test('the deferred follow-up create runs exactly once across a three-round standards run', () => {
332
+ const shouldOpenStandardsFollowUp = loadStandardsFollowUpDecision();
333
+ let alreadyOpened = false;
334
+ let createCount = 0;
335
+ for (let round = 0; round < 3; round += 1) {
336
+ if (shouldOpenStandardsFollowUp(alreadyOpened)) {
337
+ createCount += 1;
338
+ alreadyOpened = true;
339
+ }
340
+ }
341
+ assert.equal(
342
+ createCount,
343
+ 1,
344
+ 'expected the deferred follow-up issue and hardening PR to be created exactly once across a multi-round run',
345
+ );
346
+ });
347
+
348
+ test('shouldOpenStandardsFollowUp opens on the first standards-only round and skips afterward', () => {
349
+ const shouldOpenStandardsFollowUp = loadStandardsFollowUpDecision();
350
+ assert.equal(shouldOpenStandardsFollowUp(false), true);
351
+ assert.equal(shouldOpenStandardsFollowUp(true), false);
352
+ });
353
+
354
+ test('the run seeds the standards follow-up flags to false so the first standards-only round opens the follow-up', () => {
355
+ assert.match(
356
+ convergeSource,
357
+ /let hasStandardsFollowUpFiled = false/,
358
+ 'expected a run-scoped hasStandardsFollowUpFiled flag seeded false',
359
+ );
360
+ assert.match(
361
+ convergeSource,
362
+ /let wasStandardsHardeningPrOpened = false/,
363
+ 'expected a run-scoped wasStandardsHardeningPrOpened flag seeded false',
364
+ );
365
+ });
366
+
367
+ test('openStandardsFollowUpOnce gates spawnStandardsFollowUp behind the run-once flag and remembers the outcome', () => {
368
+ const onceBody = extractCallableSource('openStandardsFollowUpOnce');
369
+ assert.match(
370
+ onceBody,
371
+ /shouldOpenStandardsFollowUp\(hasStandardsFollowUpFiled\)/,
372
+ 'expected the helper to consult the run-once decision on the current flag',
373
+ );
374
+ assert.match(
375
+ onceBody,
376
+ /await spawnStandardsFollowUp\(head, findings, sourceLabel, wasStandardsHardeningPrOpened\)/,
377
+ 'expected the helper to pass the remembered hardening state into the spawn so an already-opened PR is never re-opened',
378
+ );
379
+ assert.match(
380
+ onceBody,
381
+ /hasStandardsFollowUpFiled = standardsOutcome\?\.followUpIssueFiled === true/,
382
+ 'expected the helper to latch the flag only when the follow-up issue was actually filed so a transient failure retries on a later round',
383
+ );
384
+ assert.match(
385
+ onceBody,
386
+ /wasStandardsHardeningPrOpened = wasStandardsHardeningPrOpened \|\| standardsOutcome\?\.hardeningPrOpened === true/,
387
+ 'expected the hardening guard to latch the moment a hardening PR opens and stay latched across rounds so a later issue-filing retry never re-opens it',
388
+ );
389
+ assert.match(
390
+ onceBody,
391
+ /return wasStandardsHardeningPrOpened/,
392
+ 'expected the skip path to return the cached hardening outcome',
393
+ );
394
+ });
395
+
396
+ test('both standards-deferral call sites route the create through openStandardsFollowUpOnce', () => {
397
+ const onceCalls = convergeSource.match(/await openStandardsFollowUpOnce\(/g) || [];
398
+ assert.equal(
399
+ onceCalls.length,
400
+ 2,
401
+ 'expected the converge-round and copilot standards call sites to both defer to openStandardsFollowUpOnce',
402
+ );
403
+ const directCreates = convergeSource.match(/await spawnStandardsFollowUp\(/g) || [];
404
+ assert.equal(
405
+ directCreates.length,
406
+ 1,
407
+ 'expected spawnStandardsFollowUp to be invoked once from openStandardsFollowUpOnce, never directly at a call site',
408
+ );
409
+ });
410
+
411
+ function extractCallableSource(functionName) {
412
+ const asyncStart = convergeSource.indexOf(`async function ${functionName}(`);
413
+ const plainStart = convergeSource.indexOf(`function ${functionName}(`);
414
+ const declarationStart = asyncStart !== -1 ? asyncStart : plainStart;
415
+ assert.notEqual(declarationStart, -1, `expected ${functionName} to exist`);
416
+ const bodyStart = convergeSource.indexOf('{', declarationStart);
417
+ let depth = 0;
418
+ let index = bodyStart;
419
+ for (; index < convergeSource.length; index += 1) {
420
+ const character = convergeSource[index];
421
+ if (character === '{') {
422
+ depth += 1;
423
+ } else if (character === '}') {
424
+ depth -= 1;
425
+ if (depth === 0) {
426
+ index += 1;
427
+ break;
428
+ }
429
+ }
430
+ }
431
+ return convergeSource.slice(declarationStart, index);
432
+ }
433
+
434
+ function loadStandardsFollowUpRuntime(recordedCalls, standardsEditResult) {
435
+ const runtimeSource =
436
+ 'let hasStandardsFollowUpFiled = false;\n' +
437
+ 'let wasStandardsHardeningPrOpened = false;\n' +
438
+ "let standardsFollowUpIssueUrl = '';\n" +
439
+ 'async function runCodeEditorTask(taskName, context) {\n' +
440
+ ' recordedCalls.push({ task: taskName, context });\n' +
441
+ " return taskName === 'standards-edit' ? standardsEditResult : {};\n" +
442
+ '}\n' +
443
+ 'async function runVerifierTask() {\n' +
444
+ ' return { passed: true };\n' +
445
+ '}\n' +
446
+ 'function verdictPassed() {\n' +
447
+ ' return true;\n' +
448
+ '}\n' +
449
+ 'function log() {}\n' +
450
+ `${extractCallableSource('collectFindingThreadIds')}\n` +
451
+ `${extractCallableSource('findingsCarryThreads')}\n` +
452
+ `${extractCallableSource('shouldOpenStandardsFollowUp')}\n` +
453
+ `${extractCallableSource('spawnStandardsFollowUp')}\n` +
454
+ `${extractCallableSource('resolveStandardsThreadsForBatch')}\n` +
455
+ `${extractCallableSource('openStandardsFollowUpOnce')}\n` +
456
+ 'return {\n' +
457
+ ' openStandardsFollowUpOnce,\n' +
458
+ ' guards: () => ({ hasStandardsFollowUpFiled, wasStandardsHardeningPrOpened, standardsFollowUpIssueUrl }),\n' +
459
+ '};';
460
+ return new Function('recordedCalls', 'standardsEditResult', runtimeSource)(
461
+ recordedCalls,
462
+ standardsEditResult,
463
+ );
464
+ }
465
+
466
+ test('a second standards-only round never re-opens a hardening PR after the first round opened one but failed to file the issue', async () => {
467
+ const recordedCalls = [];
468
+ const issueFailedHardeningStaged = {
469
+ issueUrl: '',
470
+ hardeningEdited: true,
471
+ hardeningRepoPath: '/tmp/hardening',
472
+ hardeningBranch: 'harden-standards',
473
+ };
474
+ const runtime = loadStandardsFollowUpRuntime(recordedCalls, issueFailedHardeningStaged);
475
+
476
+ const firstRoundHardeningPr = await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1 }], 'converge-round');
477
+ const secondRoundHardeningPr = await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1 }], 'copilot');
478
+
479
+ const hardeningCommitCalls = recordedCalls.filter((call) => call.task === 'hardening-commit').length;
480
+ assert.equal(
481
+ hardeningCommitCalls,
482
+ 1,
483
+ 'expected the hardening PR to be committed exactly once even when the follow-up issue filing must retry on the second round',
484
+ );
485
+ assert.equal(firstRoundHardeningPr, true, 'expected the first round to open the hardening PR');
486
+ assert.equal(secondRoundHardeningPr, true, 'expected the second round to report the hardening PR as opened for this run');
487
+ assert.equal(
488
+ runtime.guards().wasStandardsHardeningPrOpened,
489
+ true,
490
+ 'expected the hardening guard to stay latched across rounds',
491
+ );
492
+ assert.equal(
493
+ runtime.guards().hasStandardsFollowUpFiled,
494
+ false,
495
+ 'expected the issue guard to stay clear so the filing keeps retrying',
496
+ );
497
+ });
498
+
499
+ test('a later standards-only round resolves its own review threads after the follow-up issue was already filed', async () => {
500
+ const recordedCalls = [];
501
+ const issueFiledNoHardening = {
502
+ issueUrl: 'https://github.com/jl-cmd/claude-code-config/issues/900',
503
+ hardeningEdited: false,
504
+ hardeningRepoPath: '',
505
+ hardeningBranch: '',
506
+ };
507
+ const runtime = loadStandardsFollowUpRuntime(recordedCalls, issueFiledNoHardening);
508
+
509
+ await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1, replyToCommentId: null }], 'converge-round');
510
+ await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'b.py', line: 2, replyToCommentId: 42 }], 'copilot');
511
+
512
+ const standardsEditCalls = recordedCalls.filter((call) => call.task === 'standards-edit');
513
+ assert.equal(standardsEditCalls.length, 1, 'expected the follow-up issue to be filed exactly once across the run');
514
+
515
+ const resolveCalls = recordedCalls.filter((call) => call.task === 'standards-resolve-threads');
516
+ assert.equal(resolveCalls.length, 1, 'expected the reuse-path round to resolve its own batch review threads');
517
+ assert.deepEqual(
518
+ resolveCalls[0].context.findings,
519
+ [{ file: 'b.py', line: 2, replyToCommentId: 42 }],
520
+ 'expected the resolve step to receive the reuse-path batch findings so their threads get replied-to and resolved',
521
+ );
522
+ assert.equal(
523
+ resolveCalls[0].context.issueUrl,
524
+ 'https://github.com/jl-cmd/claude-code-config/issues/900',
525
+ 'expected the resolve step to reference the already-filed follow-up issue in its inline reply',
526
+ );
527
+ });
528
+
529
+ test('a reuse-path standards round carrying no review threads spawns no thread-resolution agent', async () => {
530
+ const recordedCalls = [];
531
+ const issueFiledNoHardening = {
532
+ issueUrl: 'https://github.com/jl-cmd/claude-code-config/issues/901',
533
+ hardeningEdited: false,
534
+ hardeningRepoPath: '',
535
+ hardeningBranch: '',
536
+ };
537
+ const runtime = loadStandardsFollowUpRuntime(recordedCalls, issueFiledNoHardening);
538
+
539
+ await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1, replyToCommentId: null }], 'converge-round');
540
+ await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'b.py', line: 2, replyToCommentId: null }], 'copilot');
541
+
542
+ const resolveCalls = recordedCalls.filter((call) => call.task === 'standards-resolve-threads');
543
+ assert.equal(
544
+ resolveCalls.length,
545
+ 0,
546
+ 'expected no thread-resolution agent when the reuse-path batch of in-memory findings carries no review threads',
547
+ );
548
+ });
549
+