claude-dev-env 8.72.2 → 8.73.1

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 (50) hide show
  1. package/.agents/skills/build-eval/SKILL.md +4 -8
  2. package/.agents/skills/build-eval/reference/context-budget.md +49 -0
  3. package/.agents/skills/orchestrator/reference/oversee-delegated-work.md +1 -0
  4. package/.agents/skills/pr-lifecycle/SKILL.md +3 -10
  5. package/bin/codex-trimmed-sol.mjs +109 -0
  6. package/bin/codex-trimmed-sol.test.mjs +226 -0
  7. package/bin/install.mjs +18 -0
  8. package/bin/install.question-presentation.test.mjs +1 -1
  9. package/docs/codex-accounts.md +26 -0
  10. package/hooks/blocking/context_budget_blocker.py +177 -0
  11. package/hooks/blocking/test_context_budget_blocker.py +189 -0
  12. package/hooks/blocking/test_pre_tool_use_dispatcher.py +3 -1
  13. package/hooks/context_budget/__init__.py +1 -0
  14. package/hooks/context_budget/baseline.py +78 -0
  15. package/hooks/context_budget/findings.py +251 -0
  16. package/hooks/context_budget/hook_measure.py +178 -0
  17. package/hooks/context_budget/model.py +134 -0
  18. package/hooks/context_budget/policy_changes.py +214 -0
  19. package/hooks/context_budget/policy_file.py +222 -0
  20. package/hooks/context_budget/sections.py +213 -0
  21. package/hooks/context_budget/tests/budget_support.py +71 -0
  22. package/hooks/context_budget/tests/test_baseline.py +43 -0
  23. package/hooks/context_budget/tests/test_findings.py +120 -0
  24. package/hooks/context_budget/tests/test_hook_measure.py +57 -0
  25. package/hooks/context_budget/tests/test_policy_changes.py +175 -0
  26. package/hooks/context_budget/tests/test_policy_file.py +62 -0
  27. package/hooks/context_budget/tests/test_sections.py +112 -0
  28. package/hooks/features/write-edit-blocking.md +5 -1
  29. package/hooks/hooks_constants/context_budget_constants.py +154 -0
  30. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -1
  31. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -2
  32. package/hooks/test_context_budget_hooks.py +79 -0
  33. package/hooks/test_files/context_budget/advisor_config/settings.json +3 -0
  34. package/hooks/test_files/context_budget/empty_transcript.jsonl +0 -0
  35. package/hooks/test_files/context_budget/orchestrator_skill_3f2c3ef.txt +170 -0
  36. package/hooks/test_files/context_budget/orchestrator_skill_98f45b1.txt +158 -0
  37. package/package.json +1 -1
  38. package/scripts/_code_review_test_support.py +7 -12
  39. package/scripts/context_budget_check.py +243 -0
  40. package/scripts/dev_env_scripts_constants/context_budget_check_constants.py +26 -0
  41. package/scripts/policy_lint/adapter_context_budget.py +158 -0
  42. package/scripts/policy_lint/adapters.py +73 -0
  43. package/scripts/policy_lint/config/constants.py +17 -1
  44. package/scripts/policy_lint/registry.py +17 -0
  45. package/scripts/test__code_review_test_support.py +40 -0
  46. package/scripts/test_codec_forwarding_test_support.py +28 -0
  47. package/scripts/tests/test_context_budget_check.py +270 -0
  48. package/scripts/tests/test_policy_lint_rules_context_budget.py +218 -0
  49. package/scripts/tests/test_policy_lint_rules_pairing.py +26 -0
  50. package/system-prompts/codex-sol.md +170 -0
@@ -0,0 +1,71 @@
1
+ """Shared builders for the context budget tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ FIXTURE_DIRECTORY = Path(__file__).resolve().parents[2] / "test_files" / "context_budget"
9
+ ORCHESTRATOR_PATH = "packages/claude-dev-env/.agents/skills/orchestrator/SKILL.md"
10
+ SKILL_PATH = "tools/example/SKILL.md"
11
+
12
+
13
+ def fixture_text(file_name: str) -> str:
14
+ """Return one committed fixture's text.
15
+
16
+ Args:
17
+ file_name: Fixture file name under ``test_files/context_budget``.
18
+
19
+ Returns:
20
+ The fixture text.
21
+ """
22
+ return (FIXTURE_DIRECTORY / file_name).read_text(encoding="utf-8")
23
+
24
+
25
+ def policy_text(baseline_files: dict | None = None) -> str:
26
+ """Return a policy JSON text with three kinds and one hook.
27
+
28
+ Args:
29
+ baseline_files: Baseline file entries, or None for an empty list.
30
+
31
+ Returns:
32
+ Policy JSON text.
33
+ """
34
+ raw_policy: dict[str, object] = {
35
+ "section_detail_line_limit": 6,
36
+ "kinds": [
37
+ {"name": "archived skill", "patterns": ["**/skills-archived/**"], "line_limit": None},
38
+ {
39
+ "name": "skill entry",
40
+ "patterns": ["**/SKILL.md"],
41
+ "line_limit": 200,
42
+ "section_rule": True,
43
+ },
44
+ {"name": "rule", "patterns": ["rules/*.md"], "line_limit": 30, "section_rule": True},
45
+ ],
46
+ "hooks": [
47
+ {"name": "greeter", "command": ["python3", "g.py"], "stdin": {}, "char_limit": 1500}
48
+ ],
49
+ "baseline": {"files": baseline_files or {}, "hooks": {}},
50
+ }
51
+ return json.dumps(raw_policy)
52
+
53
+
54
+ def section_text(heading: str, detail_count: int, pointer_line: str = "") -> str:
55
+ """Return one Markdown section with numbered detail lines.
56
+
57
+ Args:
58
+ heading: Section heading text.
59
+ detail_count: Number of detail lines.
60
+ pointer_line: Optional extra line, such as a link.
61
+
62
+ Returns:
63
+ The section text ending in a newline.
64
+ """
65
+ all_lines = [
66
+ f"## {heading}",
67
+ *(f"Detail line {each} of the section." for each in range(detail_count)),
68
+ ]
69
+ if pointer_line:
70
+ all_lines.append(pointer_line)
71
+ return "\n".join(all_lines) + "\n"
@@ -0,0 +1,43 @@
1
+ """The generated over-budget list records exactly the items over budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from context_budget.baseline import build_baseline, policy_text_with_baseline
8
+ from context_budget.policy_file import parse_policy
9
+ from context_budget.tests.budget_support import policy_text, section_text
10
+
11
+
12
+ def test_baseline_records_over_budget_files_and_hooks_only() -> None:
13
+ policy = parse_policy(policy_text())
14
+ text_by_path = {
15
+ "b/SKILL.md": section_text("Plan", 7) + section_text("Plan", 8),
16
+ "c/SKILL.md": section_text("Plan", 7) + "\n" * 200,
17
+ "a/SKILL.md": "short\n",
18
+ "x/skills-archived/SKILL.md": section_text("Old", 40),
19
+ "docs/notes.md": section_text("Notes", 40),
20
+ }
21
+
22
+ baseline = build_baseline(policy, text_by_path, {"greeter": 1501, "unlisted": 9000})
23
+
24
+ assert baseline == {
25
+ "files": {
26
+ "b/SKILL.md": {"lines": None, "sections": ["Plan", "Plan"]},
27
+ "c/SKILL.md": {"lines": 208, "sections": ["Plan"]},
28
+ },
29
+ "hooks": {"greeter": 1501},
30
+ }
31
+
32
+
33
+ def test_rewriting_the_baseline_keeps_every_other_key() -> None:
34
+ raw_policy = json.loads(policy_text())
35
+ raw_policy["kept_by_another_tool"] = [1]
36
+
37
+ rewritten = json.loads(
38
+ policy_text_with_baseline(json.dumps(raw_policy), {"files": {}, "hooks": {"g": 2}})
39
+ )
40
+
41
+ assert rewritten["kept_by_another_tool"] == [1]
42
+ assert rewritten["baseline"] == {"files": {}, "hooks": {"g": 2}}
43
+ assert rewritten["kinds"] == raw_policy["kinds"]
@@ -0,0 +1,120 @@
1
+ """File findings, the CI ratchet, and the findings an edit introduces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from context_budget.tests.budget_support import SKILL_PATH, policy_text, section_text
6
+ from context_budget.findings import edit_findings, file_findings
7
+ from context_budget.policy_file import default_policy, parse_policy
8
+
9
+
10
+ def test_unlisted_file_reports_line_limit_and_eachsection_text() -> None:
11
+ policy = parse_policy(policy_text())
12
+ text = section_text("Alpha", 7) + "\n" * 200
13
+
14
+ all_findings = file_findings(policy, SKILL_PATH, text, None)
15
+
16
+ assert [each.scope for each in all_findings] == ["file", "Alpha"]
17
+ assert "file has 208 lines (skill entry limit 200)" in all_findings[0].message
18
+
19
+
20
+ def _section_only_policy():
21
+ return parse_policy(policy_text({SKILL_PATH: {"lines": None, "sections": ["Alpha", "Alpha"]}}))
22
+
23
+
24
+ def test_section_only_listed_file_grows_under_its_limit_and_passes() -> None:
25
+ text = section_text("Alpha", 7) + section_text("Alpha", 8) + section_text("Beta", 7)
26
+
27
+ assert [each.scope for each in file_findings(_section_only_policy(), SKILL_PATH, text, None)] == [
28
+ "Beta"
29
+ ]
30
+ grown_text = text + "one more\n" * (200 - len(text.splitlines()))
31
+ assert [
32
+ each.scope for each in file_findings(_section_only_policy(), SKILL_PATH, grown_text, None)
33
+ ] == ["Beta"]
34
+
35
+
36
+ def test_section_only_listed_file_crossing_its_limit_fails() -> None:
37
+ text = section_text("Alpha", 7) + section_text("Alpha", 8)
38
+ crossed_text = text + "one more\n" * (201 - len(text.splitlines()))
39
+
40
+ all_findings = file_findings(_section_only_policy(), SKILL_PATH, crossed_text, None)
41
+
42
+ assert [each.scope for each in all_findings] == ["file"]
43
+ assert "file has 201 lines (skill entry limit 200)" in all_findings[0].message
44
+
45
+
46
+ def test_over_limit_listed_file_may_not_grow_past_its_record() -> None:
47
+ text = section_text("Alpha", 7) + "\n" * 202
48
+ policy = parse_policy(policy_text({SKILL_PATH: {"lines": 210, "sections": ["Alpha"]}}))
49
+
50
+ assert file_findings(policy, SKILL_PATH, text, None) == ()
51
+ grown_findings = file_findings(policy, SKILL_PATH, text + "one more\n", None)
52
+ assert [each.scope for each in grown_findings] == ["file"]
53
+ assert "grew from 210 to 211 lines" in grown_findings[0].message
54
+
55
+
56
+ def test_ratchet_asks_to_lower_null_or_remove_the_entry_of_a_changed_listed_file() -> None:
57
+ prior_text = section_text("Alpha", 7) + section_text("Beta", 7) + "\n" * 200
58
+ policy = parse_policy(policy_text({SKILL_PATH: {"lines": 216, "sections": ["Alpha", "Beta"]}}))
59
+ lowered_text = section_text("Alpha", 7) + section_text("Beta", 7) + "\n" * 190
60
+ nulled_text = section_text("Alpha", 7) + section_text("Beta", 7)
61
+
62
+ lowered = file_findings(policy, SKILL_PATH, lowered_text, prior_text)
63
+ nulled = file_findings(policy, SKILL_PATH, nulled_text, prior_text)
64
+ removed = file_findings(policy, SKILL_PATH, "short\n", prior_text)
65
+
66
+ assert len(lowered) == 1
67
+ assert '{"lines": 206, "sections": ["Alpha", "Beta"]}' in lowered[0].message
68
+ assert '{"lines": null, "sections": ["Alpha", "Beta"]}' in nulled[0].message
69
+ assert "Remove its entry" in removed[0].message
70
+ assert file_findings(policy, SKILL_PATH, lowered_text, None) == ()
71
+
72
+
73
+ def test_ratchet_asks_to_drop_a_lost_section_from_a_section_only_entry() -> None:
74
+ policy = parse_policy(policy_text({SKILL_PATH: {"lines": None, "sections": ["Alpha", "Beta"]}}))
75
+ prior_text = section_text("Alpha", 7) + section_text("Beta", 7)
76
+ shrunk_text = section_text("Alpha", 7) + section_text("Beta", 3, "[b](reference/beta.md)")
77
+
78
+ lowered = file_findings(policy, SKILL_PATH, shrunk_text, prior_text)
79
+
80
+ assert len(lowered) == 1
81
+ assert '{"lines": null, "sections": ["Alpha"]}' in lowered[0].message
82
+
83
+
84
+ def test_edit_that_only_shrinks_has_no_findings_without_a_policy_file() -> None:
85
+ prior_text = section_text("Alpha", 9) + "\n" * 220
86
+ shrunk_text = section_text("Alpha", 8) + "\n" * 210
87
+
88
+ assert edit_findings(default_policy(), SKILL_PATH, prior_text, shrunk_text, True) == ()
89
+
90
+
91
+ def test_edit_that_grows_or_adds_a_section_is_reported_without_a_policy_file() -> None:
92
+ prior_text = section_text("Alpha", 9) + "\n" * 220
93
+ grown = edit_findings(default_policy(), SKILL_PATH, prior_text, prior_text + "x\n", True)
94
+ added = edit_findings(
95
+ default_policy(), SKILL_PATH, prior_text, section_text("Beta", 7) + "\n" * 200, True
96
+ )
97
+
98
+ assert [each.scope for each in grown] == ["file"]
99
+ assert "was already over its limit at 230 lines and may only shrink" in grown[0].message
100
+ assert "list" not in grown[0].message
101
+ assert [each.scope for each in added] == ["Beta"]
102
+
103
+
104
+ def test_short_skill_with_one_long_section_accepts_an_added_line_without_a_policy_file() -> None:
105
+ prior_text = "# Skill\nIntro.\n" + section_text("Alpha", 8)
106
+ post_text = "# Skill\nIntro.\nOne more intro line.\n" + section_text("Alpha", 8)
107
+
108
+ assert len(prior_text.splitlines()) == 11
109
+ assert edit_findings(default_policy(), SKILL_PATH, prior_text, post_text, True) == ()
110
+
111
+
112
+ def test_edit_of_an_unlisted_over_budget_file_is_allowed_when_it_shrinks_under_a_policy() -> None:
113
+ policy = parse_policy(policy_text())
114
+ prior_text = section_text("Alpha", 9) + "\n" * 220
115
+
116
+ assert edit_findings(policy, SKILL_PATH, prior_text, prior_text[:-5], False) == ()
117
+ assert [each.scope for each in edit_findings(policy, SKILL_PATH, None, prior_text, False)] == [
118
+ "file",
119
+ "Alpha",
120
+ ]
@@ -0,0 +1,57 @@
1
+ """Hook output measurement and the hook budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from context_budget.hook_measure import (
11
+ HookMeasurementError,
12
+ hook_findings,
13
+ injected_character_count,
14
+ run_hook_command,
15
+ split_command_environment,
16
+ )
17
+ from context_budget.model import BudgetPolicy, HookBudget
18
+
19
+
20
+ def test_injected_characters_count_context_and_system_message_or_raw_stdout() -> None:
21
+ json_output = json.dumps(
22
+ {"hookSpecificOutput": {"additionalContext": "abcd"}, "systemMessage": "xy"}
23
+ )
24
+
25
+ assert injected_character_count(json_output) == 6
26
+ assert injected_character_count("plain text") == 10
27
+ assert injected_character_count("") == 0
28
+
29
+
30
+ def test_hook_is_held_to_its_limit_or_its_baseline() -> None:
31
+ hook = HookBudget("greeter", ("python3", "g.py"), "{}", 100)
32
+ unlisted_policy = BudgetPolicy(6, ())
33
+ listed_policy = BudgetPolicy(6, (), (hook,), {}, {"greeter": 150})
34
+
35
+ assert hook_findings(unlisted_policy, hook, 100) == ()
36
+ assert "injects 101 characters (limit 100)" in hook_findings(unlisted_policy, hook, 101)[0]
37
+ assert hook_findings(listed_policy, hook, 150) == ()
38
+ assert "over its baseline of 150" in hook_findings(listed_policy, hook, 151)[0]
39
+
40
+
41
+ def test_env_prefix_lifts_into_environment_pairs() -> None:
42
+ assert split_command_environment(["env", "A=1", "B=", "python3", "x.py"]) == (
43
+ {"A": "1", "B": ""},
44
+ ("python3", "x.py"),
45
+ )
46
+ assert split_command_environment(["python3", "x.py"]) == ({}, ("python3", "x.py"))
47
+
48
+
49
+ def test_a_failed_hook_with_output_is_not_a_measurement(tmp_path: Path) -> None:
50
+ hook_path = tmp_path / "failed.py"
51
+ hook_path.write_text(
52
+ "print('partial output')\nraise RuntimeError('fixture failure')\n", encoding="utf-8"
53
+ )
54
+ hook = HookBudget("failed", ("python3", str(hook_path)), "{}", 1500)
55
+
56
+ with pytest.raises(HookMeasurementError, match='hook "failed" exited with status 1'):
57
+ run_hook_command(hook, tmp_path)
@@ -0,0 +1,175 @@
1
+ """The over-budget list may only shrink."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from context_budget.tests.budget_support import policy_text
11
+ from context_budget.policy_changes import policy_shrink_findings
12
+ from context_budget.policy_file import parse_policy
13
+
14
+
15
+ @pytest.mark.parametrize(
16
+ ("mutation", "expected_fragment"),
17
+ [
18
+ (
19
+ lambda raw: raw["baseline"]["files"].update(
20
+ {"b/SKILL.md": {"lines": 300, "sections": []}}
21
+ ),
22
+ 'adds file "b/SKILL.md"',
23
+ ),
24
+ (
25
+ lambda raw: raw["baseline"]["files"]["a/SKILL.md"].update({"lines": 301}),
26
+ "from 300 to 301 lines",
27
+ ),
28
+ (
29
+ lambda raw: raw["baseline"]["files"]["a/SKILL.md"]["sections"].append("X"),
30
+ 'adds section "X"',
31
+ ),
32
+ (lambda raw: raw["baseline"]["hooks"].update({"other": 1600}), 'adds hook "other"'),
33
+ (
34
+ lambda raw: raw["baseline"]["hooks"].update({"greeter": 2001}),
35
+ "from 2000 to 2001 characters",
36
+ ),
37
+ (lambda raw: raw.update({"section_detail_line_limit": 7}), "rises from 6 to 7"),
38
+ (
39
+ lambda raw: raw["kinds"][1].update({"line_limit": 250}),
40
+ "line_limit rises from 200 to 250",
41
+ ),
42
+ (lambda raw: raw["kinds"].pop(2), 'kind "rule" is deleted'),
43
+ (
44
+ lambda raw: raw["kinds"][1].update({"patterns": ["no-skills/*.md"]}),
45
+ 'kind "skill entry" patterns change',
46
+ ),
47
+ (lambda raw: raw["kinds"].reverse(), "kind selection order changes"),
48
+ (lambda raw: raw["hooks"].clear(), 'hook "greeter" is deleted'),
49
+ (
50
+ lambda raw: raw["hooks"][0].update({"char_limit": 1501}),
51
+ "char_limit rises from 1500 to 1501",
52
+ ),
53
+ ],
54
+ )
55
+ def test_policy_change_that_loosens_the_budget_is_a_finding(
56
+ mutation, expected_fragment: str, tmp_path: Path
57
+ ) -> None:
58
+ (tmp_path / "g.py").write_text("", encoding="utf-8")
59
+ raw_prior = json.loads(policy_text({"a/SKILL.md": {"lines": 300, "sections": []}}))
60
+ raw_prior["baseline"]["hooks"] = {"greeter": 2000}
61
+ raw_current = json.loads(json.dumps(raw_prior))
62
+ mutation(raw_current)
63
+
64
+ all_findings = policy_shrink_findings(
65
+ ".claude/context-budget.json",
66
+ parse_policy(json.dumps(raw_prior)),
67
+ parse_policy(json.dumps(raw_current)),
68
+ tmp_path,
69
+ )
70
+
71
+ assert len(all_findings) == 1
72
+ assert expected_fragment in all_findings[0].message
73
+ assert "the over-budget list may only shrink" in all_findings[0].message
74
+
75
+
76
+ def test_deleting_a_hook_whose_script_is_still_in_the_tree_is_a_finding(
77
+ tmp_path: Path,
78
+ ) -> None:
79
+ (tmp_path / "g.py").write_text("", encoding="utf-8")
80
+ raw_prior = json.loads(policy_text())
81
+ raw_current = json.loads(json.dumps(raw_prior))
82
+ raw_current["hooks"].clear()
83
+
84
+ all_findings = policy_shrink_findings(
85
+ "p", parse_policy(json.dumps(raw_prior)), parse_policy(json.dumps(raw_current)), tmp_path
86
+ )
87
+
88
+ assert len(all_findings) == 1
89
+ assert 'hook "greeter" is deleted' in all_findings[0].message
90
+
91
+
92
+ def test_deleting_a_hook_together_with_its_script_passes(tmp_path: Path) -> None:
93
+ raw_prior = json.loads(policy_text())
94
+ raw_current = json.loads(json.dumps(raw_prior))
95
+ raw_current["hooks"].clear()
96
+
97
+ assert policy_shrink_findings(
98
+ "p", parse_policy(json.dumps(raw_prior)), parse_policy(json.dumps(raw_current)), tmp_path
99
+ ) == ()
100
+
101
+
102
+ def test_policy_change_that_shrinks_or_adds_the_file_passes(tmp_path: Path) -> None:
103
+ prior_policy = parse_policy(policy_text({"a/SKILL.md": {"lines": 300, "sections": ["X"]}}))
104
+ shrunk_policy = parse_policy(policy_text({"a/SKILL.md": {"lines": 250, "sections": []}}))
105
+ nulled_policy = parse_policy(policy_text({"a/SKILL.md": {"lines": None, "sections": ["X"]}}))
106
+
107
+ assert policy_shrink_findings("p", prior_policy, shrunk_policy, tmp_path) == ()
108
+ assert policy_shrink_findings("p", prior_policy, nulled_policy, tmp_path) == ()
109
+ assert policy_shrink_findings("p", None, shrunk_policy, tmp_path) == ()
110
+
111
+
112
+ def test_policy_change_from_null_lines_to_a_number_fails_shrink_only(tmp_path: Path) -> None:
113
+ prior_policy = parse_policy(policy_text({"a/SKILL.md": {"lines": None, "sections": ["X"]}}))
114
+ numbered_policy = parse_policy(policy_text({"a/SKILL.md": {"lines": 250, "sections": ["X"]}}))
115
+
116
+ all_findings = policy_shrink_findings("p", prior_policy, numbered_policy, tmp_path)
117
+
118
+ assert len(all_findings) == 1
119
+ assert 'records 250 lines for "a/SKILL.md"' in all_findings[0].message
120
+ assert "the over-budget list may only shrink" in all_findings[0].message
121
+
122
+
123
+ @pytest.mark.parametrize("position", [0, 1])
124
+ def test_a_new_kind_cannot_precede_an_existing_kind(position: int, tmp_path: Path) -> None:
125
+ raw_prior = json.loads(policy_text())
126
+ raw_current = json.loads(json.dumps(raw_prior))
127
+ raw_current["kinds"].insert(
128
+ position,
129
+ {"name": "unlimited", "patterns": ["**/*"], "line_limit": None},
130
+ )
131
+
132
+ findings = policy_shrink_findings(
133
+ "p",
134
+ parse_policy(json.dumps(raw_prior)),
135
+ parse_policy(json.dumps(raw_current)),
136
+ tmp_path,
137
+ )
138
+
139
+ assert len(findings) == 1
140
+ assert "kind selection order changes" in findings[0].message
141
+
142
+
143
+ def test_a_duplicate_name_cannot_hide_a_raised_limit(tmp_path: Path) -> None:
144
+ raw_prior = json.loads(policy_text())
145
+ raw_current = json.loads(json.dumps(raw_prior))
146
+ raw_current["kinds"][1]["line_limit"] = None
147
+ raw_current["kinds"].append(raw_prior["kinds"][1])
148
+
149
+ findings = policy_shrink_findings(
150
+ "p",
151
+ parse_policy(json.dumps(raw_prior)),
152
+ parse_policy(json.dumps(raw_current)),
153
+ tmp_path,
154
+ )
155
+
156
+ assert len(findings) == 1
157
+ assert "kind selection order changes" in findings[0].message
158
+
159
+
160
+ def test_appending_a_kind_and_tightening_limits_passes(tmp_path: Path) -> None:
161
+ raw_prior = json.loads(policy_text())
162
+ raw_current = json.loads(json.dumps(raw_prior))
163
+ raw_current["kinds"][1]["line_limit"] = 100
164
+ raw_current["hooks"][0]["char_limit"] = 1000
165
+ raw_current["section_detail_line_limit"] = 5
166
+ raw_current["kinds"].append(
167
+ {"name": "extra", "patterns": ["extra/*.md"], "line_limit": 20}
168
+ )
169
+
170
+ assert policy_shrink_findings(
171
+ "p",
172
+ parse_policy(json.dumps(raw_prior)),
173
+ parse_policy(json.dumps(raw_current)),
174
+ tmp_path,
175
+ ) == ()
@@ -0,0 +1,62 @@
1
+ """Policy parsing and repository lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from context_budget.tests.budget_support import policy_text
11
+ from context_budget.model import ContextBudgetPolicyError
12
+ from context_budget.policy_file import find_repository_root, parse_policy, read_policy_file
13
+
14
+
15
+ def test_parse_ignores_unknown_keys_and_rejects_a_bad_shape() -> None:
16
+ raw_policy = json.loads(policy_text())
17
+ raw_policy["added_by_another_tool"] = {"anything": True}
18
+ raw_policy["kinds"][1]["extra"] = 1
19
+
20
+ assert len(parse_policy(json.dumps(raw_policy)).all_kinds) == 3
21
+ with pytest.raises(ContextBudgetPolicyError):
22
+ parse_policy("[1, 2]")
23
+ with pytest.raises(ContextBudgetPolicyError):
24
+ parse_policy(json.dumps({"kinds": [{"name": "x", "patterns": "**/a.md"}]}))
25
+ with pytest.raises(ContextBudgetPolicyError):
26
+ parse_policy("{not json")
27
+
28
+
29
+ def test_baseline_lines_may_be_null_but_may_not_be_missing() -> None:
30
+ policy = parse_policy(policy_text({"a/SKILL.md": {"lines": None, "sections": ["Plan"]}}))
31
+
32
+ assert policy.baseline_entry_by_path["a/SKILL.md"].lines is None
33
+ with pytest.raises(ContextBudgetPolicyError):
34
+ parse_policy(policy_text({"a/SKILL.md": {"sections": ["Plan"]}}))
35
+
36
+
37
+ def test_duplicate_hook_names_are_rejected_before_measurement() -> None:
38
+ raw_policy = json.loads(policy_text())
39
+ raw_policy["hooks"].append(dict(raw_policy["hooks"][0]))
40
+
41
+ with pytest.raises(ContextBudgetPolicyError, match="hook names must be unique"):
42
+ parse_policy(json.dumps(raw_policy))
43
+
44
+
45
+ def test_repository_root_accepts_a_git_file_and_policy_reads_from_it(tmp_path: Path) -> None:
46
+ repository_root = tmp_path / "worktree"
47
+ (repository_root / ".claude").mkdir(parents=True)
48
+ (repository_root / ".git").write_text("gitdir: elsewhere\n", encoding="utf-8")
49
+ (repository_root / ".claude" / "context-budget.json").write_text(
50
+ policy_text(), encoding="utf-8"
51
+ )
52
+
53
+ found_root = find_repository_root(repository_root / "new" / "dir" / "SKILL.md")
54
+
55
+ assert found_root == repository_root
56
+ policy = read_policy_file(repository_root)
57
+ assert policy is not None and len(policy.all_hooks) == 1
58
+ assert read_policy_file(tmp_path) is None
59
+ assert (
60
+ find_repository_root(tmp_path / "x.md") is None
61
+ or find_repository_root(tmp_path / "x.md") != repository_root
62
+ )
@@ -0,0 +1,112 @@
1
+ """Path classification and Markdown section measurement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from context_budget.tests.budget_support import (
8
+ ORCHESTRATOR_PATH,
9
+ fixture_text,
10
+ policy_text,
11
+ section_text,
12
+ )
13
+ from context_budget.findings import file_findings
14
+ from context_budget.policy_file import default_policy, parse_policy
15
+ from context_budget.sections import context_kind_for_path, path_matches_pattern, scan_sections
16
+
17
+
18
+ def test_calibration_old_orchestrator_fails_on_oversee_delegated_work() -> None:
19
+ all_findings = file_findings(
20
+ default_policy(),
21
+ ORCHESTRATOR_PATH,
22
+ fixture_text("orchestrator_skill_3f2c3ef.txt"),
23
+ None,
24
+ )
25
+
26
+ assert [each_finding.scope for each_finding in all_findings] == ["Oversee delegated work"]
27
+ assert (
28
+ 'section "Oversee delegated work" has 9 detail lines and no pointer (limit 6)'
29
+ in all_findings[0].message
30
+ )
31
+ assert "reference/oversee-delegated-work.md" in all_findings[0].message
32
+
33
+
34
+ def test_calibration_new_orchestrator_passes_with_largest_unpointed_section_at_five() -> None:
35
+ text = fixture_text("orchestrator_skill_98f45b1.txt")
36
+ all_unpointed_counts = [
37
+ each_scan.detail_line_count
38
+ for each_scan in scan_sections(text)
39
+ if not each_scan.has_pointer
40
+ ]
41
+
42
+ assert max(all_unpointed_counts) == 5
43
+ assert file_findings(default_policy(), ORCHESTRATOR_PATH, text, None) == ()
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ ("pattern", "relative_path", "is_expected_match"),
48
+ [
49
+ ("**/SKILL.md", "SKILL.md", True),
50
+ ("**/SKILL.md", "a/SKILL.md", True),
51
+ ("**/SKILL.md", "a/b/SKILL.md", True),
52
+ ("**/SKILL.md", "a/SKILL.md.bak", False),
53
+ ("packages/claude-dev-env/rules/*.md", "packages/claude-dev-env/rules/a.md", True),
54
+ ("packages/claude-dev-env/rules/*.md", "packages/claude-dev-env/rules/deep/a.md", False),
55
+ ("**/skills-archived/**", "x/skills-archived/a/b/SKILL.md", True),
56
+ ("skill-archive/**", "skill-archive/a/SKILL.md", True),
57
+ ("skill-archive/**", "other/skill-archive/a/SKILL.md", False),
58
+ ],
59
+ )
60
+ def test_glob_matches_whole_repository_relative_paths(
61
+ pattern: str, relative_path: str, is_expected_match: bool
62
+ ) -> None:
63
+ assert path_matches_pattern(pattern, relative_path) is is_expected_match
64
+
65
+
66
+ def test_first_matching_kind_wins_and_a_skipped_kind_hides_the_path() -> None:
67
+ policy = parse_policy(policy_text())
68
+
69
+ assert context_kind_for_path(policy, "pkg/skills-archived/old/SKILL.md") is None
70
+ skill_kind = context_kind_for_path(policy, "pkg/live/SKILL.md")
71
+ assert skill_kind is not None and skill_kind.name == "skill entry"
72
+ assert context_kind_for_path(policy, "docs/notes.md") is None
73
+
74
+
75
+ def test_section_rule_counts_detail_and_skips_tables_link_lists_and_frontmatter() -> None:
76
+ text = (
77
+ "---\nname: x\ndescription: y\nmore: z\nand: w\nkeys: v\nhere: u\nlast: t\n---\n"
78
+ "# Title\n"
79
+ "| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |\n| 7 | 8 |\n| 9 | 0 |\n"
80
+ "- [one](https://example.com/one)\n- [two](https://example.com/two)\n"
81
+ "- [three](https://example.com/three)\n1. [four](https://example.com/four)\n"
82
+ "- [five](https://example.com/five)\n- [six](https://example.com/six)\n"
83
+ "- [seven](https://example.com/seven)\n"
84
+ )
85
+
86
+ all_scans = scan_sections(text)
87
+
88
+ assert [(each.heading, each.detail_line_count, each.has_pointer) for each in all_scans] == [
89
+ ("(top)", 0, False),
90
+ ("Title", 0, False),
91
+ ]
92
+
93
+
94
+ def test_fenced_lines_count_as_detail_and_hide_headings_and_links() -> None:
95
+ text = "## Example\n```bash\n# not a heading\nrun [x](ref.md)\n\nmore\n```\n"
96
+
97
+ all_scans = scan_sections(text)
98
+
99
+ assert [(each.heading, each.detail_line_count, each.has_pointer) for each in all_scans] == [
100
+ ("(top)", 0, False),
101
+ ("Example", 5, False),
102
+ ]
103
+
104
+
105
+ def test_relative_link_is_a_pointer_and_external_or_anchor_links_are_not() -> None:
106
+ pointed = section_text("Plan", 9, "Read [the plan](reference/plan.md).")
107
+ external = section_text(
108
+ "Plan", 9, "See [site](https://example.com) and [here](#plan) and [m](mailto:a@b.c)."
109
+ )
110
+
111
+ assert scan_sections(pointed)[1].has_pointer is True
112
+ assert scan_sections(external)[1].has_pointer is False
@@ -6,11 +6,13 @@ This family decides whether an agent's file mutation can run.
6
6
 
7
7
  - `blocking/pre_tool_use_dispatcher.py` runs its native description check and returns one permission decision.
8
8
  - `blocking/state_description_blocker.py` rejects historical or comparative prose in supported comments, docstrings, and Markdown.
9
+ - `blocking/context_budget_blocker.py` rejects a write that grows a skill entry, `AGENTS.md`, `CLAUDE.md`, or rule file past its context budget.
9
10
 
10
11
  ## When it fires
11
12
 
12
13
  - `blocking/pre_tool_use_dispatcher.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds in `hooks.json`.
13
14
  - `blocking/state_description_blocker.py` runs through the dispatcher's native evaluator on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. Its evaluator checks `Write`, `Edit`, and `MultiEdit` payloads and skips `apply_patch`.
15
+ - `blocking/context_budget_blocker.py` runs inside that dispatcher as a blocking hosted entry on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. It reads `.claude/context-budget.json` from the edited file's own repository.
14
16
 
15
17
  ## Proving it
16
18
 
@@ -19,11 +21,13 @@ Preconditions:
19
21
  - Run each command from the repository root with Python and pytest available. The tests use disposable paths where they need files.
20
22
 
21
23
  - **Dispatcher decision.** Input is a clean Write payload. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py -q`. The adjacent test checks that the dispatcher allows clean writes and carries denials from its checks.
24
+ - **Context budget gate.** Input is a Write of the orchestrator skill at commit `3f2c3ef`, then at `98f45b1`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_context_budget_blocker.py -q`. The adjacent test observes a denial naming "Oversee delegated work" for the first and an allow for the second.
22
25
  - **Description gate.** Input is a Write containing historical prose in a Python comment. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_state_description_blocker.py -q`. The adjacent test observes a denial reason.
23
26
 
24
27
  ## Gotchas
25
28
 
26
- - `ALL_HOSTED_HOOK_ENTRIES` is empty. The dispatcher calls the description evaluator through its native hook table.
29
+ - `ALL_HOSTED_HOOK_ENTRIES` lists the blocking context budget gate. The dispatcher calls the description evaluator through its native hook table.
30
+ - The context budget gate denies only what an edit adds. A repository with no policy file uses built-in kinds and treats the file's current text as its baseline entry, so a file under its line limit may grow to the limit and a file over it may only shrink. The CI ratchet runs in `scripts/policy_lint/adapter_context_budget.py`, never in the hook. See the [context budget reference](../../.agents/skills/build-eval/reference/context-budget.md).
27
31
  - The dispatcher runs hosted entries in roster order. A hosted advisory crash stays silent; a native blocking check can deny the write.
28
32
  - `scripts/policy_lint/adapter_detectors.py` also loads `blocking/state_description_blocker.py`. Check that adapter when changing its evaluator.
29
33
  - `bin/install.mjs` names folded and retired script paths so reinstall can remove stale registrations.