claude-dev-env 8.74.0 → 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 +179 -116
  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 +26 -6
  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 +151 -55
  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,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.0",
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"
@@ -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,
@@ -1,217 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Stop hook that ends each turn only after the session title shows its status.
3
-
4
- A turn starts at the latest user prompt in the transcript, skipping tool
5
- results and meta entries. The turn may end once a tool whose name ends in
6
- __set_session_title ran in it without error. Otherwise the hook blocks the
7
- stop once and tells the model to set '<emoji> <name>'.
8
-
9
- The hook stays silent when:
10
-
11
- - the stop is already a retry after a block (stop_hook_active is true);
12
- - the session shows no title tool: no title tool named anywhere in the
13
- transcript, and either no remote session id in the environment or a
14
- deferred tool listing that names other remote-server tools and leaves
15
- the title tool out, or an earlier Stop block the session never met;
16
- - the transcript cannot be read.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import json
22
- import os
23
- import sys
24
- from pathlib import Path
25
-
26
- hooks_root_directory = str(Path(__file__).resolve().parent.parent)
27
- if hooks_root_directory not in sys.path:
28
- sys.path.insert(0, hooks_root_directory)
29
-
30
- from hooks_constants.hook_block_logger import log_hook_block
31
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
32
- from hooks_constants.session_title_constants import (
33
- ALLOW_EXIT_CODE,
34
- BLOCK_DECISION,
35
- DEFERRED_TOOL_ATTACHMENT_TYPES,
36
- HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE,
37
- REMOTE_SERVER_TOOL_PREFIX,
38
- REMOTE_SESSION_ENVIRONMENT_VARIABLE,
39
- REMOTE_TITLE_TOOL_NAME,
40
- STOP_BLOCK_REASON,
41
- STOP_GATE_EVENT_NAME,
42
- STOP_HOOK_ACTIVE_KEY,
43
- TITLE_TOOL_NAME_PATTERN,
44
- TRANSCRIPT_PATH_KEY,
45
- UNKNOWN_TITLE_TOOL_NAME,
46
- )
47
- from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
48
-
49
-
50
- def _content_blocks(all_entry_fields: dict[str, object]) -> list[dict[str, object]]:
51
- message = all_entry_fields.get("message")
52
- content = message.get("content") if isinstance(message, dict) else None
53
- if not isinstance(content, list):
54
- return []
55
- return [each_block for each_block in content if isinstance(each_block, dict)]
56
-
57
-
58
- def _is_user_prompt(all_entry_fields: dict[str, object]) -> bool:
59
- if all_entry_fields.get("type") != "user" or all_entry_fields.get("isMeta") is True:
60
- return False
61
- message = all_entry_fields.get("message")
62
- if isinstance(message, dict) and isinstance(message.get("content"), str):
63
- return True
64
- return any(each_block.get("type") == "text" for each_block in _content_blocks(all_entry_fields))
65
-
66
-
67
- def _title_tool_names_in(all_entry_fields: dict[str, object]) -> list[str]:
68
- all_names: list[str] = []
69
- if all_entry_fields.get("type") == "assistant":
70
- all_names = [
71
- str(each_block.get("name"))
72
- for each_block in _content_blocks(all_entry_fields)
73
- if each_block.get("type") == "tool_use"
74
- ]
75
- attachment = all_entry_fields.get("attachment")
76
- if isinstance(attachment, dict) and attachment.get("type") in DEFERRED_TOOL_ATTACHMENT_TYPES:
77
- all_names += [str(each_name) for each_name in attachment.get("addedNames") or []]
78
- all_names += [
79
- str(each_record.get("name"))
80
- for each_record in attachment.get("entries") or []
81
- if isinstance(each_record, dict)
82
- ]
83
- return [each_name for each_name in all_names if TITLE_TOOL_NAME_PATTERN.match(each_name)]
84
-
85
-
86
- def _defers_remote_server_tools(all_entry_fields: dict[str, object]) -> bool:
87
- attachment = all_entry_fields.get("attachment")
88
- if not isinstance(attachment, dict) or attachment.get("type") not in DEFERRED_TOOL_ATTACHMENT_TYPES:
89
- return False
90
- all_names = [str(each_name) for each_name in attachment.get("addedNames") or []]
91
- all_names += [
92
- str(each_record.get("name"))
93
- for each_record in attachment.get("entries") or []
94
- if isinstance(each_record, dict)
95
- ]
96
- return any(each_name.startswith(REMOTE_SERVER_TOOL_PREFIX) for each_name in all_names)
97
-
98
-
99
- def _is_stop_block(all_entry_fields: dict[str, object]) -> bool:
100
- attachment = all_entry_fields.get("attachment")
101
- return (
102
- isinstance(attachment, dict)
103
- and attachment.get("type") == HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE
104
- and attachment.get("hookEvent") == STOP_GATE_EVENT_NAME
105
- )
106
-
107
-
108
- def _is_title_call(entry_type: object, all_block_fields: dict[str, object]) -> bool:
109
- return (
110
- entry_type == "assistant"
111
- and all_block_fields.get("type") == "tool_use"
112
- and bool(TITLE_TOOL_NAME_PATTERN.match(str(all_block_fields.get("name"))))
113
- )
114
-
115
-
116
- def _title_set_since(all_turn_entries: list[dict[str, object]]) -> bool:
117
- all_entry_blocks = [
118
- (each_entry.get("type"), each_block)
119
- for each_entry in all_turn_entries
120
- for each_block in _content_blocks(each_entry)
121
- ]
122
- all_title_call_ids = {
123
- str(each_block.get("id"))
124
- for each_entry_type, each_block in all_entry_blocks
125
- if _is_title_call(each_entry_type, each_block)
126
- }
127
- return any(
128
- each_block.get("type") == "tool_result"
129
- and str(each_block.get("tool_use_id")) in all_title_call_ids
130
- and not each_block.get("is_error")
131
- for _, each_block in all_entry_blocks
132
- )
133
-
134
-
135
- def _turn_start_index(all_entries: list[dict[str, object]]) -> int:
136
- return max(
137
- (
138
- each_index
139
- for each_index, each_entry in enumerate(all_entries)
140
- if _is_user_prompt(each_entry)
141
- ),
142
- default=0,
143
- )
144
-
145
-
146
- def stop_block_reason(all_entries: list[dict[str, object]], is_remote_session: bool) -> str | None:
147
- """Return the block reason when this turn has not set the title, else None.
148
-
149
- ::
150
-
151
- prompt -> set_session_title -> ok result => None
152
- prompt -> work, no title call => block reason
153
- no title tool anywhere, not remote => None
154
- remote, deferred remote tools lack title => None
155
- remote, earlier Stop block never met => None
156
- remote, remote tools load directly => block reason
157
-
158
- Args:
159
- all_entries: The transcript entries, oldest first.
160
- is_remote_session: True when the session runs in a cloud session.
161
-
162
- Returns:
163
- The reason to send back to the model, or None to let the turn end.
164
- """
165
- all_seen_tool_names = [
166
- each_name for each_entry in all_entries for each_name in _title_tool_names_in(each_entry)
167
- ]
168
- if not all_seen_tool_names and (
169
- not is_remote_session
170
- or any(_defers_remote_server_tools(each) or _is_stop_block(each) for each in all_entries)
171
- ):
172
- return None
173
- if _title_set_since(all_entries[_turn_start_index(all_entries) :]):
174
- return None
175
- if all_seen_tool_names:
176
- return STOP_BLOCK_REASON.format(tool_name=all_seen_tool_names[-1])
177
- tool_name = REMOTE_TITLE_TOOL_NAME if is_remote_session else UNKNOWN_TITLE_TOOL_NAME
178
- return STOP_BLOCK_REASON.format(tool_name=tool_name)
179
-
180
-
181
- def _parsed_entry(line: str) -> dict[str, object] | None:
182
- try:
183
- parsed_entry = json.loads(line)
184
- except json.JSONDecodeError:
185
- return None
186
- return parsed_entry if isinstance(parsed_entry, dict) else None
187
-
188
-
189
- def _read_entries(transcript_path: str) -> list[dict[str, object]]:
190
- with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
191
- all_lines = transcript.readlines()
192
- all_parsed_entries = [_parsed_entry(each_line) for each_line in all_lines]
193
- return [each_entry for each_entry in all_parsed_entries if each_entry is not None]
194
-
195
-
196
- def main() -> int:
197
- hook_input = read_hook_input_dictionary_from_stdin()
198
- if hook_input is None or hook_input.get(STOP_HOOK_ACTIVE_KEY):
199
- return ALLOW_EXIT_CODE
200
- transcript_path = hook_input.get(TRANSCRIPT_PATH_KEY)
201
- if not isinstance(transcript_path, str) or not transcript_path:
202
- return ALLOW_EXIT_CODE
203
- try:
204
- all_entries = _read_entries(transcript_path)
205
- except OSError:
206
- return ALLOW_EXIT_CODE
207
- is_remote_session = bool(os.environ.get(REMOTE_SESSION_ENVIRONMENT_VARIABLE))
208
- block_reason = stop_block_reason(all_entries, is_remote_session)
209
- if block_reason is None:
210
- return ALLOW_EXIT_CODE
211
- log_hook_block(Path(__file__).name, STOP_GATE_EVENT_NAME, block_reason)
212
- sys.stdout.write(json.dumps({"decision": BLOCK_DECISION, "reason": block_reason}))
213
- return ALLOW_EXIT_CODE
214
-
215
-
216
- if __name__ == "__main__":
217
- sys.exit(main())