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.
- package/bin/cde.mjs +1 -1
- package/bin/cde.test.mjs +1 -1
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +1 -0
- package/bin/merge_profile_settings.mjs +2 -3
- package/bin/merge_profile_settings.test.mjs +5 -5
- package/commands/fix-followups.md +5 -2
- package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
- package/docs/rule-guides/paired-test-coverage.md +1 -1
- package/docs/rule-guides/session-title.md +3 -3
- package/docs/templates/html-plan/README.md +1 -1
- package/hooks/features/conduct-gates.md +0 -3
- package/hooks/features/session-context.md +1 -1
- package/hooks/followup_ledger.py +179 -116
- package/hooks/followup_ledger_maintenance.py +127 -0
- package/hooks/hooks.json +0 -11
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
- package/hooks/hooks_constants/followup_ledger_constants.py +26 -6
- package/hooks/hooks_constants/session_title_constants.py +1 -29
- package/hooks/session/artifact_template_pointer.py +5 -4
- package/hooks/session/test_artifact_template_pointer.py +13 -0
- package/hooks/test_followup_ledger.py +151 -55
- package/hooks/test_followup_ledger_maintenance.py +107 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +1 -1
- package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
- package/scripts/followup_cli.py +11 -12
- package/scripts/policy_lint/adapter_configuration.py +0 -1
- package/scripts/policy_lint/config/constants.py +0 -1
- package/scripts/repository_checks/config/constants.py +0 -6
- package/scripts/repository_checks/runner.py +0 -7
- package/scripts/test_followup_cli.py +71 -7
- package/scripts/tests/test_adapter_configuration.py +2 -5
- package/scripts/tests/test_cde_lint.py +3 -4
- package/scripts/tests/test_repository_policy.py +1 -3
- package/hooks/blocking/session_title_stop_gate.py +0 -217
- package/hooks/blocking/test_session_title_stop_gate.py +0 -295
- package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
- 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
|
@@ -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
|
|
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 =
|
|
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"
|
package/scripts/followup_cli.py
CHANGED
|
@@ -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``
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
318
|
+
stdout: The stream the migrated and removed counts go to.
|
|
323
319
|
|
|
324
320
|
Returns:
|
|
325
321
|
SUCCESS_EXIT_CODE.
|
|
326
322
|
"""
|
|
327
|
-
|
|
323
|
+
normalization = normalize_ledger(repository_root)
|
|
328
324
|
stdout.write(
|
|
329
|
-
DEDUPE_RESULT_TEMPLATE.format(
|
|
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
|
-
|
|
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
|
|
311
|
+
def test_dedupe_moves_a_legacy_ledger_into_one_file_per_finding(
|
|
311
312
|
tmp_path: Path,
|
|
312
313
|
) -> None:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 ==
|
|
323
|
-
|
|
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
|
|
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(
|
|
428
|
-
for
|
|
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"
|
|
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
|
-
|
|
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())
|