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.
- package/.agents/skills/build-eval/SKILL.md +4 -8
- package/.agents/skills/build-eval/reference/context-budget.md +49 -0
- package/.agents/skills/orchestrator/reference/oversee-delegated-work.md +1 -0
- package/.agents/skills/pr-lifecycle/SKILL.md +3 -10
- package/bin/codex-trimmed-sol.mjs +109 -0
- package/bin/codex-trimmed-sol.test.mjs +226 -0
- package/bin/install.mjs +18 -0
- package/bin/install.question-presentation.test.mjs +1 -1
- package/docs/codex-accounts.md +26 -0
- package/hooks/blocking/context_budget_blocker.py +177 -0
- package/hooks/blocking/test_context_budget_blocker.py +189 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +3 -1
- package/hooks/context_budget/__init__.py +1 -0
- package/hooks/context_budget/baseline.py +78 -0
- package/hooks/context_budget/findings.py +251 -0
- package/hooks/context_budget/hook_measure.py +178 -0
- package/hooks/context_budget/model.py +134 -0
- package/hooks/context_budget/policy_changes.py +214 -0
- package/hooks/context_budget/policy_file.py +222 -0
- package/hooks/context_budget/sections.py +213 -0
- package/hooks/context_budget/tests/budget_support.py +71 -0
- package/hooks/context_budget/tests/test_baseline.py +43 -0
- package/hooks/context_budget/tests/test_findings.py +120 -0
- package/hooks/context_budget/tests/test_hook_measure.py +57 -0
- package/hooks/context_budget/tests/test_policy_changes.py +175 -0
- package/hooks/context_budget/tests/test_policy_file.py +62 -0
- package/hooks/context_budget/tests/test_sections.py +112 -0
- package/hooks/features/write-edit-blocking.md +5 -1
- package/hooks/hooks_constants/context_budget_constants.py +154 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -2
- package/hooks/test_context_budget_hooks.py +79 -0
- package/hooks/test_files/context_budget/advisor_config/settings.json +3 -0
- package/hooks/test_files/context_budget/empty_transcript.jsonl +0 -0
- package/hooks/test_files/context_budget/orchestrator_skill_3f2c3ef.txt +170 -0
- package/hooks/test_files/context_budget/orchestrator_skill_98f45b1.txt +158 -0
- package/package.json +1 -1
- package/scripts/_code_review_test_support.py +7 -12
- package/scripts/context_budget_check.py +243 -0
- package/scripts/dev_env_scripts_constants/context_budget_check_constants.py +26 -0
- package/scripts/policy_lint/adapter_context_budget.py +158 -0
- package/scripts/policy_lint/adapters.py +73 -0
- package/scripts/policy_lint/config/constants.py +17 -1
- package/scripts/policy_lint/registry.py +17 -0
- package/scripts/test__code_review_test_support.py +40 -0
- package/scripts/test_codec_forwarding_test_support.py +28 -0
- package/scripts/tests/test_context_budget_check.py +270 -0
- package/scripts/tests/test_policy_lint_rules_context_budget.py +218 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +26 -0
- 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`
|
|
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.
|