claude-dev-env 8.74.1 → 8.75.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 (46) hide show
  1. package/.agents/skills/correction/SKILL.md +19 -16
  2. package/.agents/skills/correction/references/filing.md +1 -1
  3. package/.agents/skills/correction/scripts/correction_task_card.py +194 -0
  4. package/.agents/skills/correction/scripts/correction_task_card_constants/__init__.py +0 -0
  5. package/.agents/skills/correction/scripts/correction_task_card_constants/config/__init__.py +0 -0
  6. package/.agents/skills/correction/scripts/correction_task_card_constants/config/constants.py +38 -0
  7. package/.agents/skills/correction/scripts/test_correction_task_card.py +142 -0
  8. package/bin/cde.mjs +1 -1
  9. package/bin/cde.test.mjs +1 -1
  10. package/bin/install.mjs +1 -0
  11. package/bin/install.test.mjs +1 -0
  12. package/bin/merge_profile_settings.mjs +2 -3
  13. package/bin/merge_profile_settings.test.mjs +5 -5
  14. package/commands/fix-followups.md +5 -2
  15. package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
  16. package/docs/rule-guides/paired-test-coverage.md +1 -1
  17. package/docs/rule-guides/session-title.md +3 -3
  18. package/docs/templates/html-plan/README.md +1 -1
  19. package/hooks/features/conduct-gates.md +0 -3
  20. package/hooks/features/session-context.md +1 -1
  21. package/hooks/followup_ledger.py +106 -108
  22. package/hooks/followup_ledger_maintenance.py +127 -0
  23. package/hooks/hooks.json +0 -11
  24. package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
  25. package/hooks/hooks_constants/followup_ledger_constants.py +14 -4
  26. package/hooks/hooks_constants/session_title_constants.py +1 -29
  27. package/hooks/session/artifact_template_pointer.py +5 -4
  28. package/hooks/session/test_artifact_template_pointer.py +13 -0
  29. package/hooks/test_followup_ledger.py +78 -59
  30. package/hooks/test_followup_ledger_maintenance.py +107 -0
  31. package/package.json +1 -1
  32. package/rules/flag-non-breaking-findings.md +1 -1
  33. package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
  34. package/scripts/followup_cli.py +11 -12
  35. package/scripts/policy_lint/adapter_configuration.py +0 -1
  36. package/scripts/policy_lint/config/constants.py +0 -1
  37. package/scripts/repository_checks/config/constants.py +0 -6
  38. package/scripts/repository_checks/runner.py +0 -7
  39. package/scripts/test_followup_cli.py +71 -7
  40. package/scripts/tests/test_adapter_configuration.py +2 -5
  41. package/scripts/tests/test_cde_lint.py +3 -4
  42. package/scripts/tests/test_repository_policy.py +1 -3
  43. package/hooks/blocking/session_title_stop_gate.py +0 -217
  44. package/hooks/blocking/test_session_title_stop_gate.py +0 -295
  45. package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
  46. package/scripts/tests/test_followup_ledger_duplicates.py +0 -58
@@ -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.75.0",
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"
@@ -12,8 +12,8 @@ work. This command reads those records back.
12
12
  ``list`` names what is outstanding, ``ingest`` adds the diagnostics from a
13
13
  policy-lint JSON report, ``brief`` writes the task an agent works from, and
14
14
  ``clear`` empties the ledger once the follow-up pull request carries the
15
- fixes, and ``dedupe`` removes exact repeated lines a ``merge=union`` merge
16
- left behind.
15
+ fixes, and ``dedupe`` moves a legacy ``smells.jsonl`` into one file per
16
+ finding and removes duplicate finding files.
17
17
  """
18
18
 
19
19
  from __future__ import annotations
@@ -62,11 +62,10 @@ if _hooks_directory not in sys.path:
62
62
  from followup_ledger import (
63
63
  FollowupFinding,
64
64
  all_recorded_findings,
65
- deduplicate_ledger,
66
- followup_ledger_path,
67
65
  head_commit,
68
66
  record_followup_finding,
69
67
  )
68
+ from followup_ledger_maintenance import clear_recorded_findings, normalize_ledger
70
69
  from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
71
70
 
72
71
 
@@ -307,26 +306,26 @@ def _run_clear(repository_root: Path) -> int:
307
306
  Returns:
308
307
  SUCCESS_EXIT_CODE.
309
308
  """
310
- try:
311
- followup_ledger_path(repository_root).unlink(missing_ok=True)
312
- except OSError:
313
- return SUCCESS_EXIT_CODE
309
+ clear_recorded_findings(repository_root)
314
310
  return SUCCESS_EXIT_CODE
315
311
 
316
312
 
317
313
  def _run_dedupe(repository_root: Path, stdout: TextIO) -> int:
318
- """Remove exact repeated lines from the repository's ledger.
314
+ """Move every record to the file its tracking key names.
319
315
 
320
316
  Args:
321
317
  repository_root: The repository whose ledger to clean.
322
- stdout: The stream the removed-line count goes to.
318
+ stdout: The stream the migrated and removed counts go to.
323
319
 
324
320
  Returns:
325
321
  SUCCESS_EXIT_CODE.
326
322
  """
327
- removed_line_count = deduplicate_ledger(repository_root)
323
+ normalization = normalize_ledger(repository_root)
328
324
  stdout.write(
329
- DEDUPE_RESULT_TEMPLATE.format(removed_count=removed_line_count)
325
+ DEDUPE_RESULT_TEMPLATE.format(
326
+ migrated_count=normalization.migrated_count,
327
+ removed_count=normalization.removed_count,
328
+ )
330
329
  + LINE_SEPARATOR
331
330
  )
332
331
  return SUCCESS_EXIT_CODE
@@ -134,7 +134,6 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
134
134
  hooks/blocking/issue_close_handoff_gate.py -> exempt
135
135
  hooks/blocking/verify_before_acting.py -> exempt
136
136
  hooks/blocking/session_title_format_gate.py -> exempt
137
- hooks/blocking/session_title_stop_gate.py -> exempt
138
137
  hooks/blocking/artifact_dark_mode_gate.py -> exempt
139
138
  hooks/blocking/some_new_blocker.py -> flagged
140
139
 
@@ -70,7 +70,6 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
70
70
  "blocking/issue_close_handoff_gate.py",
71
71
  "blocking/verify_before_acting.py",
72
72
  "blocking/session_title_format_gate.py",
73
- "blocking/session_title_stop_gate.py",
74
73
  "blocking/image_read_size_gate.py",
75
74
  "blocking/artifact_dark_mode_gate.py",
76
75
  "blocking/haiku_spawn_slim_gate.py",
@@ -9,7 +9,6 @@ from policy_lint.config.constants import (
9
9
 
10
10
  CHECK_ID_CLAUDE_MD_ORPHANS = "claude-md-orphans"
11
11
  CHECK_ID_ENV_VAR_DOCUMENTATION = "env-var-documentation"
12
- CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES = "followup-ledger-duplicates"
13
12
  CHECK_ID_PACKAGE_INVENTORY = "package-inventory"
14
13
  CHECK_ID_PYTEST_TESTPATHS = "pytest-testpaths"
15
14
  CHECK_ID_TRACKED_PERSONAL_DATA = "tracked-secrets"
@@ -19,7 +18,6 @@ PRIVATE_TERMS_SKIPPED_FILE_NAME = "CHANGELOG.md"
19
18
  ALL_CHECK_IDS = (
20
19
  CHECK_ID_CLAUDE_MD_ORPHANS,
21
20
  CHECK_ID_ENV_VAR_DOCUMENTATION,
22
- CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
23
21
  CHECK_ID_PACKAGE_INVENTORY,
24
22
  CHECK_ID_PYTEST_TESTPATHS,
25
23
  CHECK_ID_TRACKED_PERSONAL_DATA,
@@ -31,7 +29,6 @@ SEVERITY_SMELL = "smell"
31
29
  SEVERITY_BY_CHECK_ID: dict[str, str] = {
32
30
  CHECK_ID_CLAUDE_MD_ORPHANS: SEVERITY_BREAKING,
33
31
  CHECK_ID_ENV_VAR_DOCUMENTATION: SEVERITY_BREAKING,
34
- CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES: SEVERITY_BREAKING,
35
32
  CHECK_ID_PACKAGE_INVENTORY: SEVERITY_SMELL,
36
33
  CHECK_ID_PYTEST_TESTPATHS: SEVERITY_BREAKING,
37
34
  CHECK_ID_TRACKED_PERSONAL_DATA: SEVERITY_BREAKING,
@@ -52,9 +49,6 @@ EMPTY_REPORT_TEXT = ""
52
49
  TRACKED_MATCH_MESSAGE_TEMPLATE = "[{category}] {preview}"
53
50
  CLAUDE_MD_MISSING_FILE_MESSAGE_TEMPLATE = "references missing file {filename}"
54
51
  PACKAGE_INVENTORY_MESSAGE_TEMPLATE = "production file is absent from package inventory"
55
- FOLLOWUP_LEDGER_DUPLICATE_MESSAGE_TEMPLATE = (
56
- "{repeated_line_count} lines repeat an earlier line; run `cde followup dedupe`"
57
- )
58
52
  PYTEST_TESTPATH_MESSAGE_TEMPLATE = (
59
53
  "test file is outside the package testpaths allowlist"
60
54
  )
@@ -11,9 +11,6 @@ from repository_checks.config import constants as repository_constants
11
11
  from repository_checks.env_var_documentation import (
12
12
  collect_env_var_documentation_findings,
13
13
  )
14
- from repository_checks.followup_ledger_duplicates import (
15
- collect_followup_ledger_duplicate_findings,
16
- )
17
14
  from repository_checks.models import RepositoryCheckReport, RepositoryFinding
18
15
  from repository_checks.package_inventory import collect_package_inventory_findings
19
16
  from repository_checks.pytest_testpaths import collect_pytest_testpath_findings
@@ -84,10 +81,6 @@ def _all_collectors() -> tuple[tuple[str, RepositoryCollector], ...]:
84
81
  repository_constants.CHECK_ID_ENV_VAR_DOCUMENTATION,
85
82
  collect_env_var_documentation_findings,
86
83
  ),
87
- (
88
- repository_constants.CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
89
- collect_followup_ledger_duplicate_findings,
90
- ),
91
84
  (
92
85
  repository_constants.CHECK_ID_PACKAGE_INVENTORY,
93
86
  collect_package_inventory_findings,
@@ -17,7 +17,8 @@ from followup_cli import main
17
17
  from followup_ledger import (
18
18
  FollowupFinding,
19
19
  all_recorded_findings,
20
- followup_ledger_path,
20
+ finding_path,
21
+ followup_directory,
21
22
  record_followup_finding,
22
23
  )
23
24
 
@@ -307,17 +308,80 @@ def test_list_names_the_check_behind_each_finding(tmp_path: Path) -> None:
307
308
  assert "f00dcafe" in output_text
308
309
 
309
310
 
310
- def test_dedupe_removes_repeated_ledger_lines_and_keeps_the_order(
311
+ def test_dedupe_moves_a_legacy_ledger_into_one_file_per_finding(
311
312
  tmp_path: Path,
312
313
  ) -> None:
313
- ledger_path = followup_ledger_path(tmp_path)
314
- ledger_path.parent.mkdir(parents=True)
315
- ledger_path.write_text("first\nsecond\nfirst\n", encoding="utf-8")
314
+ first_line = json.dumps({"rule_id": "r1", "file_path": "a.py", "message": "m1"})
315
+ second_line = json.dumps({"rule_id": "r2", "file_path": "b.py", "message": "m2"})
316
+ legacy_path = followup_directory(tmp_path) / "smells.jsonl"
317
+ legacy_path.parent.mkdir(parents=True)
318
+ legacy_path.write_text(
319
+ f"{first_line}\n{second_line}\n{first_line}\n", encoding="utf-8"
320
+ )
316
321
 
317
322
  exit_code, output_text = run_command(
318
323
  ["dedupe", "--repository-root", str(tmp_path)]
319
324
  )
320
325
 
321
326
  assert exit_code == 0
322
- assert output_text == DEDUPE_RESULT_TEMPLATE.format(removed_count=1) + "\n"
323
- assert ledger_path.read_text(encoding="utf-8") == "first\nsecond\n"
327
+ assert output_text == (
328
+ DEDUPE_RESULT_TEMPLATE.format(migrated_count=3, removed_count=0) + "\n"
329
+ )
330
+ assert not legacy_path.exists()
331
+ assert [each.rule_id for each in all_recorded_findings(tmp_path)] == ["r1", "r2"]
332
+
333
+
334
+ def test_dedupe_removes_a_misnamed_copy_of_a_recorded_finding(tmp_path: Path) -> None:
335
+ finding = FollowupFinding("r1", "a.py", "m1", "r1")
336
+ record_followup_finding(tmp_path, finding)
337
+ recorded_path = finding_path(tmp_path, finding)
338
+ copy_path = recorded_path.with_name("copy.json")
339
+ copy_path.write_text(recorded_path.read_text(encoding="utf-8"), encoding="utf-8")
340
+
341
+ exit_code, output_text = run_command(
342
+ ["dedupe", "--repository-root", str(tmp_path)]
343
+ )
344
+
345
+ assert exit_code == 0
346
+ assert output_text == (
347
+ DEDUPE_RESULT_TEMPLATE.format(migrated_count=0, removed_count=1) + "\n"
348
+ )
349
+ assert not copy_path.exists()
350
+ assert all_recorded_findings(tmp_path) == (finding,)
351
+
352
+
353
+ def test_fix_followups_reads_the_brief_then_clears_every_finding(
354
+ tmp_path: Path,
355
+ ) -> None:
356
+ report_path = tmp_path / "lint.json"
357
+ write_lint_report(
358
+ report_path,
359
+ [
360
+ {
361
+ "rule_id": "code-rules",
362
+ "check_id": "code-rules/constant-outside-config",
363
+ "message": "Constant TIMEOUT",
364
+ "location": {"path": "src/run.py"},
365
+ },
366
+ {
367
+ "rule_id": "test-pairing",
368
+ "message": "no paired test",
369
+ "location": {"path": "src/app.py"},
370
+ },
371
+ ],
372
+ )
373
+ root_arguments = ["--repository-root", str(tmp_path)]
374
+
375
+ ingest_exit_code, _ = run_command(["ingest", str(report_path), *root_arguments])
376
+ _, brief_text = run_command(["brief", *root_arguments])
377
+ clear_exit_code, _ = run_command(["clear", *root_arguments])
378
+ _, brief_after_clear = run_command(["brief", *root_arguments])
379
+
380
+ assert ingest_exit_code == 0
381
+ assert "- [code-rules/constant-outside-config] src/run.py: Constant TIMEOUT" in (
382
+ brief_text
383
+ )
384
+ assert "- [test-pairing] src/app.py: no paired test" in brief_text
385
+ assert clear_exit_code == 0
386
+ assert brief_after_clear == "no follow-ups recorded\n"
387
+ assert not list(followup_directory(tmp_path).glob("*.json"))
@@ -98,12 +98,9 @@ def test_new_verify_before_acting_registration_is_clean(tmp_path: Path) -> None:
98
98
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
99
99
 
100
100
 
101
- def test_new_session_title_gate_registrations_are_clean(tmp_path: Path) -> None:
101
+ def test_new_session_title_gate_registration_is_clean(tmp_path: Path) -> None:
102
102
  current_document = _hook_document(
103
- [
104
- "hooks/blocking/session_title_format_gate.py",
105
- "hooks/blocking/session_title_stop_gate.py",
106
- ],
103
+ ["hooks/blocking/session_title_format_gate.py"],
107
104
  '{"hooks": {}}',
108
105
  )
109
106
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
@@ -422,10 +422,9 @@ def test_should_exit_zero_and_record_a_followup_when_only_warnings_exist(
422
422
  lint_runner=lambda _request: warning_report,
423
423
  repository_root=tmp_path,
424
424
  )
425
- ledger_path = tmp_path / ".claude" / "followups" / "smells.jsonl"
426
425
  all_ledger_records = [
427
- json.loads(each_line)
428
- for each_line in ledger_path.read_text(encoding="utf-8").splitlines()
426
+ json.loads(each_path.read_text(encoding="utf-8"))
427
+ for each_path in (tmp_path / ".claude" / "followups").glob("*.json")
429
428
  ]
430
429
  assert exit_code == 0
431
430
  assert "paired test gap" in stdout_text
@@ -445,4 +444,4 @@ def test_should_leave_the_ledger_absent_when_every_finding_is_an_error(
445
444
  ),
446
445
  repository_root=tmp_path,
447
446
  )
448
- assert not (tmp_path / ".claude" / "followups" / "smells.jsonl").exists()
447
+ assert not list((tmp_path / ".claude" / "followups").glob("*.json"))
@@ -17,7 +17,6 @@ import repository_policy
17
17
  from repository_checks.config.constants import (
18
18
  CHECK_ID_CLAUDE_MD_ORPHANS,
19
19
  CHECK_ID_ENV_VAR_DOCUMENTATION,
20
- CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
21
20
  CHECK_ID_PACKAGE_INVENTORY,
22
21
  CHECK_ID_PYTEST_TESTPATHS,
23
22
  CHECK_ID_TRACKED_PERSONAL_DATA,
@@ -51,8 +50,7 @@ def test_should_keep_stable_check_identifiers() -> None:
51
50
  assert repository_policy.ALL_CHECK_IDS == (
52
51
  CHECK_ID_CLAUDE_MD_ORPHANS,
53
52
  CHECK_ID_ENV_VAR_DOCUMENTATION,
54
- CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
55
- CHECK_ID_PACKAGE_INVENTORY,
53
+ CHECK_ID_PACKAGE_INVENTORY,
56
54
  CHECK_ID_PYTEST_TESTPATHS,
57
55
  CHECK_ID_TRACKED_PERSONAL_DATA,
58
56
  CHECK_ID_TRACKED_PRIVATE_TERMS,