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,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: set[str] = set()
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"