claude-dev-env 1.70.0 → 1.72.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 (34) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  7. package/docs/CODE_RULES.md +1 -1
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +39 -7
  9. package/hooks/blocking/code_rules_docstrings.py +60 -0
  10. package/hooks/blocking/code_rules_enforcer.py +4 -0
  11. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  12. package/hooks/blocking/code_rules_type_escape.py +447 -2
  13. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +36 -0
  14. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  15. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  16. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  17. package/hooks/git-hooks/CLAUDE.md +1 -1
  18. package/hooks/git-hooks/git_hooks_constants/__init__.py +13 -0
  19. package/hooks/git-hooks/pre_push.py +74 -15
  20. package/hooks/git-hooks/test_pre_push.py +118 -0
  21. package/hooks/hooks_constants/blocking_check_limits.py +14 -0
  22. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +13 -2
  23. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  24. package/package.json +1 -1
  25. package/rules/docstring-prose-matches-implementation.md +5 -2
  26. package/scripts/CLAUDE.md +1 -0
  27. package/scripts/Show-Asset.ps1 +106 -0
  28. package/skills/autoconverge/SKILL.md +30 -3
  29. package/skills/autoconverge/reference/convergence.md +41 -1
  30. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  31. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  32. package/skills/autoconverge/workflow/converge.mjs +176 -6
  33. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  34. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
@@ -1,20 +1,30 @@
1
1
  #!/usr/bin/env python3
2
- """Git pre-push hook: run the CODE_RULES gate over commits about to be pushed.
2
+ """Git pre-push hook: guard the push destination, then run the CODE_RULES gate.
3
3
 
4
4
  Installed to the user's shared git-hooks directory via the claude-dev-env
5
5
  installer; git invokes this file as `pre-push` (the installer strips the
6
6
  `_` and `.py` suffix when copying into the live hooks path).
7
7
 
8
8
  Protocol: git pre-push provides remote name and URL as argv, then writes
9
- `<local-ref> <local-sha> <remote-ref> <remote-sha>` lines on stdin. The
10
- first non-zero remote-sha is used as the gate `--base`, so violations are
11
- scoped to commits that are not already on the remote. When every remote
12
- object name is zero (new branch) or stdin is empty, the gate falls back
13
- to the remote's default branch symbolic ref.
9
+ `<local-ref> <local-sha> <remote-ref> <remote-sha>` lines on stdin.
10
+
11
+ Destination guard: any line that pushes a local branch onto a protected
12
+ remote branch (`main` or `master`) whose name differs from the local
13
+ branch is blocked before the gate runs. This catches a branch that tracks
14
+ `origin/main` under `push.default=upstream`, where a bare `git push`
15
+ resolves to `main`. The guard runs whether or not the CODE_RULES gate is
16
+ installed; deletions and same-name pushes pass.
17
+
18
+ Gate base: the first non-zero remote-sha is used as the gate `--base`, so
19
+ violations are scoped to commits that are not already on the remote. When
20
+ every remote object name is zero (new branch) or stdin is empty, the gate
21
+ falls back to the remote's default branch symbolic ref.
14
22
 
15
23
  Exit codes:
16
- 0 - commits to be pushed pass the gate (or the gate is not installed).
17
- 1 - one or more commits introduce blocking violations.
24
+ 0 - the push destination is allowed and its commits pass the gate (or
25
+ the gate is not installed).
26
+ 1 - the push would land a non-protected local branch onto a protected
27
+ remote branch, or a commit introduces a blocking violation.
18
28
  2 - unexpected invocation failure (e.g., subprocess could not launch).
19
29
  """
20
30
 
@@ -25,16 +35,22 @@ import sys
25
35
  from pathlib import Path
26
36
 
27
37
  from git_hooks_constants import (
38
+ ALL_PROTECTED_BRANCH_PUSH_NAMES,
28
39
  ALL_ZEROS_OBJECT_NAME_CHARACTER,
29
40
  BASE_REFERENCE_ARGUMENT,
30
41
  DEFAULT_REMOTE_BASE_REFERENCE,
31
42
  GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
32
43
  INVOKE_GATE_FAILURE_MESSAGE,
44
+ LOCAL_BRANCH_REFERENCE_PREFIX,
45
+ LOCAL_REFERENCE_FIELD_INDEX,
33
46
  LOCAL_SHA_FIELD_INDEX,
34
47
  MALFORMED_STDIN_LINE_MESSAGE,
35
48
  NO_PARSEABLE_STDIN_LINES_MESSAGE,
36
49
  NO_PARSEABLE_STDIN_LINES_SENTINEL,
37
50
  PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE,
51
+ PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE,
52
+ PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE,
53
+ REMOTE_REFERENCE_FIELD_INDEX,
38
54
  STDIN_LINE_FIELD_COUNT,
39
55
  STDIN_READ_FAILURE_MESSAGE,
40
56
  STDIN_REMOTE_OBJECT_FIELD_INDEX,
@@ -88,6 +104,36 @@ def resolve_base_reference_from_stdin(stdin_text: str) -> str | None:
88
104
  return default_remote_base_reference
89
105
 
90
106
 
107
+ def find_protected_branch_push_violation(stdin_text: str) -> tuple[str, str] | None:
108
+ stdin_line_field_count = STDIN_LINE_FIELD_COUNT
109
+ local_reference_field_index = LOCAL_REFERENCE_FIELD_INDEX
110
+ local_sha_field_index = LOCAL_SHA_FIELD_INDEX
111
+ remote_reference_field_index = REMOTE_REFERENCE_FIELD_INDEX
112
+ local_branch_reference_prefix = LOCAL_BRANCH_REFERENCE_PREFIX
113
+ protected_branch_push_names = ALL_PROTECTED_BRANCH_PUSH_NAMES
114
+ for each_line in stdin_text.splitlines():
115
+ stripped_line = each_line.strip()
116
+ if not stripped_line:
117
+ continue
118
+ fields = stripped_line.split()
119
+ if len(fields) < stdin_line_field_count:
120
+ continue
121
+ if is_all_zeros_object_name(fields[local_sha_field_index]):
122
+ continue
123
+ local_branch_name = fields[local_reference_field_index].removeprefix(
124
+ local_branch_reference_prefix
125
+ )
126
+ remote_branch_name = fields[remote_reference_field_index].removeprefix(
127
+ local_branch_reference_prefix
128
+ )
129
+ if (
130
+ remote_branch_name in protected_branch_push_names
131
+ and local_branch_name != remote_branch_name
132
+ ):
133
+ return (local_branch_name, remote_branch_name)
134
+ return None
135
+
136
+
91
137
  def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
92
138
  base_reference_argument = BASE_REFERENCE_ARGUMENT
93
139
  invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
@@ -118,13 +164,8 @@ def main() -> int:
118
164
  pre_push_gate_script_not_found_message = PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE
119
165
  no_parseable_stdin_lines_message = NO_PARSEABLE_STDIN_LINES_MESSAGE
120
166
  no_parseable_stdin_lines_sentinel = NO_PARSEABLE_STDIN_LINES_SENTINEL
121
- gate_script_path, exact_allowed_path = resolve_gate_script_path()
122
- if not is_safe_regular_file(gate_script_path, exact_allowed_path):
123
- print(
124
- pre_push_gate_script_not_found_message.format(path=gate_script_path),
125
- file=sys.stderr,
126
- )
127
- return 0
167
+ protected_branch_push_block_message = PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE
168
+ protected_branch_push_block_exit_code = PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE
128
169
  try:
129
170
  stdin_text = sys.stdin.read()
130
171
  except OSError as read_error:
@@ -133,6 +174,24 @@ def main() -> int:
133
174
  file=sys.stderr,
134
175
  )
135
176
  return gate_infrastructure_failure_exit_code
177
+ protected_branch_push_violation = find_protected_branch_push_violation(stdin_text)
178
+ if protected_branch_push_violation is not None:
179
+ local_branch_name, remote_branch_name = protected_branch_push_violation
180
+ print(
181
+ protected_branch_push_block_message.format(
182
+ local_branch=local_branch_name,
183
+ remote_branch=remote_branch_name,
184
+ ),
185
+ file=sys.stderr,
186
+ )
187
+ return protected_branch_push_block_exit_code
188
+ gate_script_path, exact_allowed_path = resolve_gate_script_path()
189
+ if not is_safe_regular_file(gate_script_path, exact_allowed_path):
190
+ print(
191
+ pre_push_gate_script_not_found_message.format(path=gate_script_path),
192
+ file=sys.stderr,
193
+ )
194
+ return 0
136
195
  base_reference = resolve_base_reference_from_stdin(stdin_text)
137
196
  if base_reference is None:
138
197
  return 0
@@ -321,3 +321,121 @@ def test_invoke_gate_uses_resolved_path(
321
321
  assert exit_code == 0
322
322
  executed_path = recorded_path_file.read_text(encoding="utf-8")
323
323
  assert executed_path == str(resolved_path)
324
+
325
+
326
+ def test_find_protected_branch_push_violation_flags_feature_branch_to_main() -> None:
327
+ stdin_text = (
328
+ f"refs/heads/feat/example {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
329
+ )
330
+
331
+ violation = pre_push.find_protected_branch_push_violation(stdin_text)
332
+
333
+ assert violation == ("feat/example", "main")
334
+
335
+
336
+ def test_find_protected_branch_push_violation_flags_feature_branch_to_master() -> None:
337
+ stdin_text = (
338
+ f"refs/heads/topic {NON_ZERO_LOCAL_SHA} refs/heads/master {NON_ZERO_REMOTE_SHA_ONE}\n"
339
+ )
340
+
341
+ violation = pre_push.find_protected_branch_push_violation(stdin_text)
342
+
343
+ assert violation == ("topic", "master")
344
+
345
+
346
+ def test_find_protected_branch_push_violation_allows_main_onto_main() -> None:
347
+ stdin_text = (
348
+ f"refs/heads/main {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
349
+ )
350
+
351
+ violation = pre_push.find_protected_branch_push_violation(stdin_text)
352
+
353
+ assert violation is None
354
+
355
+
356
+ def test_find_protected_branch_push_violation_allows_feature_onto_own_ref() -> None:
357
+ stdin_text = (
358
+ f"refs/heads/feat/example {NON_ZERO_LOCAL_SHA} refs/heads/feat/example {NON_ZERO_REMOTE_SHA_ONE}\n"
359
+ )
360
+
361
+ violation = pre_push.find_protected_branch_push_violation(stdin_text)
362
+
363
+ assert violation is None
364
+
365
+
366
+ def test_find_protected_branch_push_violation_ignores_deletion_of_main() -> None:
367
+ stdin_text = (
368
+ f"(delete) {ALL_ZEROS_OBJECT_NAME} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
369
+ )
370
+
371
+ violation = pre_push.find_protected_branch_push_violation(stdin_text)
372
+
373
+ assert violation is None
374
+
375
+
376
+ def test_main_blocks_feature_branch_push_onto_main(
377
+ tmp_path: Path,
378
+ monkeypatch: pytest.MonkeyPatch,
379
+ capsys: pytest.CaptureFixture[str],
380
+ ) -> None:
381
+ passing_gate_path = tmp_path / "gate.py"
382
+ passing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
383
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(passing_gate_path))
384
+ monkeypatch.setattr(
385
+ sys,
386
+ "stdin",
387
+ io.StringIO(
388
+ f"refs/heads/feat/example {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
389
+ ),
390
+ )
391
+
392
+ exit_code = pre_push.main()
393
+
394
+ assert exit_code == git_hooks_constants.PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE
395
+ captured = capsys.readouterr()
396
+ assert "feat/example" in captured.err
397
+ assert "main" in captured.err
398
+
399
+
400
+ def test_main_blocks_protected_push_even_when_gate_script_missing(
401
+ tmp_path: Path,
402
+ monkeypatch: pytest.MonkeyPatch,
403
+ capsys: pytest.CaptureFixture[str],
404
+ ) -> None:
405
+ monkeypatch.setenv(
406
+ "CODE_RULES_GATE_PATH",
407
+ str(tmp_path / "does_not_exist.py"),
408
+ )
409
+ monkeypatch.setattr(
410
+ sys,
411
+ "stdin",
412
+ io.StringIO(
413
+ f"refs/heads/feat/example {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
414
+ ),
415
+ )
416
+
417
+ exit_code = pre_push.main()
418
+
419
+ assert exit_code == git_hooks_constants.PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE
420
+ captured = capsys.readouterr()
421
+ assert "feat/example" in captured.err
422
+
423
+
424
+ def test_main_allows_main_onto_main_push(
425
+ tmp_path: Path,
426
+ monkeypatch: pytest.MonkeyPatch,
427
+ ) -> None:
428
+ passing_gate_path = tmp_path / "gate.py"
429
+ passing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
430
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(passing_gate_path))
431
+ monkeypatch.setattr(
432
+ sys,
433
+ "stdin",
434
+ io.StringIO(
435
+ f"refs/heads/main {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
436
+ ),
437
+ )
438
+
439
+ exit_code = pre_push.main()
440
+
441
+ assert exit_code == 0
@@ -29,6 +29,20 @@ MAX_E2E_TEST_NAMING_ISSUES: int = 3
29
29
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT: int = 3
30
30
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
31
31
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
32
+ MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES: int = 3
33
+ MAX_STALE_TEST_NAME_TARGET_ISSUES: int = 3
34
+ STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT: int = 2
35
+
36
+ ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
37
+ "no consumer reads",
38
+ "no consumer yet",
39
+ "no submission-run consumer reads",
40
+ "producer-only artifact",
41
+ "no reader consumes",
42
+ "nothing reads it yet",
43
+ "no one reads it yet",
44
+ "not yet read by any consumer",
45
+ )
32
46
 
33
47
  ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES: tuple[str, ...] = (
34
48
  "only when",
@@ -8,8 +8,8 @@ subdirectories, and its siblings), the table points a reader at a file that is
8
8
  not there. This module holds the patterns that find those cells, the filename
9
9
  extensions that mark a cell as a file reference, the region-boundary marker that
10
10
  scopes a prose region to one section, the relative-path marker that exempts a
11
- cross-directory table block, the subtree scan budget, and the block-message text
12
- the hook emits.
11
+ cross-directory table block, the directory names the subtree walk prunes, the
12
+ subtree scan budget, and the block-message text the hook emits.
13
13
  """
14
14
 
15
15
  import re
@@ -23,6 +23,7 @@ __all__ = [
23
23
  "REGION_BOUNDARY_PATTERN",
24
24
  "RELATIVE_PATH_SOURCE_PATTERN",
25
25
  "ALL_REFERENCED_FILE_EXTENSIONS",
26
+ "ALL_NOISE_DIRECTORY_NAMES",
26
27
  "MAX_SUBTREE_FILES_SCANNED",
27
28
  "MAX_ORPHAN_FILE_ISSUES",
28
29
  "ORPHAN_FILE_MESSAGE_TEMPLATE",
@@ -65,6 +66,16 @@ ALL_REFERENCED_FILE_EXTENSIONS: frozenset[str] = frozenset(
65
66
  }
66
67
  )
67
68
 
69
+ ALL_NOISE_DIRECTORY_NAMES: frozenset[str] = frozenset(
70
+ {
71
+ ".git",
72
+ "__pycache__",
73
+ "node_modules",
74
+ ".pytest_cache",
75
+ ".ruff_cache",
76
+ }
77
+ )
78
+
68
79
  MAX_SUBTREE_FILES_SCANNED: int = 5000
69
80
 
70
81
  MAX_ORPHAN_FILE_ISSUES: int = 20
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
 
9
9
  GIT_DASH_C_COMMIT_PATTERN: str = r"git\s+-C\s+[\"']?[^\"';&|]+?[\"']?\s+commit\b"
10
10
  GIT_COMMAND_TIMEOUT_SECONDS: int = 5
11
- GATE_TIMEOUT_SECONDS: int = 120
11
+ GATE_TIMEOUT_SECONDS: int = 240
12
12
  GATE_RELATIVE_PATH: Path = Path("_shared") / "pr-loop" / "scripts" / "code_rules_gate.py"
13
13
  ALL_STAGED_PYTHON_FILES_COMMAND: tuple[str, ...] = (
14
14
  "git",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.70.0",
3
+ "version": "1.72.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,12 @@
1
1
  # Docstring Prose Matches Implementation
2
2
 
3
- **When this applies:** Any Write or Edit to a public function, method, class, or module whose docstring prose makes an enumerable claim about behavior — a list of inputs the code handles, the conditions it treats as a match, the cases it skips, or the order of its steps.
3
+ **When this applies:** Any Write or Edit to a public function, method, class, or module whose docstring prose makes an enumerable claim about behavior — a list of inputs the code handles, the conditions it treats as a match, the cases it skips, or the order of its steps. It applies equally to a skill's companion `SKILL.md` (or any sibling `.md`) that describes a producer the skill's `scripts/` carry out: a doc sentence that claims a produced artifact's ordering or content is the prose this rule governs, and it tracks the producer function's own docstring and body.
4
4
 
5
5
  ## Rule
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. Two more gate validators each cover one deterministic slice of the free-form prose. `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. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — 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 three gated slices.
9
+ The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Three more gate validators each cover one deterministic slice of the free-form prose. `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. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — 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 four gated slices.
10
10
 
11
11
  ## What to check before you write the docstring
12
12
 
@@ -17,6 +17,7 @@ Read the body and the docstring side by side:
17
17
  - **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
18
18
  - **Step order.** A docstring that says `A then B then C` matches the call order in the body.
19
19
  - **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.
20
+ - **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.
20
21
 
21
22
  When the body changes the set of behaviors it applies, the same edit updates the prose enumeration. The two move together in one commit.
22
23
 
@@ -39,6 +40,8 @@ A docstring that enumerates "attribute read, augmented-assignment target, class-
39
40
 
40
41
  This drift class is sub-bucket **O6** in `packages/claude-dev-env/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md` (free-form `Note:` / `Returns:` / responsibility-list claims). The audit teammate lists every prose enumeration in a changed docstring and verifies each item against the body, and lists every union member / suppressor / step in the body and verifies each appears in the prose. A union member or suppressor in the body that the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift is gated deterministically by `check_docstring_fallback_branch_coverage` (`packages/claude-dev-env/hooks/blocking/code_rules_docstrings.py`); the audit lane covers every O6 shape the gate cannot match.
41
42
 
43
+ When a changed PR touches a producer function whose ordering or union shifts, the O8 audit lane also reads that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact. A doc sentence that claims the artifact is `sorted` or holds `just the at-risk names` while the producer merges prior names and appends without re-sorting is an O8 finding, even when the PR diff never touched the `.md` file — the behavior change orphaned the doc claim.
44
+
42
45
  ## Why
43
46
 
44
47
  A docstring enumeration earns its place by being trustworthy. A complete list lets a reader reason about the function without scanning the body; a list missing one item is worse than no list, because it asserts completeness it does not have. Naming this standard makes the gap a first-class finding at write time and at audit, rather than a surprise a reader hits months later.
package/scripts/CLAUDE.md CHANGED
@@ -18,6 +18,7 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
18
18
  | `Migrate-ShellPolicy.ps1` | Applies automated fixes for common shell-policy violations found by the audit script |
19
19
  | `Install-SweepEmptyDirs.ps1` | Registers `sweep_empty_dirs.py` as a scheduled task on Windows |
20
20
  | `check.ps1` | Runs the full code-quality check suite |
21
+ | `Show-Asset.ps1` | Opens files on screen, sizing each image window to the image's pixel dimensions (scaled to fit the screen); non-image files open in their default application |
21
22
 
22
23
  ## Subdirectories
23
24
 
@@ -0,0 +1,106 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Opens files on screen, sizing each image window to the image's own dimensions.
4
+
5
+ .DESCRIPTION
6
+ For every path given, an image opens in a window whose client area matches the
7
+ image's pixel size, scaled down to fit the primary screen's working area when the
8
+ image is larger than the screen. A small image gets a usable minimum window with
9
+ the picture centered at native size. Non-image files open in their registered
10
+ default application, and any file that cannot be loaded as an image falls back to
11
+ that default application too. Escape or the close button dismisses a window; the
12
+ process exits once every window is closed.
13
+
14
+ .PARAMETER Paths
15
+ One or more file paths to open.
16
+ #>
17
+ param(
18
+ [Parameter(ValueFromRemainingArguments = $true)]
19
+ [string[]]$Paths
20
+ )
21
+
22
+ Add-Type -AssemblyName System.Windows.Forms
23
+ Add-Type -AssemblyName System.Drawing
24
+
25
+ try {
26
+ [System.Windows.Forms.Application]::SetHighDpiMode([System.Windows.Forms.HighDpiMode]::PerMonitorV2) | Out-Null
27
+ }
28
+ catch {
29
+ $null = $_
30
+ }
31
+ [System.Windows.Forms.Application]::EnableVisualStyles()
32
+
33
+ $imageExtensions = @('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tif', '.tiff', '.ico')
34
+ $screenMargin = 80
35
+ $minimumClientWidth = 220
36
+ $minimumClientHeight = 160
37
+ $openWindowCount = 0
38
+
39
+ foreach ($path in $Paths) {
40
+ if (-not (Test-Path -LiteralPath $path)) { continue }
41
+ $fullPath = (Resolve-Path -LiteralPath $path).Path
42
+ $extension = [System.IO.Path]::GetExtension($fullPath).ToLowerInvariant()
43
+
44
+ if ($imageExtensions -notcontains $extension) {
45
+ Invoke-Item -LiteralPath $fullPath
46
+ continue
47
+ }
48
+
49
+ try {
50
+ $imageBytes = [System.IO.File]::ReadAllBytes($fullPath)
51
+ $imageStream = New-Object System.IO.MemoryStream(, $imageBytes)
52
+ $loadedImage = [System.Drawing.Image]::FromStream($imageStream)
53
+ $image = New-Object System.Drawing.Bitmap($loadedImage)
54
+ $loadedImage.Dispose()
55
+ $imageStream.Dispose()
56
+ }
57
+ catch {
58
+ Invoke-Item -LiteralPath $fullPath
59
+ continue
60
+ }
61
+
62
+ $workingArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
63
+ $maximumWidth = $workingArea.Width - $screenMargin
64
+ $maximumHeight = $workingArea.Height - $screenMargin
65
+ $scale = [Math]::Min(1.0, [Math]::Min($maximumWidth / $image.Width, $maximumHeight / $image.Height))
66
+
67
+ $pictureBox = New-Object System.Windows.Forms.PictureBox
68
+ $pictureBox.Dock = [System.Windows.Forms.DockStyle]::Fill
69
+ $pictureBox.Image = $image
70
+
71
+ if ($scale -lt 1.0) {
72
+ $pictureBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
73
+ $clientWidth = [int][Math]::Round($image.Width * $scale)
74
+ $clientHeight = [int][Math]::Round($image.Height * $scale)
75
+ }
76
+ else {
77
+ $pictureBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::CenterImage
78
+ $clientWidth = [Math]::Max($minimumClientWidth, $image.Width)
79
+ $clientHeight = [Math]::Max($minimumClientHeight, $image.Height)
80
+ }
81
+
82
+ $form = New-Object System.Windows.Forms.Form
83
+ $form.Text = [System.IO.Path]::GetFileName($fullPath)
84
+ $form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::None
85
+ $form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen
86
+ $form.ClientSize = New-Object System.Drawing.Size($clientWidth, $clientHeight)
87
+ $form.KeyPreview = $true
88
+ $form.BackColor = [System.Drawing.Color]::FromArgb(24, 24, 24)
89
+ $form.Controls.Add($pictureBox)
90
+
91
+ $form.Add_KeyDown({
92
+ param($sender, $eventArguments)
93
+ if ($eventArguments.KeyCode -eq [System.Windows.Forms.Keys]::Escape) { $sender.Close() }
94
+ })
95
+ $form.Add_FormClosed({
96
+ $script:openWindowCount--
97
+ if ($script:openWindowCount -le 0) { [System.Windows.Forms.Application]::Exit() }
98
+ })
99
+
100
+ $openWindowCount++
101
+ $form.Show()
102
+ }
103
+
104
+ if ($openWindowCount -gt 0) {
105
+ [System.Windows.Forms.Application]::Run()
106
+ }
@@ -101,7 +101,7 @@ own. The workflow runs in the background and notifies this session on
101
101
  completion. Watch live progress with `/workflows`.
102
102
 
103
103
  The workflow returns
104
- `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote }`.
104
+ `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote }`.
105
105
 
106
106
  ## Budget-aware round boundaries
107
107
 
@@ -207,8 +207,31 @@ round records nothing resumable and replays dirty.
207
207
  Blocker: <blocker> # only when blocked
208
208
  Standards: <standardsNote> # only when a round deferred code-standard findings
209
209
  Copilot: <copilotNote> # only when Copilot was down or out of quota
210
+ Reuse: <reuseNote> # only when the reuse pass identified an improvement
210
211
  ```
211
212
 
213
+ ## Reuse pass (before convergence)
214
+
215
+ Before the first round, one reuse lens (`code-quality-agent`) scans the full
216
+ `origin/main...HEAD` diff for places the PR re-implements behavior the codebase
217
+ already provides. It reports a reuse improvement only when all three criteria
218
+ hold, and drops any case where even one is in doubt:
219
+
220
+ - **Certain** — an existing symbol or module unquestionably covers the new
221
+ code's behavior, cited at `file:line`.
222
+ - **Behaviorally identical** — swapping the new code for the existing one
223
+ changes no observable behavior: same inputs, outputs, side effects, and error
224
+ handling.
225
+ - **Autonomously implementable** — the replacement is a mechanical edit (import
226
+ and call the existing symbol, delete the duplicate) needing no product
227
+ decision and no human judgment.
228
+
229
+ The reuse lens reports without editing. Qualifying improvements then run through
230
+ the same edit → verify → commit fix flow the rounds use, so they land in one
231
+ verified commit before convergence starts. The pass is best-effort: when no case
232
+ clears all three criteria, the run proceeds straight to convergence, and
233
+ `reuseNote` records what landed.
234
+
212
235
  ## What the workflow does each round
213
236
 
214
237
  See [`reference/convergence.md`](reference/convergence.md) for the full round
@@ -227,8 +250,12 @@ suite (`python -m pytest`) and keep scratch work in ephemeral temp dirs.
227
250
  - **Converge:** `parallel([Bugbot lens, code-review lens, bug-audit lens])` on
228
251
  the current HEAD, full `origin/main...HEAD` diff. Dedup findings; one
229
252
  `clean-coder` applies all fixes in a single commit, pushes, replies to and
230
- resolves any bot threads; re-verify next round on the new HEAD. When all
231
- three are clean on a stable HEAD, post the CLEAN bugteam audit artifact.
253
+ resolves any bot threads; re-verify next round on the new HEAD. Every edit
254
+ step ends with a pre-commit gate check: before its turn ends, the fixer
255
+ dry-runs the CODE_RULES commit gate (`code_rules_gate.py --staged`) and keeps
256
+ fixing until that gate would accept the commit — it makes no commit itself.
257
+ When all three are clean on a stable HEAD, post the CLEAN bugteam audit
258
+ artifact.
232
259
  A round whose findings are ALL code-standard violations (pure CODE_RULES/style,
233
260
  no behavioral impact) passes for convergence purposes: the workflow files a
234
261
  follow-up issue listing the findings, opens a draft environment-hardening PR
@@ -1,5 +1,42 @@
1
1
  # Convergence — round shape and the ready definition
2
2
 
3
+ ## Pre-flight: clear merge conflicts
4
+
5
+ Before the first round, the workflow checks once whether the PR branch conflicts
6
+ with `origin/main`. When GitHub reports a conflict (`mergeable` false or
7
+ `mergeable_state` dirty), one `clean-coder` rebases the branch onto `origin/main`
8
+ and resolves every conflict — gated the same way as every other code change: the
9
+ edit leaves the rebase in the working tree, a `code-verifier` binds a verdict to
10
+ it, and the commit step force-pushes with lease. The bug checks then run on a
11
+ conflict-free diff.
12
+
13
+ A PR that merges cleanly skips the rebase. A conflict that surfaces mid-run, when
14
+ `origin/main` advances during a later round, is caught by the convergence repair
15
+ at the end of the loop, which also rebases.
16
+
17
+ ## Reuse pass (runs after the conflict pre-flight, before convergence)
18
+
19
+ One reuse lens (`code-quality-agent`) reviews the full `origin/main...HEAD` diff
20
+ for code that re-implements behavior the repository already provides. It reports a
21
+ reuse improvement only when all three criteria hold together, and omits any case
22
+ where even one is in doubt:
23
+
24
+ 1. **Certain** — an existing symbol or module unquestionably covers the new
25
+ code's behavior, cited at `file:line`.
26
+ 2. **Behaviorally the same** — swapping the new code for the existing one
27
+ changes no observable behavior: same inputs, outputs, side effects, and
28
+ error handling.
29
+ 3. **Autonomously implementable** — the replacement is a mechanical edit (import
30
+ and call the existing symbol, drop the duplicate) needing no product
31
+ decision and no human judgment.
32
+
33
+ The lens reports without editing. Each qualifying improvement runs through the
34
+ same edit → verify → commit fix flow the rounds use, landing in one verified
35
+ commit before convergence begins. The pass is best-effort: when no case clears
36
+ all three criteria the run proceeds straight to convergence. Whatever the reuse
37
+ pass surfaces also joins the round findings, so the code-review lens re-checks
38
+ any improvement that did not land.
39
+
3
40
  ## The round loop
4
41
 
5
42
  The workflow holds three states and moves between them until the PR is ready or
@@ -26,7 +63,10 @@ tracks CONVERGE passes only and is never the cap.
26
63
  colliding threads.
27
64
  4. **Any findings** → one `clean-coder` applies every fix in a single test-first
28
65
  commit, pushes, then replies to and resolves each finding that carries a
29
- GitHub review thread. A round progresses when the fix lens lands a push that
66
+ GitHub review thread. Before its turn ends, the edit step dry-runs the
67
+ CODE_RULES commit gate (`code_rules_gate.py --staged`) over its staged
68
+ changes and keeps fixing until that gate would accept the commit, so the
69
+ later commit step never hits a gate rejection. A round progresses when the fix lens lands a push that
30
70
  moves HEAD, or when every finding was already addressed so no code change is
31
71
  needed yet each finding thread is still resolved (the fix lens reports
32
72
  `resolvedWithoutCommit` and the run re-converges on the unchanged HEAD). A