claude-dev-env 8.74.1 → 8.74.2

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 (39) hide show
  1. package/bin/cde.mjs +1 -1
  2. package/bin/cde.test.mjs +1 -1
  3. package/bin/install.mjs +1 -0
  4. package/bin/install.test.mjs +1 -0
  5. package/bin/merge_profile_settings.mjs +2 -3
  6. package/bin/merge_profile_settings.test.mjs +5 -5
  7. package/commands/fix-followups.md +5 -2
  8. package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
  9. package/docs/rule-guides/paired-test-coverage.md +1 -1
  10. package/docs/rule-guides/session-title.md +3 -3
  11. package/docs/templates/html-plan/README.md +1 -1
  12. package/hooks/features/conduct-gates.md +0 -3
  13. package/hooks/features/session-context.md +1 -1
  14. package/hooks/followup_ledger.py +106 -108
  15. package/hooks/followup_ledger_maintenance.py +127 -0
  16. package/hooks/hooks.json +0 -11
  17. package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
  18. package/hooks/hooks_constants/followup_ledger_constants.py +14 -4
  19. package/hooks/hooks_constants/session_title_constants.py +1 -29
  20. package/hooks/session/artifact_template_pointer.py +5 -4
  21. package/hooks/session/test_artifact_template_pointer.py +13 -0
  22. package/hooks/test_followup_ledger.py +78 -59
  23. package/hooks/test_followup_ledger_maintenance.py +107 -0
  24. package/package.json +1 -1
  25. package/rules/flag-non-breaking-findings.md +1 -1
  26. package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
  27. package/scripts/followup_cli.py +11 -12
  28. package/scripts/policy_lint/adapter_configuration.py +0 -1
  29. package/scripts/policy_lint/config/constants.py +0 -1
  30. package/scripts/repository_checks/config/constants.py +0 -6
  31. package/scripts/repository_checks/runner.py +0 -7
  32. package/scripts/test_followup_cli.py +71 -7
  33. package/scripts/tests/test_adapter_configuration.py +2 -5
  34. package/scripts/tests/test_cde_lint.py +3 -4
  35. package/scripts/tests/test_repository_policy.py +1 -3
  36. package/hooks/blocking/session_title_stop_gate.py +0 -217
  37. package/hooks/blocking/test_session_title_stop_gate.py +0 -295
  38. package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
  39. package/scripts/tests/test_followup_ledger_duplicates.py +0 -58
@@ -0,0 +1,127 @@
1
+ """Clear and normalize the follow-up ledger directory.
2
+
3
+ ``cde followup clear`` and ``cde followup dedupe`` run these. The gates that
4
+ record findings use only ``followup_ledger``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import NamedTuple
12
+
13
+ _hooks_directory = str(Path(__file__).resolve().parent)
14
+ if _hooks_directory not in sys.path:
15
+ sys.path.insert(0, _hooks_directory)
16
+
17
+ from followup_ledger import (
18
+ all_finding_files,
19
+ all_record_paths,
20
+ finding_from_text,
21
+ finding_path,
22
+ followup_directory,
23
+ record_followup_finding,
24
+ )
25
+ from hooks_constants.followup_ledger_constants import (
26
+ LEDGER_ENCODING,
27
+ LEGACY_LEDGER_FILE_NAME,
28
+ )
29
+
30
+
31
+ def clear_recorded_findings(repository_root: Path) -> None:
32
+ """Remove every recorded finding and any legacy ledger file.
33
+
34
+ The directory and its ``.gitignore`` stay. A file that cannot be removed
35
+ is left in place.
36
+
37
+ Args:
38
+ repository_root: The repository whose ledger to empty.
39
+ """
40
+ directory = followup_directory(repository_root)
41
+ all_paths = [*all_record_paths(directory), directory / LEGACY_LEDGER_FILE_NAME]
42
+ for each_path in all_paths:
43
+ try:
44
+ each_path.unlink(missing_ok=True)
45
+ except OSError:
46
+ continue
47
+
48
+
49
+ class LedgerNormalization(NamedTuple):
50
+ """What one ``normalize_ledger`` run changed.
51
+
52
+ Attributes:
53
+ migrated_count: Legacy ledger lines written as finding files.
54
+ removed_count: Finding files removed as duplicates.
55
+ """
56
+
57
+ migrated_count: int
58
+ removed_count: int
59
+
60
+
61
+ def normalize_ledger(repository_root: Path) -> LedgerNormalization:
62
+ """Move every record to the file its tracking key names.
63
+
64
+ A legacy ``smells.jsonl`` ledger is split into one file per finding and
65
+ removed. A finding file whose name does not match its tracking key moves
66
+ to the matching name, or is removed when that name already holds the
67
+ finding.
68
+
69
+ Args:
70
+ repository_root: The repository whose ledger to normalize.
71
+
72
+ Returns:
73
+ How many legacy lines moved and how many duplicate files left.
74
+ """
75
+ migrated_count = _migrate_legacy_ledger(repository_root)
76
+ all_pairs = all_finding_files(repository_root)
77
+ all_readable_paths = {each_path for each_path, _each_finding in all_pairs}
78
+ removed_count = sum(
79
+ _settle_finding_file(
80
+ each_path,
81
+ finding_path(repository_root, each_finding),
82
+ all_readable_paths,
83
+ )
84
+ for each_path, each_finding in all_pairs
85
+ )
86
+ return LedgerNormalization(migrated_count, removed_count)
87
+
88
+
89
+ def _settle_finding_file(
90
+ current_path: Path, expected_path: Path, all_readable_paths: set[Path]
91
+ ) -> int:
92
+ if current_path == expected_path:
93
+ return 0
94
+ try:
95
+ if expected_path in all_readable_paths:
96
+ current_path.unlink()
97
+ return 1
98
+ if not expected_path.exists():
99
+ current_path.rename(expected_path)
100
+ except OSError:
101
+ return 0
102
+ return 0
103
+
104
+
105
+ def _migrate_legacy_ledger(repository_root: Path) -> int:
106
+ legacy_path = followup_directory(repository_root) / LEGACY_LEDGER_FILE_NAME
107
+ try:
108
+ legacy_text = legacy_path.read_text(encoding=LEDGER_ENCODING)
109
+ except (OSError, UnicodeError):
110
+ return 0
111
+ all_legacy_findings = [
112
+ each_finding
113
+ for each_finding in map(finding_from_text, legacy_text.splitlines())
114
+ if each_finding is not None
115
+ ]
116
+ for each_finding in all_legacy_findings:
117
+ record_followup_finding(repository_root, each_finding)
118
+ if not all(
119
+ finding_path(repository_root, each_finding).is_file()
120
+ for each_finding in all_legacy_findings
121
+ ):
122
+ return 0
123
+ try:
124
+ legacy_path.unlink()
125
+ except OSError:
126
+ return 0
127
+ return len(all_legacy_findings)
package/hooks/hooks.json CHANGED
@@ -332,17 +332,6 @@
332
332
  ]
333
333
  }
334
334
  ],
335
- "Stop": [
336
- {
337
- "hooks": [
338
- {
339
- "type": "command",
340
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_title_stop_gate.py",
341
- "timeout": 10
342
- }
343
- ]
344
- }
345
- ],
346
335
  "PostToolUse": [
347
336
  {
348
337
  "matcher": "Write|Edit",
@@ -26,7 +26,11 @@ ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
26
26
  TEMPLATES_DIRECTORY = Path(__file__).resolve().parents[2] / "docs" / "templates"
27
27
  ARTIFACT_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "README.md"
28
28
  HTML_PLAN_TEMPLATE_README_PATH = TEMPLATES_DIRECTORY / "html-plan" / "README.md"
29
- POINTER_TEXT_PREFIX = "Build this artifact page from the html-plan skill, following "
29
+ POINTER_TEXT_PREFIX = (
30
+ "Pick the visual by scope. For one flow or one comparison, draw an inline SVG"
31
+ " flowchart artifact with minimal text, using the artifact-diagramming skill."
32
+ " For a multi-part plan or evidence, build an html-plan page following "
33
+ )
30
34
  POINTER_ALTERNATIVE_PREFIX = (
31
35
  ". When the request names the older artifact page template,"
32
36
  " pick the one that fits the ask from "
@@ -3,9 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  __all__ = [
6
- "ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS",
6
+ "ALL_FOLLOWUP_DIRECTORY_SEGMENTS",
7
+ "LEGACY_LEDGER_FILE_NAME",
8
+ "FINDING_FILE_SUFFIX",
9
+ "FINDING_FILE_PATTERN",
10
+ "FINDING_FILE_NAME_HASH_LENGTH",
7
11
  "LEDGER_ENCODING",
8
- "LEDGER_APPEND_MODE",
12
+ "LEDGER_CREATE_MODE",
13
+ "LEDGER_JSON_INDENT",
9
14
  "LEDGER_IGNORE_FILE_NAME",
10
15
  "LEDGER_IGNORE_TEXT",
11
16
  "RULE_ID_KEY",
@@ -28,9 +33,14 @@ __all__ = [
28
33
  "ALL_SEVERITY_NAMES",
29
34
  ]
30
35
 
31
- ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS: tuple[str, ...] = (".claude", "followups", "smells.jsonl")
36
+ ALL_FOLLOWUP_DIRECTORY_SEGMENTS: tuple[str, ...] = (".claude", "followups")
37
+ LEGACY_LEDGER_FILE_NAME: str = "smells.jsonl"
38
+ FINDING_FILE_SUFFIX: str = ".json"
39
+ FINDING_FILE_PATTERN: str = "*.json"
40
+ FINDING_FILE_NAME_HASH_LENGTH: int = 16
32
41
  LEDGER_ENCODING: str = "utf-8"
33
- LEDGER_APPEND_MODE: str = "a"
42
+ LEDGER_CREATE_MODE: str = "x"
43
+ LEDGER_JSON_INDENT: int = 2
34
44
  LEDGER_IGNORE_FILE_NAME: str = ".gitignore"
35
45
  LEDGER_IGNORE_TEXT: str = "*\n"
36
46
 
@@ -1,21 +1,14 @@
1
- """Constants for session_title_format_gate.py and session_title_stop_gate.py."""
1
+ """Constants for session_title_format_gate.py."""
2
2
 
3
3
  import re
4
4
 
5
5
  TITLE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__set_session_title$")
6
- REMOTE_SESSION_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_REMOTE_SESSION_ID"
7
6
  TITLE_INPUT_KEY = "title"
8
7
  TOOL_NAME_KEY = "tool_name"
9
8
  TOOL_INPUT_KEY = "tool_input"
10
- TRANSCRIPT_PATH_KEY = "transcript_path"
11
- STOP_HOOK_ACTIVE_KEY = "stop_hook_active"
12
9
  FORMAT_GATE_EVENT_NAME = "PreToolUse"
13
- STOP_GATE_EVENT_NAME = "Stop"
14
10
  ALLOW_EXIT_CODE = 0
15
11
  BLOCK_EXIT_CODE = 2
16
- BLOCK_DECISION = "block"
17
- DEFERRED_TOOL_ATTACHMENT_TYPES = frozenset({"deferred_tools_delta", "deferred_tools_record"})
18
- HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE = "hook_blocking_error"
19
12
 
20
13
  STATUS_PREFIX_PATTERN = re.compile("^(\U0001f6a9|\u2705|\u23f3)\ufe0f? (?=\\S)")
21
14
  MAXIMUM_NAME_LENGTH = 25
@@ -38,24 +31,3 @@ FORMAT_RETRY_INSTRUCTION = (
38
31
  " the main change or outcome in concrete nouns, such as"
39
32
  " '\u23f3 Broker gate + replay rule'."
40
33
  )
41
-
42
- STOP_BLOCK_REASON = (
43
- "Before you end this turn, set the session title to its current status."
44
- " Call the session title tool ({tool_name}); when it asks for a session id,"
45
- " get it from get_session with no session_id."
46
- " Use the shape '<emoji> <name>'."
47
- " Emoji: \U0001f6a9 when the user must act (a question, an approval, or a step only the user can do);"
48
- " \u2705 when the work is done (merged, or nothing left);"
49
- " \u23f3 while work, CI or a merge queue still runs and nothing waits on the user."
50
- " Name: 25 characters or fewer, the main change or outcome in concrete nouns,"
51
- " two parts joined with ' + ', sentence case, no end punctuation,"
52
- " no dates, IDs, branch names or filler words."
53
- " Rename it when the scope of the work has changed."
54
- " The title is a silent step: make the call and end the turn."
55
- " Say nothing to the user about the title, its emoji or this reminder."
56
- " When the user already has your answer this turn, end with no text."
57
- " Next time, set the title before your final reply."
58
- )
59
- REMOTE_TITLE_TOOL_NAME = "mcp__claude-code-remote__set_session_title"
60
- REMOTE_SERVER_TOOL_PREFIX = "mcp__claude-code-remote__"
61
- UNKNOWN_TITLE_TOOL_NAME = "its name ends in __set_session_title"
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env python3
2
- """PreToolUse hook: point an agent about to build an artifact page at html-plan.
2
+ """PreToolUse hook: route an agent about to build an artifact page by scope.
3
3
 
4
4
  Registered on ``Artifact|Skill``. It adds one line of ``additionalContext``
5
- naming ``docs/templates/html-plan/README.md`` as the default and
6
- ``docs/templates/artifact-page/README.md`` as the alternative when a
7
- session starts a new artifact page:
5
+ when a session starts a new artifact page. One flow or one comparison is an
6
+ inline SVG flowchart with minimal text. A multi-part plan or evidence is an
7
+ html-plan page from ``docs/templates/html-plan/README.md``, and
8
+ ``docs/templates/artifact-page/README.md`` stays the alternative:
8
9
 
9
10
  ::
10
11
 
@@ -44,6 +44,19 @@ def test_artifact_quickstart_gets_the_template_pointer() -> None:
44
44
  assert "permissionDecision" not in specific_output
45
45
 
46
46
 
47
+ def test_pointer_routes_a_simple_visual_to_a_flowchart_ahead_of_html_plan() -> None:
48
+ hook_output = artifact_template_pointer.decide_hook_output(
49
+ _payload("Artifact", {"action": "quickstart", "intent": "other"})
50
+ )
51
+ assert hook_output is not None
52
+ pointer_text = hook_output["hookSpecificOutput"]["additionalContext"]
53
+ assert pointer_text.index("one flow or one comparison") < pointer_text.index("flowchart")
54
+ assert pointer_text.index("flowchart") < pointer_text.index("multi-part plan or evidence")
55
+ assert pointer_text.index("multi-part plan or evidence") < pointer_text.index(
56
+ str(HTML_PLAN_TEMPLATE_README_PATH)
57
+ )
58
+
59
+
47
60
  def test_artifact_design_skill_gets_the_template_pointer() -> None:
48
61
  hook_output = artifact_template_pointer.decide_hook_output(
49
62
  _payload("Skill", {"skill": "artifact-design"})
@@ -12,9 +12,8 @@ if _hooks_directory not in sys.path:
12
12
  from followup_ledger import (
13
13
  FollowupFinding,
14
14
  all_recorded_findings,
15
- deduplicate_ledger,
16
- deduplicated_lines,
17
- followup_ledger_path,
15
+ finding_path,
16
+ followup_directory,
18
17
  head_commit,
19
18
  record_followup_finding,
20
19
  )
@@ -62,27 +61,29 @@ def test_all_recorded_findings_is_empty_without_a_ledger(tmp_path: Path) -> None
62
61
  assert all_recorded_findings(tmp_path) == ()
63
62
 
64
63
 
65
- def test_all_recorded_findings_skips_an_unreadable_line(tmp_path: Path) -> None:
64
+ def test_all_recorded_findings_skips_an_unreadable_file(tmp_path: Path) -> None:
66
65
  finding = FollowupFinding(
67
66
  "instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
68
67
  )
69
68
  record_followup_finding(tmp_path, finding)
70
- ledger_path = followup_ledger_path(tmp_path)
71
- with ledger_path.open("a", encoding="utf-8") as ledger_file:
72
- ledger_file.write("{ not json\n")
69
+ (followup_directory(tmp_path) / "damaged.json").write_text(
70
+ "{ not json\n", encoding="utf-8"
71
+ )
73
72
 
74
73
  assert all_recorded_findings(tmp_path) == (finding,)
75
74
 
76
75
 
77
- def test_record_followup_finding_writes_one_json_object_per_line(tmp_path: Path) -> None:
78
- record_followup_finding(
79
- tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
80
- )
76
+ def test_record_followup_finding_writes_one_json_file_per_finding(
77
+ tmp_path: Path,
78
+ ) -> None:
79
+ finding = FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
81
80
 
82
- all_lines = followup_ledger_path(tmp_path).read_text(encoding="utf-8").splitlines()
81
+ record_followup_finding(tmp_path, finding)
83
82
 
84
- assert len(all_lines) == 1
85
- assert json.loads(all_lines[0])["rule_id"] == "instruction-mode"
83
+ all_record_paths = list(followup_directory(tmp_path).glob("*.json"))
84
+ assert all_record_paths == [finding_path(tmp_path, finding)]
85
+ recorded = json.loads(all_record_paths[0].read_text(encoding="utf-8"))
86
+ assert recorded["rule_id"] == "instruction-mode"
86
87
 
87
88
 
88
89
  def test_a_recorded_ledger_stays_out_of_git_status(tmp_path: Path) -> None:
@@ -98,7 +99,7 @@ def test_a_recorded_ledger_stays_out_of_git_status(tmp_path: Path) -> None:
98
99
  capture_output=True,
99
100
  text=True,
100
101
  )
101
- assert followup_ledger_path(tmp_path).is_file()
102
+ assert any(followup_directory(tmp_path).glob("*.json"))
102
103
  assert status_result.stdout == ""
103
104
 
104
105
 
@@ -133,9 +134,9 @@ def test_a_recorded_finding_carries_its_check_severity_and_origin(
133
134
  def test_a_record_written_without_the_tracking_fields_still_reads(
134
135
  tmp_path: Path,
135
136
  ) -> None:
136
- ledger_path = followup_ledger_path(tmp_path)
137
- ledger_path.parent.mkdir(parents=True, exist_ok=True)
138
- ledger_path.write_text(
137
+ record_path = followup_directory(tmp_path) / "older.json"
138
+ record_path.parent.mkdir(parents=True, exist_ok=True)
139
+ record_path.write_text(
139
140
  json.dumps(
140
141
  {
141
142
  "rule_id": "instruction-mode",
@@ -268,57 +269,75 @@ def test_head_commit_reads_a_packed_branch_from_a_linked_worktree(
268
269
  assert head_commit(worktree_root) == expected_commit
269
270
 
270
271
 
271
- def test_deduplicated_lines_keeps_the_first_of_each_exact_line_in_order() -> None:
272
- assert deduplicated_lines(["a", "b", "a", "c", "b"]) == ["a", "b", "c"]
272
+ def test_the_file_name_depends_on_the_tracking_key_alone(tmp_path: Path) -> None:
273
+ first = FollowupFinding("r1", "a.py", "m1", "r1", "smell", "aaa1111")
274
+ later = first._replace(origin_commit="bbb2222")
275
+ other = first._replace(message="m2")
273
276
 
277
+ assert finding_path(tmp_path, first) == finding_path(tmp_path, later)
278
+ assert finding_path(tmp_path, first) != finding_path(tmp_path, other)
274
279
 
275
- def test_record_followup_finding_removes_lines_a_union_merge_repeated(
276
- tmp_path: Path,
277
- ) -> None:
278
- ledger_path = followup_ledger_path(tmp_path)
279
- ledger_path.parent.mkdir(parents=True)
280
- first_line = '{"rule_id": "r1", "file_path": "a.py", "message": "m1"}'
281
- second_line = '{"rule_id": "r2", "file_path": "b.py", "message": "m2"}'
282
- ledger_path.write_text(
283
- f"{first_line}\n{second_line}\n{first_line}\n", encoding="utf-8"
284
- )
285
280
 
286
- record_followup_finding(tmp_path, FollowupFinding("r3", "c.py", "m3", "r3"))
287
-
288
- all_ledger_lines = ledger_path.read_text(encoding="utf-8").splitlines()
289
- assert all_ledger_lines[:2] == [first_line, second_line]
290
- assert [json.loads(each_line)["rule_id"] for each_line in all_ledger_lines] == [
291
- "r1",
292
- "r2",
293
- "r3",
294
- ]
281
+ def _git(repository_root: Path, *all_arguments: str) -> subprocess.CompletedProcess:
282
+ return subprocess.run(
283
+ [
284
+ "git",
285
+ "-C",
286
+ str(repository_root),
287
+ "-c",
288
+ "user.name=t",
289
+ "-c",
290
+ "user.email=t@t",
291
+ *all_arguments,
292
+ ],
293
+ check=False,
294
+ capture_output=True,
295
+ text=True,
296
+ )
295
297
 
296
298
 
297
- def test_record_followup_finding_removes_a_repeat_of_an_already_recorded_finding(
298
- tmp_path: Path,
299
- ) -> None:
300
- finding = FollowupFinding("r1", "a.py", "m1", "r1")
301
- record_followup_finding(tmp_path, finding)
302
- ledger_path = followup_ledger_path(tmp_path)
303
- recorded_text = ledger_path.read_text(encoding="utf-8")
304
- ledger_path.write_text(recorded_text * 2, encoding="utf-8")
299
+ def _commit_finding_on_branch(
300
+ repository_root: Path, branch_name: str, finding: FollowupFinding
301
+ ) -> set[str]:
302
+ _git(repository_root, "checkout", "--quiet", "-b", branch_name, "main")
303
+ record_followup_finding(repository_root, finding)
304
+ _git(repository_root, "add", "--all")
305
+ _git(repository_root, "commit", "--quiet", "-m", branch_name)
306
+ changed_text = _git(
307
+ repository_root, "diff", "--name-only", "main", branch_name
308
+ ).stdout
309
+ return set(changed_text.split())
305
310
 
306
- record_followup_finding(tmp_path, finding)
307
311
 
308
- assert ledger_path.read_text(encoding="utf-8") == recorded_text
312
+ def test_two_parallel_writers_share_no_file_and_merge_cleanly(tmp_path: Path) -> None:
313
+ repository_root = tmp_path / "repo"
314
+ subprocess.run(
315
+ ["git", "init", "--quiet", "-b", "main", str(repository_root)], check=True
316
+ )
317
+ ignore_path = followup_directory(repository_root) / ".gitignore"
318
+ ignore_path.parent.mkdir(parents=True)
319
+ ignore_path.write_text("!*.json\n", encoding="utf-8")
320
+ _git(repository_root, "add", "--all")
321
+ _git(repository_root, "commit", "--quiet", "-m", "start")
322
+ first = FollowupFinding("r1", "a.py", "m1", "r1")
323
+ second = FollowupFinding("r2", "b.py", "m2", "r2")
309
324
 
325
+ first_files = _commit_finding_on_branch(repository_root, "first", first)
326
+ second_files = _commit_finding_on_branch(repository_root, "second", second)
327
+ _git(repository_root, "checkout", "--quiet", "first")
328
+ merge_result = _git(repository_root, "merge", "--no-edit", "second")
310
329
 
311
- def test_deduplicate_ledger_reports_how_many_lines_it_removed(tmp_path: Path) -> None:
312
- ledger_path = followup_ledger_path(tmp_path)
313
- ledger_path.parent.mkdir(parents=True)
314
- ledger_path.write_text("x\ny\nx\nx\n", encoding="utf-8")
330
+ assert first_files and second_files
331
+ assert not first_files & second_files
332
+ assert merge_result.returncode == 0, merge_result.stdout + merge_result.stderr
333
+ assert all_recorded_findings(repository_root) == (first, second)
315
334
 
316
- removed_count = deduplicate_ledger(tmp_path)
317
335
 
318
- assert removed_count == 2
319
- assert ledger_path.read_text(encoding="utf-8") == "x\ny\n"
336
+ def test_a_committed_ignore_file_is_left_as_it_stands(tmp_path: Path) -> None:
337
+ ignore_path = followup_directory(tmp_path) / ".gitignore"
338
+ ignore_path.parent.mkdir(parents=True)
339
+ ignore_path.write_text("!*.json\n", encoding="utf-8")
320
340
 
341
+ record_followup_finding(tmp_path, FollowupFinding("r1", "a.py", "m1", "r1"))
321
342
 
322
- def test_deduplicate_ledger_leaves_an_absent_ledger_absent(tmp_path: Path) -> None:
323
- assert deduplicate_ledger(tmp_path) == 0
324
- assert not followup_ledger_path(tmp_path).exists()
343
+ assert ignore_path.read_text(encoding="utf-8") == "!*.json\n"
@@ -0,0 +1,107 @@
1
+ """Behavior tests for clearing and normalizing the follow-up ledger."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _hooks_directory = str(Path(__file__).resolve().parent)
8
+ if _hooks_directory not in sys.path:
9
+ sys.path.insert(0, _hooks_directory)
10
+
11
+ from followup_ledger import (
12
+ FollowupFinding,
13
+ all_recorded_findings,
14
+ finding_path,
15
+ followup_directory,
16
+ record_followup_finding,
17
+ )
18
+ from followup_ledger_maintenance import clear_recorded_findings, normalize_ledger
19
+
20
+
21
+ def test_clear_removes_every_finding_and_the_legacy_ledger_but_keeps_the_ignore(
22
+ tmp_path: Path,
23
+ ) -> None:
24
+ record_followup_finding(tmp_path, FollowupFinding("r1", "a.py", "m1", "r1"))
25
+ record_followup_finding(tmp_path, FollowupFinding("r2", "b.py", "m2", "r2"))
26
+ legacy_path = followup_directory(tmp_path) / "smells.jsonl"
27
+ legacy_path.write_text("{}\n", encoding="utf-8")
28
+
29
+ clear_recorded_findings(tmp_path)
30
+
31
+ assert all_recorded_findings(tmp_path) == ()
32
+ assert not legacy_path.exists()
33
+ assert (followup_directory(tmp_path) / ".gitignore").is_file()
34
+
35
+
36
+ def test_normalize_splits_a_legacy_ledger_and_keeps_the_first_origin(
37
+ tmp_path: Path,
38
+ ) -> None:
39
+ first_record = {
40
+ "rule_id": "r1",
41
+ "file_path": "a.py",
42
+ "message": "m1",
43
+ "origin_commit": "aaa1111",
44
+ }
45
+ later_record = {**first_record, "origin_commit": "bbb2222"}
46
+ legacy_path = followup_directory(tmp_path) / "smells.jsonl"
47
+ legacy_path.parent.mkdir(parents=True)
48
+ legacy_path.write_text(
49
+ f"{json.dumps(first_record)}\n{json.dumps(later_record)}\nnot json\n",
50
+ encoding="utf-8",
51
+ )
52
+
53
+ normalization = normalize_ledger(tmp_path)
54
+
55
+ assert normalization.migrated_count == 2
56
+ assert normalization.removed_count == 0
57
+ assert not legacy_path.exists()
58
+ assert [each.origin_commit for each in all_recorded_findings(tmp_path)] == [
59
+ "aaa1111"
60
+ ]
61
+
62
+
63
+ def test_normalize_renames_a_misnamed_finding_file(tmp_path: Path) -> None:
64
+ finding = FollowupFinding("r1", "a.py", "m1", "r1")
65
+ record_followup_finding(tmp_path, finding)
66
+ expected_path = finding_path(tmp_path, finding)
67
+ misnamed_path = expected_path.with_name("misnamed.json")
68
+ expected_path.rename(misnamed_path)
69
+
70
+ normalization = normalize_ledger(tmp_path)
71
+
72
+ assert normalization.removed_count == 0
73
+ assert expected_path.is_file()
74
+ assert not misnamed_path.exists()
75
+
76
+
77
+ def test_normalize_keeps_the_legacy_ledger_when_a_finding_cannot_be_written(
78
+ tmp_path: Path,
79
+ ) -> None:
80
+ legacy_path = followup_directory(tmp_path) / "smells.jsonl"
81
+ legacy_path.parent.mkdir(parents=True)
82
+ legacy_text = json.dumps({"rule_id": "r1", "file_path": "a.py", "message": "m1"})
83
+ legacy_path.write_text(legacy_text + "\n", encoding="utf-8")
84
+ blocked_path = finding_path(tmp_path, FollowupFinding("r1", "a.py", "m1", "r1"))
85
+ blocked_path.mkdir()
86
+
87
+ normalization = normalize_ledger(tmp_path)
88
+
89
+ assert normalization.migrated_count == 0
90
+ assert legacy_path.read_text(encoding="utf-8") == legacy_text + "\n"
91
+
92
+
93
+ def test_normalize_keeps_a_misnamed_finding_when_its_name_holds_a_damaged_file(
94
+ tmp_path: Path,
95
+ ) -> None:
96
+ finding = FollowupFinding("r1", "a.py", "m1", "r1")
97
+ record_followup_finding(tmp_path, finding)
98
+ expected_path = finding_path(tmp_path, finding)
99
+ misnamed_path = expected_path.with_name("misnamed.json")
100
+ expected_path.rename(misnamed_path)
101
+ expected_path.write_text("{ damaged\n", encoding="utf-8")
102
+
103
+ normalization = normalize_ledger(tmp_path)
104
+
105
+ assert normalization.removed_count == 0
106
+ assert misnamed_path.is_file()
107
+ assert all_recorded_findings(tmp_path) == (finding,)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.74.1",
3
+ "version": "8.74.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ paths:
13
13
 
14
14
  **When:** Writing a local gate or changing a check's severity.
15
15
 
16
- Give every check ID a breaking or smell severity row; style and structure findings are smells. Fail the gate and stop for a bug, secret, broken test, syntax error, or instruction file that cannot load. Record a smell in `.claude/followups/smells.jsonl`, proceed, and fix it later in one combined follow-up pull request per parent pull request, whose body names the parent as `Follow-up to #N`; ledger failure leaves the gate decision unchanged. Treat an unclassified check as breaking.
16
+ Give every check ID a breaking or smell severity row; style and structure findings are smells. Fail the gate and stop for a bug, secret, broken test, syntax error, or instruction file that cannot load. Record a smell as one file in `.claude/followups/`, proceed, and fix it later in one combined follow-up pull request per parent pull request, whose body names the parent as `Follow-up to #N`; ledger failure leaves the gate decision unchanged. Treat an unclassified check as breaking.
17
17
 
18
18
  **Enforcement:** none.
19
19
 
@@ -63,7 +63,10 @@ ABSENT_LOCATION_PATH: str = ""
63
63
  LINE_SEPARATOR: str = "\n"
64
64
  EMPTY_LEDGER_MESSAGE: str = "no follow-ups recorded"
65
65
  UNREADABLE_REPORT_TEMPLATE: str = "cannot read the lint report: {report_path}"
66
- DEDUPE_RESULT_TEMPLATE: str = "removed {removed_count} duplicate ledger lines"
66
+ DEDUPE_RESULT_TEMPLATE: str = (
67
+ "moved {migrated_count} legacy ledger lines into finding files, "
68
+ "removed {removed_count} duplicate finding files"
69
+ )
67
70
  USAGE_TEXT: str = (
68
71
  "Usage: followup_cli.py <list|ingest|brief|clear|count|dedupe> "
69
72
  "[--repository-root PATH]\n"