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,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook that denies a write growing an agent context file past its budget.
|
|
3
|
+
|
|
4
|
+
The edited file's own repository supplies ``.claude/context-budget.json``; a
|
|
5
|
+
repository without one uses the built-in kinds and treats the file's current
|
|
6
|
+
content as its baseline entry. The hook denies only what an edit adds: an edit
|
|
7
|
+
that only shrinks a file passes, and the CI ratchet never runs here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Iterator, Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _hooks_dir not in sys.path:
|
|
19
|
+
sys.path.insert(0, _hooks_dir)
|
|
20
|
+
|
|
21
|
+
from blocking.codex_apply_patch import (
|
|
22
|
+
CodexPatchError,
|
|
23
|
+
parse_codex_apply_patch,
|
|
24
|
+
payload_patch_command,
|
|
25
|
+
)
|
|
26
|
+
from context_budget.findings import edit_findings
|
|
27
|
+
from context_budget.model import BudgetPolicy, ContextBudgetPolicyError
|
|
28
|
+
from context_budget.policy_file import default_policy, find_repository_root, read_policy_file
|
|
29
|
+
from hooks_constants.context_budget_constants import (
|
|
30
|
+
CONTENT_KEY,
|
|
31
|
+
CONTEXT_BUDGET_BLOCK_PREFIX,
|
|
32
|
+
CONTEXT_BUDGET_HOOK_NAME,
|
|
33
|
+
CONTEXT_BUDGET_NOTICE,
|
|
34
|
+
CONTEXT_BUDGET_REASON_SEPARATOR,
|
|
35
|
+
CWD_KEY,
|
|
36
|
+
FILE_PATH_KEY,
|
|
37
|
+
TOOL_INPUT_KEY,
|
|
38
|
+
TOOL_NAME_KEY,
|
|
39
|
+
UTF8_ENCODING,
|
|
40
|
+
)
|
|
41
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
42
|
+
from hooks_constants.multi_edit_reconstruction import apply_edits, edits_for_tool
|
|
43
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
44
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
45
|
+
APPLY_PATCH_TOOL_NAME,
|
|
46
|
+
HOOK_EVENT_NAME,
|
|
47
|
+
WRITE_TOOL_NAME,
|
|
48
|
+
)
|
|
49
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
50
|
+
|
|
51
|
+
EditedText = tuple[Path, str | None, str]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_optional_text(file_path: Path) -> str | None:
|
|
55
|
+
try:
|
|
56
|
+
return file_path.read_text(encoding=UTF8_ENCODING)
|
|
57
|
+
except (OSError, UnicodeDecodeError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _absolute_path(raw_path: str, payload_by_key: Mapping[str, object]) -> Path:
|
|
62
|
+
candidate = Path(raw_path).expanduser()
|
|
63
|
+
working_directory = payload_by_key.get(CWD_KEY)
|
|
64
|
+
if not candidate.is_absolute() and isinstance(working_directory, str):
|
|
65
|
+
candidate = Path(working_directory) / candidate
|
|
66
|
+
return candidate.resolve()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _edited_texts(payload_by_key: Mapping[str, object]) -> Iterator[EditedText]:
|
|
70
|
+
tool_name = payload_by_key.get(TOOL_NAME_KEY)
|
|
71
|
+
tool_input = payload_by_key.get(TOOL_INPUT_KEY)
|
|
72
|
+
if not isinstance(tool_input, dict):
|
|
73
|
+
return
|
|
74
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
75
|
+
command, working_directory = payload_patch_command(dict(payload_by_key), tool_input)
|
|
76
|
+
try:
|
|
77
|
+
all_patch_files = parse_codex_apply_patch(command, working_directory)
|
|
78
|
+
except CodexPatchError:
|
|
79
|
+
return
|
|
80
|
+
for each_file in all_patch_files:
|
|
81
|
+
yield Path(each_file.file_path), each_file.prior_content or None, each_file.post_content
|
|
82
|
+
return
|
|
83
|
+
raw_path = tool_input.get(FILE_PATH_KEY)
|
|
84
|
+
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES or not isinstance(raw_path, str):
|
|
85
|
+
return
|
|
86
|
+
file_path = _absolute_path(raw_path, payload_by_key)
|
|
87
|
+
prior_text = _read_optional_text(file_path)
|
|
88
|
+
if tool_name == WRITE_TOOL_NAME:
|
|
89
|
+
content = tool_input.get(CONTENT_KEY)
|
|
90
|
+
if isinstance(content, str):
|
|
91
|
+
yield file_path, prior_text, content
|
|
92
|
+
elif prior_text is not None:
|
|
93
|
+
yield file_path, prior_text, apply_edits(prior_text, edits_for_tool(str(tool_name), tool_input))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _policy_for(repository_root: Path) -> tuple[BudgetPolicy, bool]:
|
|
97
|
+
try:
|
|
98
|
+
policy = read_policy_file(repository_root)
|
|
99
|
+
except ContextBudgetPolicyError:
|
|
100
|
+
policy = None
|
|
101
|
+
if policy is None:
|
|
102
|
+
return default_policy(), True
|
|
103
|
+
return policy, False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _messages_for(edited_text: EditedText) -> list[str]:
|
|
107
|
+
file_path, prior_text, post_text = edited_text
|
|
108
|
+
repository_root = find_repository_root(file_path)
|
|
109
|
+
if repository_root is None:
|
|
110
|
+
return []
|
|
111
|
+
policy, is_prior_the_baseline = _policy_for(repository_root)
|
|
112
|
+
relative_path = file_path.relative_to(repository_root).as_posix()
|
|
113
|
+
all_findings = edit_findings(policy, relative_path, prior_text, post_text, is_prior_the_baseline)
|
|
114
|
+
return [each.message for each in all_findings]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def evaluate(payload_by_key: Mapping[str, object]) -> str | None:
|
|
118
|
+
"""Return a deny reason when a write adds context past its budget.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
payload_by_key: The PreToolUse payload.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The deny reason, or None to allow.
|
|
125
|
+
"""
|
|
126
|
+
all_messages = [
|
|
127
|
+
each_message
|
|
128
|
+
for each_edited_text in _edited_texts(payload_by_key)
|
|
129
|
+
for each_message in _messages_for(each_edited_text)
|
|
130
|
+
]
|
|
131
|
+
if not all_messages:
|
|
132
|
+
return None
|
|
133
|
+
return CONTEXT_BUDGET_BLOCK_PREFIX + CONTEXT_BUDGET_REASON_SEPARATOR.join(all_messages)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_deny_payload(deny_reason: str, tool_name: str | None) -> dict[str, object]:
|
|
137
|
+
"""Build the PreToolUse deny response and log the block.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
deny_reason: The reason ``evaluate`` returned.
|
|
141
|
+
tool_name: The intercepted tool name.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The deny payload.
|
|
145
|
+
"""
|
|
146
|
+
log_hook_block(
|
|
147
|
+
calling_hook_name=CONTEXT_BUDGET_HOOK_NAME,
|
|
148
|
+
hook_event=HOOK_EVENT_NAME,
|
|
149
|
+
block_reason=deny_reason,
|
|
150
|
+
tool_name=tool_name,
|
|
151
|
+
)
|
|
152
|
+
return {
|
|
153
|
+
"hookSpecificOutput": {
|
|
154
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
155
|
+
"permissionDecision": "deny",
|
|
156
|
+
"permissionDecisionReason": deny_reason,
|
|
157
|
+
},
|
|
158
|
+
"systemMessage": CONTEXT_BUDGET_NOTICE,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> None:
|
|
163
|
+
"""Read one hook payload and emit a deny response when needed."""
|
|
164
|
+
payload_by_key = read_hook_input_dictionary_from_stdin()
|
|
165
|
+
if payload_by_key is None:
|
|
166
|
+
return
|
|
167
|
+
deny_reason = evaluate(payload_by_key)
|
|
168
|
+
if deny_reason is None:
|
|
169
|
+
return
|
|
170
|
+
raw_tool_name = payload_by_key.get(TOOL_NAME_KEY)
|
|
171
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else None
|
|
172
|
+
sys.stdout.write(json.dumps(build_deny_payload(deny_reason, tool_name)) + "\n")
|
|
173
|
+
sys.stdout.flush()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
main()
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The context budget write hook denies only what an edit adds to agent context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
13
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
15
|
+
|
|
16
|
+
from blocking.context_budget_blocker import evaluate
|
|
17
|
+
|
|
18
|
+
FIXTURE_DIRECTORY = HOOKS_DIRECTORY / "test_files" / "context_budget"
|
|
19
|
+
DISPATCHER_PATH = HOOKS_DIRECTORY / "blocking" / "pre_tool_use_dispatcher.py"
|
|
20
|
+
ORCHESTRATOR_RELATIVE_PATH = "packages/claude-dev-env/.agents/skills/orchestrator/SKILL.md"
|
|
21
|
+
OVER_LIMIT_DETAIL = "".join(f"Detail line {each}.\n" for each in range(9))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fixture_text(file_name: str) -> str:
|
|
25
|
+
return (FIXTURE_DIRECTORY / file_name).read_text(encoding="utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def repository_root(tmp_path: Path) -> Path:
|
|
30
|
+
root = tmp_path / "other-repository"
|
|
31
|
+
(root / ".git").mkdir(parents=True)
|
|
32
|
+
return root
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_payload(file_path: Path, content: str) -> dict[str, object]:
|
|
36
|
+
return {
|
|
37
|
+
"tool_name": "Write",
|
|
38
|
+
"tool_input": {"file_path": str(file_path), "content": content},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _edit_payload(file_path: Path, old_string: str, new_string: str) -> dict[str, object]:
|
|
43
|
+
return {
|
|
44
|
+
"tool_name": "Edit",
|
|
45
|
+
"tool_input": {
|
|
46
|
+
"file_path": str(file_path),
|
|
47
|
+
"old_string": old_string,
|
|
48
|
+
"new_string": new_string,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _over_budget_skill() -> str:
|
|
54
|
+
return "# Skill\n## Plan\n" + OVER_LIMIT_DETAIL + "filler\n" * 200
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_calibration_old_revision_is_denied_and_new_revision_allowed(repository_root: Path) -> None:
|
|
58
|
+
target_path = repository_root / ORCHESTRATOR_RELATIVE_PATH
|
|
59
|
+
|
|
60
|
+
old_reason = evaluate(
|
|
61
|
+
_write_payload(target_path, _fixture_text("orchestrator_skill_3f2c3ef.txt"))
|
|
62
|
+
)
|
|
63
|
+
new_reason = evaluate(
|
|
64
|
+
_write_payload(target_path, _fixture_text("orchestrator_skill_98f45b1.txt"))
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assert old_reason is not None
|
|
68
|
+
assert (
|
|
69
|
+
'section "Oversee delegated work" has 9 detail lines and no pointer (limit 6)' in old_reason
|
|
70
|
+
)
|
|
71
|
+
assert new_reason is None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_edit_that_only_shrinks_an_over_budget_file_is_allowed(repository_root: Path) -> None:
|
|
75
|
+
target_path = repository_root / "skills" / "big" / "SKILL.md"
|
|
76
|
+
target_path.parent.mkdir(parents=True)
|
|
77
|
+
target_path.write_text(_over_budget_skill(), encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
assert evaluate(_edit_payload(target_path, "filler\nfiller\n", "")) is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_edit_that_grows_an_over_budget_file_is_denied(repository_root: Path) -> None:
|
|
83
|
+
target_path = repository_root / "skills" / "big" / "SKILL.md"
|
|
84
|
+
target_path.parent.mkdir(parents=True)
|
|
85
|
+
target_path.write_text(_over_budget_skill(), encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
reason = evaluate(_edit_payload(target_path, "# Skill\n", "# Skill\nOne more line.\n"))
|
|
88
|
+
|
|
89
|
+
assert reason is not None
|
|
90
|
+
assert "was already over its limit at 211 lines and may only shrink" in reason
|
|
91
|
+
assert "list" not in reason
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_edit_that_adds_a_line_to_a_short_skill_with_one_long_section_is_allowed(
|
|
95
|
+
repository_root: Path,
|
|
96
|
+
) -> None:
|
|
97
|
+
target_path = repository_root / "skills" / "short" / "SKILL.md"
|
|
98
|
+
target_path.parent.mkdir(parents=True)
|
|
99
|
+
plan_body = "".join(f"Detail line {each}.\n" for each in range(8))
|
|
100
|
+
target_path.write_text("# Skill\nIntro.\n## Plan\n" + plan_body, encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
assert len(target_path.read_text(encoding="utf-8").splitlines()) == 11
|
|
103
|
+
assert evaluate(_edit_payload(target_path, "Intro.\n", "Intro.\nOne more line.\n")) is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_multi_edit_adding_an_unpointed_section_is_denied(repository_root: Path) -> None:
|
|
107
|
+
target_path = repository_root / "AGENTS.md"
|
|
108
|
+
target_path.write_text("# Agents\nShort.\n", encoding="utf-8")
|
|
109
|
+
payload = {
|
|
110
|
+
"tool_name": "MultiEdit",
|
|
111
|
+
"tool_input": {
|
|
112
|
+
"file_path": str(target_path),
|
|
113
|
+
"edits": [
|
|
114
|
+
{"old_string": "Short.\n", "new_string": "Short.\n## Steps\n" + OVER_LIMIT_DETAIL}
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
reason = evaluate(payload)
|
|
120
|
+
|
|
121
|
+
assert reason is not None and 'section "Steps"' in reason
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_apply_patch_adding_an_over_budget_rule_is_denied(repository_root: Path) -> None:
|
|
125
|
+
patch_lines = ["*** Begin Patch", "*** Add File: rules/long.md"]
|
|
126
|
+
patch_lines.extend(f"+Rule line {each}." for each in range(31))
|
|
127
|
+
patch_lines.append("*** End Patch")
|
|
128
|
+
payload = {
|
|
129
|
+
"tool_name": "apply_patch",
|
|
130
|
+
"cwd": str(repository_root),
|
|
131
|
+
"tool_input": {"command": "\n".join(patch_lines) + "\n"},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
reason = evaluate(payload)
|
|
135
|
+
|
|
136
|
+
assert reason is not None and "file has 31 lines (rule limit 30)" in reason
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_policy_file_of_the_edited_repository_supplies_kinds_and_baseline(
|
|
140
|
+
repository_root: Path,
|
|
141
|
+
) -> None:
|
|
142
|
+
(repository_root / ".claude").mkdir()
|
|
143
|
+
policy = {
|
|
144
|
+
"kinds": [
|
|
145
|
+
{"name": "guide", "patterns": ["docs/*.md"], "line_limit": 3, "section_rule": False}
|
|
146
|
+
],
|
|
147
|
+
"baseline": {"files": {"docs/a.md": {"lines": 5, "sections": []}}},
|
|
148
|
+
}
|
|
149
|
+
(repository_root / ".claude" / "context-budget.json").write_text(
|
|
150
|
+
json.dumps(policy), encoding="utf-8"
|
|
151
|
+
)
|
|
152
|
+
target_path = repository_root / "docs" / "a.md"
|
|
153
|
+
|
|
154
|
+
assert evaluate(_write_payload(target_path, "1\n2\n3\n4\n5\n")) is None
|
|
155
|
+
reason = evaluate(_write_payload(target_path, "1\n2\n3\n4\n5\n6\n"))
|
|
156
|
+
assert reason is not None and "grew from 5 to 6 lines" in reason
|
|
157
|
+
assert evaluate(_write_payload(repository_root / "SKILL.md", OVER_LIMIT_DETAIL * 30)) is None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_malformed_policy_file_falls_back_to_the_built_in_kinds(repository_root: Path) -> None:
|
|
161
|
+
(repository_root / ".claude").mkdir()
|
|
162
|
+
(repository_root / ".claude" / "context-budget.json").write_text("{broken", encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
reason = evaluate(_write_payload(repository_root / "rules" / "r.md", "x\n" * 31))
|
|
165
|
+
|
|
166
|
+
assert reason is not None and "rule limit 30" in reason
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_files_outside_context_or_any_repository_are_allowed(
|
|
170
|
+
repository_root: Path, tmp_path: Path
|
|
171
|
+
) -> None:
|
|
172
|
+
assert evaluate(_write_payload(repository_root / "notes.txt", "x\n" * 999)) is None
|
|
173
|
+
assert evaluate(_write_payload(tmp_path / "loose" / "SKILL.md", OVER_LIMIT_DETAIL * 99)) is None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_dispatcher_carries_the_denial(repository_root: Path) -> None:
|
|
177
|
+
payload = _write_payload(repository_root / "rules" / "r.md", "x\n" * 31)
|
|
178
|
+
|
|
179
|
+
completed_process = subprocess.run(
|
|
180
|
+
[sys.executable, str(DISPATCHER_PATH)],
|
|
181
|
+
input=json.dumps(payload),
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
check=False,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
decision = json.loads(completed_process.stdout)["hookSpecificOutput"]
|
|
188
|
+
assert decision["permissionDecision"] == "deny"
|
|
189
|
+
assert "[CONTEXT_BUDGET]" in decision["permissionDecisionReason"]
|
|
@@ -804,7 +804,9 @@ def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
|
|
|
804
804
|
assert not is_deny, reason_text
|
|
805
805
|
|
|
806
806
|
|
|
807
|
-
ALL_RETAINED_PATHS
|
|
807
|
+
ALL_RETAINED_PATHS = {
|
|
808
|
+
"blocking/context_budget_blocker.py",
|
|
809
|
+
}
|
|
808
810
|
ALL_RETIRED_PATHS = {
|
|
809
811
|
"validation/hook_format_validator.py",
|
|
810
812
|
"blocking/code_rules_enforcer.py",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Context budget check: measure agent context files and hooks against a policy."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Build the over-budget list from the current tree, so no person writes it by hand.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
tree a/SKILL.md 743 lines, section "Plan" over the limit
|
|
6
|
+
hooks "startup print" injects 5200 characters (limit 1500)
|
|
7
|
+
result {"files": {"a/SKILL.md": {"lines": 743, "sections": ["Plan"]}},
|
|
8
|
+
"hooks": {"startup print": 5200}}
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
from context_budget.model import BudgetPolicy
|
|
17
|
+
from context_budget.sections import baseline_entry_for, measure_file
|
|
18
|
+
from hooks_constants.context_budget_constants import (
|
|
19
|
+
BASELINE_FILES_KEY,
|
|
20
|
+
BASELINE_HOOKS_KEY,
|
|
21
|
+
BASELINE_KEY,
|
|
22
|
+
JSON_INDENT,
|
|
23
|
+
LINES_KEY,
|
|
24
|
+
SECTIONS_KEY,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _file_entries(policy: BudgetPolicy, text_by_path: Mapping[str, str]) -> dict[str, dict]:
|
|
29
|
+
entry_by_path: dict[str, dict] = {}
|
|
30
|
+
for each_path in sorted(text_by_path):
|
|
31
|
+
measure = measure_file(policy, each_path, text_by_path[each_path])
|
|
32
|
+
entry = None if measure is None else baseline_entry_for(measure)
|
|
33
|
+
if entry is not None:
|
|
34
|
+
entry_by_path[each_path] = {
|
|
35
|
+
LINES_KEY: entry.lines,
|
|
36
|
+
SECTIONS_KEY: list(entry.all_section_headings),
|
|
37
|
+
}
|
|
38
|
+
return entry_by_path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_baseline(
|
|
42
|
+
policy: BudgetPolicy,
|
|
43
|
+
text_by_path: Mapping[str, str],
|
|
44
|
+
characters_by_hook_name: Mapping[str, int],
|
|
45
|
+
) -> dict[str, dict]:
|
|
46
|
+
"""Return the baseline object that records every item over budget now.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
policy: Budget policy whose kinds and hook limits apply.
|
|
50
|
+
text_by_path: Text of each tracked file by repository-relative path.
|
|
51
|
+
characters_by_hook_name: Measured characters of each policy hook.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The ``baseline`` object with sorted file and hook entries.
|
|
55
|
+
"""
|
|
56
|
+
entry_by_path = _file_entries(policy, text_by_path)
|
|
57
|
+
limit_by_hook_name = {each.name: each.char_limit for each in policy.all_hooks}
|
|
58
|
+
count_by_hook_name = {
|
|
59
|
+
each_name: each_count
|
|
60
|
+
for each_name, each_count in sorted(characters_by_hook_name.items())
|
|
61
|
+
if each_count > limit_by_hook_name.get(each_name, each_count)
|
|
62
|
+
}
|
|
63
|
+
return {BASELINE_FILES_KEY: entry_by_path, BASELINE_HOOKS_KEY: count_by_hook_name}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def policy_text_with_baseline(policy_text: str, baseline_by_key: Mapping[str, dict]) -> str:
|
|
67
|
+
"""Return the policy text with its baseline replaced and every other key kept.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
policy_text: Current policy JSON text.
|
|
71
|
+
baseline_by_key: The new ``baseline`` object.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Indented policy JSON text ending in a newline.
|
|
75
|
+
"""
|
|
76
|
+
raw_policy = json.loads(policy_text)
|
|
77
|
+
raw_policy[BASELINE_KEY] = dict(baseline_by_key)
|
|
78
|
+
return json.dumps(raw_policy, indent=JSON_INDENT) + "\n"
|