claude-dev-env 1.69.2 → 1.71.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.
@@ -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
package/hooks/hooks.json CHANGED
@@ -70,6 +70,11 @@
70
70
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/workflow_substitution_slot_blocker.py",
71
71
  "timeout": 10
72
72
  },
73
+ {
74
+ "type": "command",
75
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/claude_md_orphan_file_blocker.py",
76
+ "timeout": 10
77
+ },
73
78
  {
74
79
  "type": "command",
75
80
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
@@ -11,6 +11,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
11
11
  | `banned_identifiers_constants.py` | The set of banned short identifiers and banned function-name prefixes |
12
12
  | `blocking_check_limits.py` | Max issue counts and preview lengths for blocking hooks |
13
13
  | `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
14
+ | `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
14
15
  | `code_rules_enforcer_constants.py` | File-extension sets, test-path patterns, advisory line thresholds, boolean-name prefixes |
15
16
  | `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
16
17
  | `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
@@ -0,0 +1,107 @@
1
+ """Constants for the per-directory CLAUDE.md orphan-file-reference blocker.
2
+
3
+ A per-directory ``CLAUDE.md`` documents the files reachable from its own
4
+ directory in a markdown table whose first column names each file in backticks.
5
+ When a first-column cell names a bare filename that exists nowhere under the scan
6
+ root (the CLAUDE.md directory's parent, covering the directory, its
7
+ subdirectories, and its siblings), the table points a reader at a file that is
8
+ not there. This module holds the patterns that find those cells, the filename
9
+ extensions that mark a cell as a file reference, the region-boundary marker that
10
+ scopes a prose region to one section, the relative-path marker that exempts a
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
+ """
14
+
15
+ import re
16
+
17
+ __all__ = [
18
+ "CLAUDE_MD_FILENAME",
19
+ "TABLE_ROW_PATTERN",
20
+ "CODE_FENCE_PATTERN",
21
+ "FIRST_COLUMN_BACKTICK_PATTERN",
22
+ "SEPARATOR_CELL_PATTERN",
23
+ "REGION_BOUNDARY_PATTERN",
24
+ "RELATIVE_PATH_SOURCE_PATTERN",
25
+ "ALL_REFERENCED_FILE_EXTENSIONS",
26
+ "ALL_NOISE_DIRECTORY_NAMES",
27
+ "MAX_SUBTREE_FILES_SCANNED",
28
+ "MAX_ORPHAN_FILE_ISSUES",
29
+ "ORPHAN_FILE_MESSAGE_TEMPLATE",
30
+ "ORPHAN_FILE_SYSTEM_MESSAGE",
31
+ "ORPHAN_FILE_ADDITIONAL_CONTEXT",
32
+ ]
33
+
34
+ CLAUDE_MD_FILENAME: str = "CLAUDE.md"
35
+
36
+ TABLE_ROW_PATTERN: re.Pattern[str] = re.compile(r"^\s*\|")
37
+
38
+ CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
39
+
40
+ FIRST_COLUMN_BACKTICK_PATTERN: re.Pattern[str] = re.compile(r"`([^`]+)`")
41
+
42
+ SEPARATOR_CELL_PATTERN: re.Pattern[str] = re.compile(r"^[\s:\-]+$")
43
+
44
+ REGION_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"^\s*#")
45
+
46
+ RELATIVE_PATH_SOURCE_PATTERN: re.Pattern[str] = re.compile(r"\.\.[\\/]")
47
+
48
+ ALL_REFERENCED_FILE_EXTENSIONS: frozenset[str] = frozenset(
49
+ {
50
+ ".py",
51
+ ".md",
52
+ ".json",
53
+ ".mjs",
54
+ ".js",
55
+ ".ts",
56
+ ".ps1",
57
+ ".cmd",
58
+ ".ahk",
59
+ ".yml",
60
+ ".yaml",
61
+ ".sh",
62
+ ".txt",
63
+ ".cfg",
64
+ ".toml",
65
+ ".ini",
66
+ }
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
+
79
+ MAX_SUBTREE_FILES_SCANNED: int = 5000
80
+
81
+ MAX_ORPHAN_FILE_ISSUES: int = 20
82
+
83
+ ORPHAN_FILE_MESSAGE_TEMPLATE: str = (
84
+ "CLAUDE.md table references files that exist nowhere under {directory}: "
85
+ "{missing}. A per-directory CLAUDE.md table names files in its own directory "
86
+ "subtree; a first-column cell naming a file absent from that subtree points a "
87
+ "reader at something that is not there. Drop the row, or correct the cell to "
88
+ "name a file that exists in this directory, a subdirectory of it, or a sibling "
89
+ "directory under its parent."
90
+ )
91
+
92
+ ORPHAN_FILE_SYSTEM_MESSAGE: str = (
93
+ "CLAUDE.md table names a file that does not exist in its directory subtree - "
94
+ "drop the row or name an existing file"
95
+ )
96
+
97
+ ORPHAN_FILE_ADDITIONAL_CONTEXT: str = (
98
+ "Each first-column table cell wrapped in backticks that ends in a known file "
99
+ "extension must name a file present under the scan root: this CLAUDE.md's own "
100
+ "directory, a subdirectory of it, or a sibling directory under its parent. "
101
+ "Cells holding a path with a slash, a subdirectory ending in '/', or a "
102
+ "slash-command are out of scope. A table whose own block names an explicit "
103
+ "relative-path source (a '../' token) documents files outside the subtree and "
104
+ "is out of scope. For each missing file:\n"
105
+ " - delete the table row, or\n"
106
+ " - rename the cell to an existing file under the scan root."
107
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.69.2",
3
+ "version": "1.71.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/CLAUDE.md CHANGED
@@ -9,6 +9,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
9
9
  | `agent-spawn-protocol.md` | Protocol for spawning subagents: context sufficiency check, prompt generation via `/prompt-generator`, then spawn |
10
10
  | `ask-user-question-required.md` | Every user-directed question goes through the `AskUserQuestion` tool — no plain-text questions |
11
11
  | `bdd.md` | BDD discovery-driven development workflow and Example Mapping reference |
12
+ | `claude-md-orphan-file.md` | Every backticked bare filename in a per-directory `CLAUDE.md` table's first column names a file in that directory's subtree |
12
13
  | `cleanup-temp-files.md` | Remove temporary files created during a task when the task is complete |
13
14
  | `code-reviews.md` | Mandatory protocol for responding to GitHub PR review feedback |
14
15
  | `code-standards.md` | Pointer to `CODE_RULES.md` as the single source of truth |
@@ -0,0 +1,24 @@
1
+ # Orphan File Reference in a Per-Directory CLAUDE.md
2
+
3
+ **When this applies:** Any Write, Edit, or MultiEdit to a file named `CLAUDE.md` that lists files in a markdown table whose first column names each file in backticks.
4
+
5
+ ## Rule
6
+
7
+ Every bare filename a per-directory `CLAUDE.md` table names in its first column points at a file that exists in the directory subtree the `CLAUDE.md` describes. A first-column cell naming a file that exists nowhere in that subtree points a reader at something that is not there: the listing claims a file the directory does not hold.
8
+
9
+ When you add a table row, the file it names already exists in this directory or a subdirectory of it. When you remove a file, drop the row that named it.
10
+
11
+ ## What the gate checks
12
+
13
+ The `claude_md_orphan_file_blocker.py` hook runs on every Write, Edit, and MultiEdit whose target basename is `CLAUDE.md`. It:
14
+
15
+ 1. Reads the content the tool would leave on disk. For a Write that is the full `content`. For an Edit or MultiEdit it reconstructs the post-edit file — the existing on-disk file with the replacements applied — and also notes which orphans the file already held before the edit, so a pre-existing orphan on an untouched line is excluded and only an orphan the edit introduces is reported; when the existing file cannot be read, it scans the raw `new_string` fragment(s) instead.
16
+ 2. Skips any line inside a fenced code block (between a ``` or `~~~` fence pair), since an example table there is documentation, not a live listing.
17
+ 3. Takes the first column of each remaining markdown table row and keeps the cells that name a bare filename: wrapped in backticks, no path separator, not a slash-command, and ending in a known file extension (`.py`, `.md`, `.json`, `.mjs`, `.js`, `.ts`, `.ps1`, `.cmd`, `.ahk`, `.yml`, `.yaml`, `.sh`, `.txt`, `.cfg`, `.toml`, `.ini`).
18
+ 4. Blocks the write when a named file exists nowhere under the scan root — the `CLAUDE.md` directory's parent, which covers the directory, its subdirectories, and its siblings. A filesystem error that halts the whole subtree walk fails open (the write proceeds), so an unreadable tree never blocks a write.
19
+
20
+ The check stays quiet for a target that is not a `CLAUDE.md`, for a table cell that holds a path, a subdirectory ending in `/`, or a slash-command, for a table row inside a fenced code block, and for a table whose content names an explicit relative-path source (a `../` token), since that table documents files that sit outside the subtree by design.
21
+
22
+ ## Why this is a hook, not a lint pass
23
+
24
+ A table row that names an absent file reads as a contract: a reader trusts the listing to map the directory. A wrong row sends the reader looking for a file that is not there and erodes trust in every other row. Catching it as each row is written keeps the table and the directory in step.
@@ -1,6 +1,6 @@
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
 
@@ -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.
@@ -92,6 +92,16 @@ TIMELINE_TERMINAL_BAR_LABEL = "terminal"
92
92
 
93
93
  CAUSE_MUTED_STYLE = "color:#94a3b8;"
94
94
 
95
+ ISSUE_ICON_BY_CATEGORY = {
96
+ "bug": "\U0001f41e",
97
+ "code-standard": "\U0001f4cf",
98
+ }
99
+ DEFAULT_ISSUE_ICON = "\U0001f41e"
100
+
101
+ SCORECARD_LABEL_CAUGHT = "caught"
102
+ SCORECARD_LABEL_ROUNDS = "rounds"
103
+ SCORECARD_LABEL_REMAINING = "left"
104
+
95
105
  GITHUB_PR_URL_TEMPLATE = "https://github.com/{owner}/{repo}/pull/{number}"
96
106
 
97
107
  SUMMARY_PR_COORDINATES_TEMPLATE = "owner={owner} repo={repo} PR #{pr_number} ({url})"
@@ -128,12 +138,13 @@ Write so a non-programmer understands every line. The reader has never seen the
128
138
  - problemScenes/fixScenes example (a DIFFERENT project, to teach the style not the answer). Problem scene: trigger "export stops at batch 90", condition "you restart it", result "starts again at batch 1", caption "A halted export threw away the 90 batches it had already finished and began again." Mirroring fix scene: trigger "export stops at batch 90", condition "you restart it", result "continues at batch 91", caption "A restarted export now picks up at the next unfinished batch."
129
139
  - GROUP near-duplicate findings into issue CLASSES: the same KIND of problem across different files or lines becomes ONE class with a count. Example: seven "Missing return type annotation on test function" findings become one class with count 7.
130
140
  - TRANSLATE reviewer jargon into plain everyday English. Examples: "CodingGuidelineID 1000000 / Repository guideline (Types)" -> "a typing rule the project enforces"; "missing return type annotation / Add -> None" -> "a test did not declare what it returns"; "Banned identifier result" -> "a vague variable name the project bans"; a magic-value finding -> "a raw number or string that should be a named value".
131
- - plainName: a short plain symptom a non-programmer recognizes - name what BROKE or what a person would notice, not the internal component. Carry NO tool token, rule id, file path, line number, severity code (P0/P1/P2), or bot name. Good: "The install command quietly did nothing". Weak: "Entry-point guard misfires under symlinked bin invocation".
141
+ - TRANSLATE internal-mechanism nouns the same way. "guard", "hook", "checker", "validator", "gate", "parser", "entry-point", "AST" each name a part of the machine a non-programmer never sees. Replace each with the everyday check it performs or, better, the effect it had: "the guard blocked the save" -> "saving the file was blocked"; "the hook fired" -> "the project's automatic check ran". Never leave a bare "guard", "hook", or "checker" in a sentence a reader sees.
142
+ - plainName: a short plain symptom a non-programmer recognizes - name what BROKE or what a person would notice, not the internal component. Carry NO tool token, rule id, file path, line number, severity code (P0/P1/P2), bot name, or internal-mechanism noun (guard, hook, checker, validator, gate, parser, AST, annotation). Lead with the visible effect. Good: "The install command quietly did nothing"; "Saving a file was blocked by mistake". Weak: "Entry-point guard misfires under symlinked bin invocation"; "The guard wrongly flagged a field".
132
143
  - cause is the field that matters most. Sentence 1: the observable impact - what a person saw go wrong, or would have. Sentence 2: the cause in everyday terms. At most 2 sentences, no paragraphs.
133
144
  - In cause describe the CONSEQUENCE, never the internal mechanism. Do not narrate control flow, comparisons, or internal state. Banned phrasings: "a check returned false", "two forms of the path", "treated the run as a non-run", "the values did not match", "a guard misfired". Say what that meant for the person instead.
134
145
  - Prefer concrete everyday words: "the install command" and "on Mac and Linux" over "the launcher", "the entry-point guard", or "the symlink". Read each sentence as if aloud to a non-programmer; if they would not follow it, rewrite it.
135
146
  - cause worked example (a DIFFERENT problem, to show the style, not the answer). WEAK, do not write like this: "A cached value's timestamp was compared across mismatched time zones, so the freshness check evaluated false and the entry was treated as stale and re-fetched on every request." STRONG, write like this: "Every page re-downloaded the same data from scratch instead of reusing the copy it already had, so pages loaded slower than needed. The app misjudged its saved copy as out of date."
136
- - medium picks how the before/after panels are drawn: 'terminal' when the user-visible effect is command-line behavior; 'code' when the finding is best shown as a small before/after code snippet (e.g. a missing return type: before "def test_x():" / after "def test_x() -> None:"); 'text' otherwise.
147
+ - medium picks how the before/after panels are drawn: 'terminal' when the user-visible effect is command-line behavior; 'code' when the finding is best shown as a small before/after code snippet (e.g. a missing return type: before "def test_x():" / after "def test_x() -> None:"); 'text' otherwise. PREFER 'terminal' or 'text' so the panels show what a PERSON sees; reserve 'code' for the rare finding whose fix truly is a one-line code change a reader would recognize. When in doubt, choose 'text' and describe the effect in plain words, not source code.
137
148
  - beforeLines and afterLines are the literal short lines to show in each panel - what a person sees. For a terminal: include the prompt + command + the (missing or present) output. For code: 1 to 4 lines before, 1 to 4 lines after. Keep every line short. Never fabricate exact output text that is not implied by the finding - if the exact text is unknown, show the shape (e.g. "(no output - nothing installed)"). Leave both arrays empty to fall back to the cause line alone.
138
149
  - Lead with category 'bug' classes, then 'code-standard'. Create one class per distinct KIND of problem, however many that is; never merge different kinds or drop classes to hit a number.
139
150
  - status is 'fixed' unless the fix summaries or the deferred code-standard note mark the class deferred, in which case status is 'deferred'.
@@ -148,6 +159,7 @@ HTML_DOCTYPE = "<!DOCTYPE html>"
148
159
  HTML_HEAD_TEMPLATE = """\
149
160
  <head>
150
161
  <meta charset="utf-8">
162
+ <meta name="viewport" content="width=device-width, initial-scale=1">
151
163
  <title>PR #{pr_number} Convergence Summary</title>
152
164
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
153
165
  {style_block}
@@ -156,9 +168,10 @@ HTML_HEAD_TEMPLATE = """\
156
168
  HTML_STYLE_BLOCK = """\
157
169
  <style>
158
170
  * { box-sizing: border-box; margin: 0; padding: 0; }
171
+ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
159
172
  body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #f1f5f9; color: #334155; line-height: 1.6; padding: 48px 20px; }
160
173
  .container { max-width: 860px; margin: 0 auto; }
161
- h1 { font-size: 30px; font-weight: 800; color: #0f172a; letter-spacing: -0.5px; }
174
+ h1 { font-size: clamp(22px, 6vw, 30px); font-weight: 800; color: #0f172a; letter-spacing: -0.5px; line-height: 1.2; }
162
175
  .subtitle { color: #64748b; font-size: 14px; margin: 6px 0 26px; }
163
176
  h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: #94a3b8; margin: 40px 0 16px; }
164
177
 
@@ -168,6 +181,14 @@ HTML_STYLE_BLOCK = """\
168
181
  .verdict .vtext { font-size: 16px; font-weight: 700; color:#0f172a; }
169
182
  .verdict .vsub { font-size: 13px; font-weight: 500; color:#15803d; }
170
183
 
184
+ /* scorecard */
185
+ .scorecard { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 14px; }
186
+ .stat { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:16px 14px; text-align:center; }
187
+ .stat-num { font-size: clamp(26px, 8vw, 34px); font-weight:800; color:#0f172a; line-height:1.1; }
188
+ .stat-label { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.5px; color:#94a3b8; margin-top:4px; }
189
+ .stat.good { border-color:#22c55e; background:linear-gradient(135deg,#f0fdf4,#dcfce7); }
190
+ .stat.good .stat-num { color:#16a34a; }
191
+
171
192
  /* problem / fix scenes */
172
193
  .pf-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
173
194
  .pf { border-radius: 12px; padding: 18px 20px; border:1px solid #e2e8f0; background:#fff; }
@@ -186,8 +207,10 @@ HTML_STYLE_BLOCK = """\
186
207
  .scene-cap:last-child { margin-bottom:0; }
187
208
 
188
209
  /* bug class */
189
- .bug-head { display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin:32px 0 12px; }
210
+ .bug-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:32px 0 12px; }
190
211
  .bug-head:first-of-type { margin-top:8px; }
212
+ .bug-title { display:flex; align-items:center; gap:8px; min-width:0; }
213
+ .bug-icon { font-size:18px; line-height:1; flex:0 0 auto; }
191
214
  .bug-name { font-size:16px; font-weight:700; color:#0f172a; }
192
215
  .bug-count { font-size:12px; font-weight:600; color:#64748b; background:#f1f5f9; border:1px solid #e2e8f0; border-radius:20px; padding:3px 11px; white-space:nowrap; flex:0 0 auto; }
193
216
 
@@ -222,5 +245,13 @@ HTML_STYLE_BLOCK = """\
222
245
 
223
246
  footer { margin-top:40px; padding-top:16px; border-top:1px solid #e2e8f0; color:#94a3b8; font-size:12px; }
224
247
  footer code { background:#e2e8f0; padding:1px 6px; border-radius:4px; font-family:'JetBrains Mono',monospace; }
225
- @media (max-width:680px){ .pf-grid,.term-grid{grid-template-columns:1fr;} }
248
+ @media (max-width:680px){
249
+ body{ padding:24px 14px; }
250
+ .pf-grid,.term-grid{ grid-template-columns:1fr; }
251
+ .scorecard{ gap:8px; }
252
+ .stat{ padding:12px 8px; }
253
+ .stat-label{ font-size:11px; }
254
+ .bug-head{ flex-wrap:wrap; gap:6px; }
255
+ h2{ margin:28px 0 12px; }
256
+ }
226
257
  </style>"""