claude-dev-env 1.80.0 → 1.82.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 (64) 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 +3 -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/conventional_pr_title_gate.py +444 -0
  33. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  34. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  35. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  36. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  37. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  38. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  40. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
  41. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  42. package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
  43. package/hooks/hooks.json +5 -0
  44. package/hooks/hooks_constants/CLAUDE.md +3 -0
  45. package/hooks/hooks_constants/blocking_check_limits.py +9 -0
  46. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  47. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
  48. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  49. package/package.json +1 -1
  50. package/rules/docstring-prose-matches-implementation.md +2 -1
  51. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  52. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  53. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  54. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  55. package/skills/autoconverge/SKILL.md +92 -7
  56. package/skills/autoconverge/reference/convergence.md +3 -3
  57. package/skills/autoconverge/reference/gotchas.md +5 -5
  58. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  59. package/skills/autoconverge/workflow/converge.contract.test.mjs +161 -29
  60. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +342 -1
  61. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
  62. package/skills/autoconverge/workflow/converge.mjs +178 -32
  63. package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
  64. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
@@ -0,0 +1,28 @@
1
+ """Constants for the unanchored command-dispatch meta-gate."""
2
+
3
+ import re
4
+
5
+ COMMAND_DISPATCH_PATH_MARKER: str = "hooks/blocking"
6
+
7
+ COMMAND_DISPATCH_LITERAL_PATTERN: re.Pattern[str] = re.compile(
8
+ r"\b(?:gh|git|npm|npx|node|python3?|pwsh|powershell|docker|kubectl|pip|cargo"
9
+ r"|yarn|pnpm)\\s[+*]"
10
+ )
11
+
12
+ COMMAND_KEY_ACCESS_PATTERN: re.Pattern[str] = re.compile(
13
+ r"""(?:\[\s*|\.get\(\s*)["']command["']"""
14
+ )
15
+
16
+ FIRST_TOKEN_TOKENIZATION_PATTERN: re.Pattern[str] = re.compile(
17
+ r"shlex\.split|\.split\("
18
+ )
19
+
20
+ ALL_REGEX_START_ANCHOR_TOKENS: tuple[str, ...] = ("^", "\\A")
21
+
22
+ COMMAND_DISPATCH_MESSAGE_SUFFIX: str = (
23
+ "matches a multi-word command as a substring - anchor the pattern to the "
24
+ "start of the command (^) or tokenize the first word (shlex.split) so a "
25
+ "command like 'echo gh pr create' is not matched"
26
+ )
27
+
28
+ MAX_COMMAND_DISPATCH_ISSUES: int = 20
@@ -0,0 +1,58 @@
1
+ """Configuration constants for the conventional_pr_title_gate PreToolUse hook."""
2
+
3
+ import re
4
+
5
+ ALL_GH_EXECUTABLE_BASENAMES: frozenset[str] = frozenset({"gh", "gh.exe"})
6
+ PR_SUBCOMMAND_TOKEN: str = "pr"
7
+ ALL_PR_TITLE_SUBCOMMAND_VERBS: frozenset[str] = frozenset({"create", "edit"})
8
+ GH_PR_SUBCOMMAND_MINIMUM_TOKEN_COUNT: int = 3
9
+
10
+ BASH_TOOL_NAME: str = "Bash"
11
+
12
+ TITLE_LONG_FLAG: str = "--title"
13
+ TITLE_SHORT_FLAG: str = "-t"
14
+ REPO_LONG_FLAG: str = "--repo"
15
+ REPO_SHORT_FLAG: str = "-R"
16
+
17
+ WORKFLOWS_DIRECTORY_RELATIVE_PATH: str = ".github/workflows"
18
+ ALL_WORKFLOW_FILE_GLOB_PATTERNS: tuple[str, ...] = ("*.yml", "*.yaml")
19
+
20
+ ALL_SEMANTIC_TITLE_CI_MARKERS: tuple[str, ...] = (
21
+ "semantic-pull-request",
22
+ "action-semantic-pull-request",
23
+ "semantic_pull_request",
24
+ )
25
+
26
+ ALL_CONVENTIONAL_COMMIT_TYPES: tuple[str, ...] = (
27
+ "feat",
28
+ "fix",
29
+ "chore",
30
+ "docs",
31
+ "refactor",
32
+ "perf",
33
+ "ci",
34
+ "style",
35
+ "test",
36
+ "build",
37
+ "revert",
38
+ )
39
+
40
+ CONVENTIONAL_COMMIT_TITLE_PATTERN: re.Pattern[str] = re.compile(
41
+ r"^(?:" + "|".join(ALL_CONVENTIONAL_COMMIT_TYPES) + r")(?:\([^)]+\))?!?: .+"
42
+ )
43
+
44
+ SEMANTIC_ACTION_TYPES_INPUT_PATTERN: re.Pattern[str] = re.compile(r"^\s*types\s*:")
45
+
46
+ SEMANTIC_ACTION_FLOW_TYPES_INPUT_PATTERN: re.Pattern[str] = re.compile(r"[{,]\s*types\s*:")
47
+
48
+ YAML_LIST_ITEM_PREFIX: str = "- "
49
+
50
+ CORRECTIVE_MESSAGE: str = (
51
+ "BLOCKED [conventional-pr-title]: this repository's CI validates PR titles "
52
+ "against Conventional Commits, and the --title value here does not match. "
53
+ "Required shape: type(scope)!: description, where the scope and the "
54
+ "breaking-change marker (!) are optional. Allowed types: "
55
+ f"{', '.join(ALL_CONVENTIONAL_COMMIT_TYPES)}.\n\n"
56
+ "Example: feat(hooks): add the conventional PR title gate\n\n"
57
+ "Fix the --title value and retry."
58
+ )
@@ -0,0 +1,54 @@
1
+ """Configuration constants for the JavaScript convention checks in code_rules_enforcer."""
2
+
3
+ import re
4
+
5
+ ALL_JAVASCRIPT_BANNED_IDENTIFIERS: frozenset[str] = frozenset(
6
+ {
7
+ "result",
8
+ "data",
9
+ "output",
10
+ "response",
11
+ "value",
12
+ "item",
13
+ "temp",
14
+ "ctx",
15
+ "cfg",
16
+ "msg",
17
+ "btn",
18
+ "idx",
19
+ "cnt",
20
+ "tmp",
21
+ "elem",
22
+ "val",
23
+ }
24
+ )
25
+
26
+ _JAVASCRIPT_NEGATION_OPERAND: str = (
27
+ r"[A-Za-z_$][\w$]*"
28
+ r"(?:\s*\.\s*[A-Za-z_$][\w$]*|\s*\((?:[^()]|\([^()]*\))*\))*"
29
+ )
30
+
31
+ JAVASCRIPT_BOOLEAN_DECLARATION_PATTERN: re.Pattern[str] = re.compile(
32
+ r"\b(?:const|let|var)\s+(?P<name>[A-Za-z_$][\w$]*)\s*=\s*"
33
+ r"(?:(?:true|false)\b|!\s*" + _JAVASCRIPT_NEGATION_OPERAND + r"\s*(?:;|$))"
34
+ )
35
+
36
+ JAVASCRIPT_BOOLEAN_JSDOC_PARAMETER_PATTERN: re.Pattern[str] = re.compile(
37
+ r"@param\s*\{\s*boolean\s*\}\s+(?P<name>[A-Za-z_$][\w$]*)"
38
+ )
39
+
40
+ JAVASCRIPT_DECLARATION_NAME_PATTERN: re.Pattern[str] = re.compile(
41
+ r"\b(?:const|let|var)\s+(?P<name>[A-Za-z_$][\w$]*)\s*="
42
+ )
43
+
44
+ JAVASCRIPT_BOOLEAN_PREFIX_PATTERN: re.Pattern[str] = re.compile(
45
+ r"^(?:is|has|should|can|was|did)(?:[A-Z0-9_]|$)"
46
+ )
47
+
48
+ BOOLEAN_PREFIX_GUIDANCE: str = "prefix with is/has/should/can/was/did"
49
+
50
+ SINGLE_CHARACTER_NAME_LENGTH: int = 1
51
+
52
+ MAX_JAVASCRIPT_BOOLEAN_NAMING_ISSUES: int = 20
53
+
54
+ MAX_JAVASCRIPT_BANNED_IDENTIFIER_ISSUES: int = 20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.80.0",
3
+ "version": "1.82.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@
6
6
 
7
7
  When a docstring enumerates the behaviors a body applies, the enumeration covers every behavior the body applies. A reader trusts the list to be complete: an item the code applies but the prose omits is a silent gap that misleads every future reader and reviewer.
8
8
 
9
- The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Nine more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_length_constant_superlative_vs_exact_gate` covers a length-constant module whose docstring describes an integer `*_LENGTH` constant with a superlative or range word (`the longest color string the swatch accepts`) while the only code consuming the constant compares `len(...)` against it with `==`/`!=` — an exact-length gate that rejects every other length — and never with an ordered operator; the check scans the constant module's package tree (its own directory, or the parent package when the module sits in a `config/` subdirectory), so the mismatching consumer may sit in a sibling module. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. `check_docstring_returns_plural_cardinality` covers a `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) while the returned dict literal holds exactly one key in that family (`sheen_mid`) — the drift where a single-key family carries a plural noun, so the prose claims a cardinality of two or more that the dict does not hold. `check_docstring_args_single_line_scope_vs_span` covers an `Args:` entry whose prose scopes a finding to a single named line (`only when its block-anchor line is among the changed lines`) while the body builds a `range(...)` span over the finding's source lines and scopes it through a span-intersection scoper — the drift where the Args entry claims a narrower single-line scope than the span-intersection body applies, so an edit touching any non-anchor line of the span still blocks. `check_docstring_cardinal_count_matches_constant_family` covers a docstring that states a cardinal count of an outcome family (`Covers the four outcome branches: ...`) and lists those members, while the module references more members of the same `UPPER_SNAKE` constant family than the count names (`OUTCOME_OFFENDER_UNREADABLE` is imported and exercised, yet the summary stops at four) — the drift where a summary keeps the old count after the code grows another branch; this gate runs on test modules as well as production modules. `check_docstring_raises_unraisable_largezipfile` covers a `Raises:` clause that names `zipfile.LargeZipFile` while the function opens its `zipfile.ZipFile` writer in a write mode (`w`/`a`/`x`) with `allowZip64` left at its default of True — `zipfile` raises `LargeZipFile` only when an entry needs ZIP64 and `allowZip64` is False, so a writer that allows ZIP64 documents an exception the body cannot produce. `check_docstring_no_network_claim_with_metadata_access` covers a function docstring promising a code path returns `without touching the network` (or a sibling no-network phrase) while the body calls a path-metadata method (`is_file`, `is_dir`, `exists`, `stat`, `lstat`) — on a network share each metadata call is a round-trip over the wire, so a cache-hit path the docstring swore avoids the network still pays a stat on every call; reword the claim to state the path is stat-checked on every call, or short-circuit to the cached path before the share is touched. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and the broader module-responsibility paragraph outside the user-facing-text-scope slice the checklist below names — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the ten gated slices.
9
+ The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Ten more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_names_absent_type_checking_gate` covers a module or function docstring that names a `TYPE_CHECKING` gate-detection step, or a `type-checking-gate` helper family, while no identifier in the module's code carries the `type_checking` marker — the drift where the prose points a reader at a gate the body never performs; drop the `TYPE_CHECKING` gate wording, or add the detection the prose describes. `check_docstring_length_constant_superlative_vs_exact_gate` covers a length-constant module whose docstring describes an integer `*_LENGTH` constant with a superlative or range word (`the longest color string the swatch accepts`) while the only code consuming the constant compares `len(...)` against it with `==`/`!=` — an exact-length gate that rejects every other length — and never with an ordered operator; the check scans the constant module's package tree (its own directory, or the parent package when the module sits in a `config/` subdirectory), so the mismatching consumer may sit in a sibling module. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. `check_docstring_returns_plural_cardinality` covers a `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) while the returned dict literal holds exactly one key in that family (`sheen_mid`) — the drift where a single-key family carries a plural noun, so the prose claims a cardinality of two or more that the dict does not hold. `check_docstring_args_single_line_scope_vs_span` covers an `Args:` entry whose prose scopes a finding to a single named line (`only when its block-anchor line is among the changed lines`) while the body builds a `range(...)` span over the finding's source lines and scopes it through a span-intersection scoper — the drift where the Args entry claims a narrower single-line scope than the span-intersection body applies, so an edit touching any non-anchor line of the span still blocks. `check_docstring_cardinal_count_matches_constant_family` covers a docstring that states a cardinal count of an outcome family (`Covers the four outcome branches: ...`) and lists those members, while the module references more members of the same `UPPER_SNAKE` constant family than the count names (`OUTCOME_OFFENDER_UNREADABLE` is imported and exercised, yet the summary stops at four) — the drift where a summary keeps the old count after the code grows another branch; this gate runs on test modules as well as production modules. `check_docstring_raises_unraisable_largezipfile` covers a `Raises:` clause that names `zipfile.LargeZipFile` while the function opens its `zipfile.ZipFile` writer in a write mode (`w`/`a`/`x`) with `allowZip64` left at its default of True — `zipfile` raises `LargeZipFile` only when an entry needs ZIP64 and `allowZip64` is False, so a writer that allows ZIP64 documents an exception the body cannot produce. `check_docstring_no_network_claim_with_metadata_access` covers a function docstring promising a code path returns `without touching the network` (or a sibling no-network phrase) while the body calls a path-metadata method (`is_file`, `is_dir`, `exists`, `stat`, `lstat`) — on a network share each metadata call is a round-trip over the wire, so a cache-hit path the docstring swore avoids the network still pays a stat on every call; reword the claim to state the path is stat-checked on every call, or short-circuit to the cached path before the share is touched. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and the broader module-responsibility paragraph outside the user-facing-text-scope slice the checklist below names — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the eleven gated slices.
10
10
 
11
11
  ## What to check before you write the docstring
12
12
 
@@ -28,6 +28,7 @@ Read the body and the docstring side by side:
28
28
  - **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
29
29
  - **Exclusion-clause distinguisher.** A docstring sentence that says a named category of input "are not" / "is not" the thing the function flags (`plain logging, screenshot, or method-on-local calls inside a branch are not dispatch steps`) keys the exclusion to the same axis the body's classification keys on. When the body decides on one axis (a call sits in an `If.test` guard versus a plain statement) but the prose excludes on a different axis (the call's receiver shape — a method on a local), the exclusion clause names a category the body still flags: a guarded method-on-local call is flagged even though the prose lists method-on-local calls as excluded. Read the body's actual branch condition, then state the exclusion on that same axis (`plain (unguarded) calls inside a branch body are not dispatch steps`), so every member the prose excludes is a member the body also excludes.
30
30
  - **Companion-doc ordering and content claims.** A `SKILL.md` (or sibling `.md`) sentence that names a produced artifact and claims its order (`sorted`, `alphabetical`, `in sorted order`) or its content (`the at-risk names`, `just the current set`) matches the producer function's docstring and body for that same artifact. A producer that builds the artifact by merging stored names with new names and appending — preserving file order, not re-sorting the union — leaves a doc that still says `sorted` drifted on both counts: the order claim is wrong, and the content claim hides the merged-in prior entries. When the producer's ordering or union changes, the same change updates the companion doc. The two move together in one commit, even when the producer edit does not touch the `.md` file.
31
+ - **TYPE_CHECKING gate claim vs code.** A docstring that names a `TYPE_CHECKING` gate-detection step, or a `type-checking-gate` helper family, matches a module whose code handles TYPE_CHECKING. When no identifier in the body carries the `type_checking` marker — no `TYPE_CHECKING` load, import alias, attribute, or helper name — the prose points a reader at a gate the module never performs. State what the module does, or add the detection the prose describes. The `check_docstring_names_absent_type_checking_gate` gate blocks this drift at Write/Edit time, and covers hook infrastructure, where the import-scan gates that carry this drift class live.
31
32
 
32
33
  When the body changes the set of behaviors it applies, the same edit updates the prose enumeration. The two move together in one commit.
33
34
 
@@ -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")
@@ -131,7 +131,10 @@ no agent spawned. The workflow runs in the background and notifies this session
131
131
  on completion. Watch live progress with `/workflows`.
132
132
 
133
133
  The workflow returns
134
- `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote }`.
134
+ `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote, deferredPrs }`.
135
+ `deferredPrs` is the list of draft environment-hardening PRs the standards-deferral
136
+ path opened this run, each as `{ owner, repo, prNumber }` — the seed the
137
+ [self-closing loop](#self-closing-loop-converge-the-deferred-prs) converges next.
135
138
 
136
139
  ## Budget-aware round boundaries
137
140
 
@@ -312,10 +315,10 @@ matches.
312
315
  (hooks/rules that block those violation classes at Write/Edit time), resolves
313
316
  any bot threads with a deferral note, and reports the deferral in
314
317
  `standardsNote`.
315
- - **Copilot gate:** request a Copilot review, poll up to three times; findings
318
+ - **Copilot gate:** request a Copilot review, poll up to the configured cap; findings
316
319
  route back into Converge. When Copilot is down or out of quota — it posts an
317
320
  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
321
+ review at all after the configured cap — the gate logs a notice and the run marks the PR
319
322
  ready with the Copilot gate bypassed. `copilotNote` records the bypass.
320
323
  - **Convergence check:** `check_convergence.py` is the authoritative gate; on a
321
324
  full pass the workflow marks `draft=false`.
@@ -387,10 +390,13 @@ checked out in. The workflow runs in the background and notifies this session on
387
390
  completion; watch live progress with `/workflows`, where each PR's child run
388
391
  appears under its own group.
389
392
 
390
- The workflow returns `{ converged, prCount, convergedCount, results, blocker }`,
391
- where `results` is one record per PR carrying
392
- `{ owner, repo, prNumber, converged, rounds, finalSha, blocker }`. The top-level
393
- `converged` is true only when every PR converged.
393
+ The workflow returns
394
+ `{ converged, prCount, convergedCount, results, allDeferredPrs, blocker }`, where
395
+ `results` is one record per PR carrying
396
+ `{ owner, repo, prNumber, converged, rounds, finalSha, blocker, deferredPrs }`.
397
+ Each record's `deferredPrs` is that PR's own list of draft hardening PRs, and
398
+ `allDeferredPrs` is every record's `deferredPrs` flattened into one list. The
399
+ top-level `converged` is true only when every PR converged.
394
400
 
395
401
  ### Multi-PR teardown (on workflow completion)
396
402
 
@@ -403,6 +409,85 @@ permissions once per repository after every PR's teardown. Then print one summar
403
409
  report — a line per PR as
404
410
  `#<prNumber>: <converged | blocked> — rounds <N>, final <finalSha>[, blocker <blocker>]`.
405
411
 
412
+ ## Self-closing loop: converge the deferred PRs
413
+
414
+ Every run leaves work behind. When a round holds only code-standard findings, the
415
+ standards-deferral path opens a draft environment-hardening PR and reports it in
416
+ `deferredPrs`. That PR is itself a draft that needs to reach ready, and its own
417
+ convergence can defer more standard findings, opening more hardening PRs. The
418
+ self-closing loop drives that chain to the ground: after teardown, the
419
+ orchestrator (this session, which launched the workflow) converges the deferred
420
+ PRs, then the PRs their runs defer, and so on until a generation opens none.
421
+
422
+ This loop runs by default at the end of every autoconverge run — single-PR and
423
+ multi-PR alike. It stops only when a generation's deferred-PR list comes back
424
+ empty.
425
+
426
+ ### Seed the loop
427
+
428
+ Collect the first generation of deferred PRs from the run that just finished:
429
+
430
+ - A single-PR run seeds from its `deferredPrs`.
431
+ - A multi-PR run seeds from its `allDeferredPrs`.
432
+
433
+ When the seed list is empty, the loop is already done — the run deferred nothing,
434
+ so there is nothing to converge. Report that and stop.
435
+
436
+ ### Each generation
437
+
438
+ Given a non-empty list of deferred PRs `{ owner, repo, prNumber }` (a generation
439
+ may span more than one repository — a hardening PR lands in whichever repo owns
440
+ the surface that blocks the deferred class, so `JonEcho/llm-settings` for hooks
441
+ and `jl-cmd/claude-code-config` for rules and skills both appear):
442
+
443
+ 1. **Check out each deferred PR.** Run the
444
+ [Multi-PR pre-flight](#multi-pr-pre-flight-main-session) once per deferred PR.
445
+ Because a deferred PR can live in a repo the first run never touched, first
446
+ find a local checkout of that PR's repo; when none exists, clone it under the
447
+ session temp dir, then add the per-PR worktree on the PR's head ref. Drop any
448
+ PR whose strict pre-flight fails rather than stopping the whole generation.
449
+ Grant project permissions once per repository the generation spans.
450
+ 2. **Converge the generation.** Launch `workflow/converge_multi.mjs` with one
451
+ entry per checked-out deferred PR, exactly as the
452
+ [multi-PR launch](#launch-the-multi-pr-workflow) describes.
453
+ 3. **Tear down.** Run the [multi-PR teardown](#multi-pr-teardown-on-workflow-completion)
454
+ over the generation's `results`, and revoke project permissions once per
455
+ repository.
456
+ 4. **Take the next seed.** The generation's `allDeferredPrs` is the next
457
+ generation's seed list. When it is empty, the loop is done.
458
+
459
+ Repeat from step 1 with each new seed. The depth is unbounded: the loop keeps
460
+ opening generations until one converges every deferred PR without deferring
461
+ anything new.
462
+
463
+ ### Report each generation
464
+
465
+ Log one line as each generation finishes, so a watcher sees the chain close:
466
+
467
+ ```
468
+ Self-closing generation <k>: <converged>/<total> deferred PR(s) converged, <new> new deferred PR(s) opened
469
+ ```
470
+
471
+ When the loop ends, print a final line naming the generation count and the total
472
+ deferred PRs converged across every generation.
473
+
474
+ ### Conventional titles on deferred PRs
475
+
476
+ Each hardening PR the loop opens targets a repo whose CI validates the PR title
477
+ as a Conventional Commit. The commit step's prompt directs the agent to title
478
+ the hardening PR as a Conventional Commit — a type prefix, an optional scope,
479
+ then a colon and a short summary — so a deferred PR carries a conforming title
480
+ (`feat(hooks): …`, `chore(rules): …`) before it exists. That prompt is where the
481
+ conforming title is enforced.
482
+
483
+ The `conventional_pr_title_gate` hook is a best-effort backstop on that title,
484
+ not the guarantee. It blocks a `gh pr create` with a non-conforming `--title`
485
+ only for a repo whose semantic-pull-request workflow leaves the action's
486
+ `types:` input at the default Conventional Commits list. For a repo that pins
487
+ its own explicit `types:` list — which the main target repo does in
488
+ `.github/workflows/pr-check.yml` — the hook fails open and lets the title
489
+ through, and the CI title check on GitHub has the final say.
490
+
406
491
  ## Folder map
407
492
 
408
493
  - `SKILL.md` — this hub.
@@ -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**:
@@ -3,11 +3,11 @@
3
3
  Hard-won lessons for the autoconverge workflow. Append a bullet each time a run
4
4
  fails in a new way.
5
5
 
6
- - **The workflow script cannot sleep.** `Date.now`, `Math.random`, and timers
7
- are unavailable in the script body, and a foreground sleep is blocked. Every
8
- reviewer wait lives inside an `agent`'s own poll loop a shell-agnostic `sleep`
9
- loop (PowerShell `Start-Sleep` is an allowed alternative), or `gh` check
10
- polling. Never try to wait in the script itself.
6
+ - **The workflow script cannot sleep, and neither can an agent's foreground shell.** `Date.now`, `Math.random`, and timers
7
+ are unavailable in the script body, and a foreground `sleep` / `Start-Sleep` is blocked in the headless harness. Every
8
+ reviewer wait lives inside an `agent`'s own poll loop, and that loop waits with the Monitor tool inside the same turn —
9
+ never a foreground sleep, and never backgrounding a wait and ending the turn to await it, which
10
+ leaves a schema-bearing agent with no `StructuredOutput` call. Never try to wait in the script itself.
11
11
 
12
12
  - **Workflow agents start blank.** Spawned agents do not inherit CLAUDE.md,
13
13
  rules, or this skill's context. Each agent prompt is self-contained: it names
@@ -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`