claude-dev-env 8.72.2 → 8.73.0

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 (48) 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/tests/test_context_budget_check.py +270 -0
  46. package/scripts/tests/test_policy_lint_rules_context_budget.py +218 -0
  47. package/scripts/tests/test_policy_lint_rules_pairing.py +26 -0
  48. package/system-prompts/codex-sol.md +170 -0
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ """Answer "is this context file thin enough?" and keep the over-budget list current.
3
+
4
+ ::
5
+
6
+ python context_budget_check.py path/to/SKILL.md check files, prior = HEAD
7
+ python context_budget_check.py --hooks measure the policy's hooks
8
+ python context_budget_check.py --write-baseline rebuild the over-budget list
9
+
10
+ Run it from inside the repository whose ``.claude/context-budget.json`` applies.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ from collections.abc import Sequence
18
+ from pathlib import Path, PurePosixPath
19
+ from typing import TextIO
20
+
21
+ from dev_env_scripts_constants.context_budget_check_constants import (
22
+ ALL_GIT_ROOT_ARGUMENTS,
23
+ ALL_LS_FILES_ARGUMENTS,
24
+ BASELINE_WRITTEN_MESSAGE,
25
+ CLEAN_MESSAGE,
26
+ COMMAND_DESCRIPTION,
27
+ FAILURE_EXIT_CODE,
28
+ HEAD_REVISION,
29
+ HOOK_REPORT_TEMPLATE,
30
+ HOOKS_FLAG,
31
+ HOOKS_HELP,
32
+ MARKDOWN_SUFFIX,
33
+ NO_BASELINE_TEXT,
34
+ NO_POLICY_MESSAGE,
35
+ PATHS_HELP,
36
+ POLICY_RELATIVE_PATH,
37
+ SUCCESS_EXIT_CODE,
38
+ UTF8_ENCODING,
39
+ WRITE_BASELINE_FLAG,
40
+ WRITE_BASELINE_HELP,
41
+ )
42
+ from policy_lint import adapters
43
+ from policy_lint.model import ContentOrigin, Diagnostic, Document
44
+ from policy_lint.selection_git import git_bytes_for, read_blob, split_nul_tokens
45
+
46
+ _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
47
+ if _hooks_directory not in sys.path:
48
+ sys.path.insert(0, _hooks_directory)
49
+
50
+ from context_budget.baseline import build_baseline, policy_text_with_baseline
51
+ from context_budget.hook_measure import (
52
+ HookMeasurementError,
53
+ hook_findings,
54
+ injected_character_count,
55
+ run_hook_command,
56
+ )
57
+ from context_budget.model import BudgetPolicy, ContextBudgetPolicyError
58
+ from context_budget.policy_file import read_policy_file
59
+ from context_budget.sections import context_kind_for_path
60
+ from hooks_constants.context_budget_constants import BASELINE_FILES_KEY, BASELINE_HOOKS_KEY
61
+
62
+
63
+ def build_parser() -> argparse.ArgumentParser:
64
+ """Return the command's argument parser.
65
+
66
+ Returns:
67
+ The parser for paths, ``--hooks``, and ``--write-baseline``.
68
+ """
69
+ parser = argparse.ArgumentParser(description=COMMAND_DESCRIPTION)
70
+ parser.add_argument("paths", nargs="*", help=PATHS_HELP)
71
+ parser.add_argument(HOOKS_FLAG, action="store_true", help=HOOKS_HELP)
72
+ parser.add_argument(WRITE_BASELINE_FLAG, action="store_true", help=WRITE_BASELINE_HELP)
73
+ return parser
74
+
75
+
76
+ def _repository_root(starting_directory: Path) -> Path:
77
+ root_bytes = git_bytes_for(starting_directory, ALL_GIT_ROOT_ARGUMENTS)
78
+ return Path(root_bytes.decode(UTF8_ENCODING).strip()).resolve()
79
+
80
+
81
+ def _document_for(repository_root: Path, file_path: Path) -> Document:
82
+ relative_path = file_path.resolve().relative_to(repository_root).as_posix()
83
+ return Document(
84
+ PurePosixPath(relative_path),
85
+ file_path.read_text(encoding=UTF8_ENCODING),
86
+ read_blob(repository_root, HEAD_REVISION, relative_path),
87
+ None,
88
+ ContentOrigin.WORKTREE,
89
+ )
90
+
91
+
92
+ def _diagnostics_for(repository_root: Path, file_path: Path) -> tuple[Diagnostic, ...]:
93
+ document = _document_for(repository_root, file_path)
94
+ if document.path.as_posix() == POLICY_RELATIVE_PATH:
95
+ return adapters.context_budget_policy_diagnostics(document, repository_root)
96
+ if document.path.suffix == MARKDOWN_SUFFIX:
97
+ return adapters.context_budget_diagnostics(document, repository_root)
98
+ return ()
99
+
100
+
101
+ def _check_paths(repository_root: Path, all_paths: Sequence[str], stdout: TextIO) -> int:
102
+ """Check explicit files with each file's HEAD version as its prior state.
103
+
104
+ Args:
105
+ repository_root: Repository root holding the policy.
106
+ all_paths: Paths given on the command line.
107
+ stdout: Report stream.
108
+
109
+ Returns:
110
+ Zero when every file is within budget, else one.
111
+ """
112
+ all_messages = [
113
+ each_diagnostic.message
114
+ for each_path in all_paths
115
+ for each_diagnostic in _diagnostics_for(repository_root, Path(each_path))
116
+ ]
117
+ for each_message in all_messages:
118
+ stdout.write(each_message + "\n")
119
+ if all_messages:
120
+ return FAILURE_EXIT_CODE
121
+ stdout.write(CLEAN_MESSAGE.format(count=len(all_paths)) + "\n")
122
+ return SUCCESS_EXIT_CODE
123
+
124
+
125
+ def _measure_hooks(policy: BudgetPolicy, repository_root: Path) -> dict[str, int]:
126
+ """Run every hook the policy lists and count the characters each injects.
127
+
128
+ Args:
129
+ policy: Budget policy listing the hooks.
130
+ repository_root: Directory each hook runs from.
131
+
132
+ Returns:
133
+ Injected characters by hook name.
134
+ """
135
+ return {
136
+ each_hook.name: injected_character_count(run_hook_command(each_hook, repository_root))
137
+ for each_hook in policy.all_hooks
138
+ }
139
+
140
+
141
+ def _check_hooks(policy: BudgetPolicy, repository_root: Path, stdout: TextIO) -> int:
142
+ """Report each hook's injected characters and fail on any over budget.
143
+
144
+ Args:
145
+ policy: Budget policy listing the hooks.
146
+ repository_root: Directory each hook runs from.
147
+ stdout: Report stream.
148
+
149
+ Returns:
150
+ Zero when every hook is within budget, else one.
151
+ """
152
+ count_by_name = _measure_hooks(policy, repository_root)
153
+ all_messages: list[str] = []
154
+ for each_hook in policy.all_hooks:
155
+ count = count_by_name[each_hook.name]
156
+ baseline = policy.baseline_characters_by_hook_name.get(each_hook.name, NO_BASELINE_TEXT)
157
+ stdout.write(
158
+ HOOK_REPORT_TEMPLATE.format(
159
+ name=each_hook.name, count=count, limit=each_hook.char_limit, baseline=baseline
160
+ )
161
+ + "\n"
162
+ )
163
+ all_messages.extend(hook_findings(policy, each_hook, count))
164
+ for each_message in all_messages:
165
+ stdout.write(each_message + "\n")
166
+ return FAILURE_EXIT_CODE if all_messages else SUCCESS_EXIT_CODE
167
+
168
+
169
+ def current_baseline(policy: BudgetPolicy, repository_root: Path) -> dict[str, dict]:
170
+ """Build the baseline the tracked tree and the policy's hooks need today.
171
+
172
+ Args:
173
+ policy: Current budget policy.
174
+ repository_root: Repository root holding the policy.
175
+
176
+ Returns:
177
+ The ``baseline`` object.
178
+ """
179
+ all_tracked_paths = split_nul_tokens(git_bytes_for(repository_root, ALL_LS_FILES_ARGUMENTS))
180
+ text_by_path = {
181
+ each_path: (repository_root / each_path).read_text(encoding=UTF8_ENCODING)
182
+ for each_path in all_tracked_paths
183
+ if context_kind_for_path(policy, each_path) is not None
184
+ and (repository_root / each_path).is_file()
185
+ }
186
+ return build_baseline(policy, text_by_path, _measure_hooks(policy, repository_root))
187
+
188
+
189
+ def _write_baseline(policy: BudgetPolicy, repository_root: Path, stdout: TextIO) -> int:
190
+ """Rebuild the policy file's baseline from the tracked tree and its hooks.
191
+
192
+ Args:
193
+ policy: Current budget policy.
194
+ repository_root: Repository root holding the policy.
195
+ stdout: Report stream.
196
+
197
+ Returns:
198
+ Zero after the file is written.
199
+ """
200
+ baseline = current_baseline(policy, repository_root)
201
+ policy_path = repository_root / POLICY_RELATIVE_PATH
202
+ policy_text = policy_path.read_text(encoding=UTF8_ENCODING)
203
+ policy_path.write_text(policy_text_with_baseline(policy_text, baseline), encoding=UTF8_ENCODING)
204
+ file_count = len(baseline[BASELINE_FILES_KEY])
205
+ hook_count = len(baseline[BASELINE_HOOKS_KEY])
206
+ stdout.write(BASELINE_WRITTEN_MESSAGE.format(files=file_count, hooks=hook_count) + "\n")
207
+ return SUCCESS_EXIT_CODE
208
+
209
+
210
+ def _run_action(namespace: argparse.Namespace, repository_root: Path, stdout: TextIO) -> int:
211
+ policy = read_policy_file(repository_root)
212
+ if policy is None:
213
+ stdout.write(NO_POLICY_MESSAGE + "\n")
214
+ return FAILURE_EXIT_CODE
215
+ if namespace.write_baseline:
216
+ return _write_baseline(policy, repository_root, stdout)
217
+ if namespace.hooks:
218
+ return _check_hooks(policy, repository_root, stdout)
219
+ return _check_paths(repository_root, namespace.paths, stdout)
220
+
221
+
222
+ def main(all_arguments: Sequence[str], starting_directory: Path, stdout: TextIO) -> int:
223
+ """Run the command.
224
+
225
+ Args:
226
+ all_arguments: Command-line arguments.
227
+ starting_directory: Directory inside the repository to check.
228
+ stdout: Report stream.
229
+
230
+ Returns:
231
+ The exit code of the requested action.
232
+ """
233
+ namespace = build_parser().parse_args(list(all_arguments))
234
+ repository_root = _repository_root(starting_directory)
235
+ try:
236
+ return _run_action(namespace, repository_root, stdout)
237
+ except (ContextBudgetPolicyError, HookMeasurementError) as error:
238
+ stdout.write(str(error) + "\n")
239
+ return FAILURE_EXIT_CODE
240
+
241
+
242
+ if __name__ == "__main__":
243
+ raise SystemExit(main(sys.argv[1:], Path.cwd(), sys.stdout))
@@ -0,0 +1,26 @@
1
+ """Constants for the context budget command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ COMMAND_DESCRIPTION = (
6
+ "Check context files against .claude/context-budget.json, measure its hooks, "
7
+ "or rebuild its over-budget list from the current tree."
8
+ )
9
+ PATHS_HELP = "Context files to check; the prior state is each file's HEAD version."
10
+ HOOKS_FLAG = "--hooks"
11
+ HOOKS_HELP = "Run each hook the policy lists and hold it to its character budget."
12
+ WRITE_BASELINE_FLAG = "--write-baseline"
13
+ WRITE_BASELINE_HELP = "Rebuild the policy's baseline from the tracked tree and its hooks."
14
+ HEAD_REVISION = "HEAD"
15
+ ALL_LS_FILES_ARGUMENTS: tuple[str, ...] = ("ls-files", "-z")
16
+ ALL_GIT_ROOT_ARGUMENTS: tuple[str, ...] = ("rev-parse", "--show-toplevel")
17
+ POLICY_RELATIVE_PATH = ".claude/context-budget.json"
18
+ MARKDOWN_SUFFIX = ".md"
19
+ UTF8_ENCODING = "utf-8"
20
+ NO_POLICY_MESSAGE = "context-budget: no " + POLICY_RELATIVE_PATH + " in this repository."
21
+ CLEAN_MESSAGE = "context-budget: OK ({count} checked)"
22
+ HOOK_REPORT_TEMPLATE = "{name}: {count} characters (limit {limit}, baseline {baseline})"
23
+ BASELINE_WRITTEN_MESSAGE = "context-budget: baseline written ({files} files, {hooks} hooks)"
24
+ NO_BASELINE_TEXT = "none"
25
+ FAILURE_EXIT_CODE = 1
26
+ SUCCESS_EXIT_CODE = 0
@@ -0,0 +1,158 @@
1
+ """Hold changed agent context files and the over-budget list to the context budget.
2
+
3
+ The budget core lives in ``hooks/context_budget``, shared with the write hook.
4
+ The policy is ``.claude/context-budget.json`` at the repository root; a
5
+ repository without one is not checked.
6
+
7
+ ::
8
+
9
+ context-budget SKILL.md over its limit grew -> finding
10
+ context-budget-policy baseline entry raised -> finding
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path, PurePosixPath
16
+ from types import ModuleType
17
+
18
+ from .adapter_support import HookModuleLoader
19
+ from .config import constants
20
+ from .model import Diagnostic, Document, DocumentSet, Severity
21
+
22
+
23
+ def accepts_context_markdown(document: Document) -> bool:
24
+ """Return whether the document is Markdown the kind table may select.
25
+
26
+ Args:
27
+ document: Candidate document.
28
+
29
+ Returns:
30
+ True for Markdown documents.
31
+ """
32
+ return document.path.suffix.lower() in constants.ALL_MARKDOWN_SUFFIXES
33
+
34
+
35
+ def accepts_context_budget_policy(document: Document) -> bool:
36
+ """Return whether the document is the repository's budget policy file.
37
+
38
+ Args:
39
+ document: Candidate document.
40
+
41
+ Returns:
42
+ True for ``.claude/context-budget.json``.
43
+ """
44
+ return document.path.as_posix() == constants.CONTEXT_BUDGET_POLICY_PATH
45
+
46
+
47
+ def _file_diagnostic(rule_id: str, message: str) -> Diagnostic:
48
+ return Diagnostic(rule_id, Severity.ERROR, message)
49
+
50
+
51
+ def context_budget_diagnostics(
52
+ document: Document, repository_root: Path, load_module: HookModuleLoader
53
+ ) -> tuple[Diagnostic, ...]:
54
+ """Report the budget findings of one changed context file.
55
+
56
+ Args:
57
+ document: Current text and the prior text the ratchet compares.
58
+ repository_root: Repository root holding the policy file.
59
+ load_module: Hook module loader.
60
+
61
+ Returns:
62
+ One diagnostic per finding; empty without a readable policy file.
63
+ """
64
+ policy_file = load_module(constants.CONTEXT_BUDGET_POLICY_FILE_MODULE)
65
+ model = load_module(constants.CONTEXT_BUDGET_MODEL_MODULE)
66
+ try:
67
+ policy = policy_file.read_policy_file(repository_root)
68
+ except model.ContextBudgetPolicyError:
69
+ return ()
70
+ if policy is None:
71
+ return ()
72
+ findings = load_module(constants.CONTEXT_BUDGET_FINDINGS_MODULE)
73
+ all_findings = findings.file_findings(
74
+ policy, document.path.as_posix(), document.text, document.prior_text
75
+ )
76
+ return tuple(
77
+ _file_diagnostic(constants.CONTEXT_BUDGET_RULE_ID, each.message)
78
+ for each in all_findings
79
+ )
80
+
81
+
82
+ def _prior_policy(prior_text: str | None, policy_file: ModuleType, model: ModuleType) -> object:
83
+ if prior_text is None:
84
+ return None
85
+ try:
86
+ return policy_file.parse_policy(prior_text)
87
+ except model.ContextBudgetPolicyError:
88
+ return None
89
+
90
+
91
+ def _policy_messages(
92
+ document: Document, repository_root: Path, load_module: HookModuleLoader
93
+ ) -> list[str]:
94
+ policy_file = load_module(constants.CONTEXT_BUDGET_POLICY_FILE_MODULE)
95
+ model = load_module(constants.CONTEXT_BUDGET_MODEL_MODULE)
96
+ policy_path = document.path.as_posix()
97
+ try:
98
+ current_policy = policy_file.parse_policy(document.text)
99
+ except model.ContextBudgetPolicyError as error:
100
+ all_messages = [
101
+ constants.CONTEXT_BUDGET_UNREADABLE_MESSAGE.format(path=policy_path, error=error)
102
+ ]
103
+ else:
104
+ prior_policy = _prior_policy(document.prior_text, policy_file, model)
105
+ policy_changes = load_module(constants.CONTEXT_BUDGET_POLICY_CHANGES_MODULE)
106
+ all_messages = [
107
+ each.message
108
+ for each in policy_changes.policy_shrink_findings(
109
+ policy_path, prior_policy, current_policy, repository_root
110
+ )
111
+ ]
112
+ return all_messages
113
+
114
+
115
+ def context_budget_policy_diagnostics(
116
+ document: Document, repository_root: Path, load_module: HookModuleLoader
117
+ ) -> tuple[Diagnostic, ...]:
118
+ """Report each change to the policy file that loosens the budget.
119
+
120
+ Args:
121
+ document: Current policy text and its prior text.
122
+ repository_root: Working tree that holds each hook's command script.
123
+ load_module: Hook module loader.
124
+
125
+ Returns:
126
+ One diagnostic per loosening change, or one for an unparseable file.
127
+ """
128
+ rule_id = constants.CONTEXT_BUDGET_POLICY_RULE_ID
129
+ all_messages = _policy_messages(document, repository_root, load_module)
130
+ return tuple(_file_diagnostic(rule_id, each) for each in all_messages)
131
+
132
+
133
+ def context_budget_policy_change_set_diagnostics(
134
+ document_set: DocumentSet,
135
+ ) -> tuple[Diagnostic, ...]:
136
+ """Reject removal or movement of the policy path in a Git selection.
137
+
138
+ Args:
139
+ document_set: Changed documents and removed or renamed paths.
140
+
141
+ Returns:
142
+ One finding when the selection removes the policy path.
143
+ """
144
+ policy_path = PurePosixPath(constants.CONTEXT_BUDGET_POLICY_PATH)
145
+ removed_paths = set(document_set.deleted_paths)
146
+ removed_paths.update(
147
+ old_path for old_path, new_path in document_set.renamed_paths if old_path != new_path
148
+ )
149
+ if policy_path not in removed_paths:
150
+ return ()
151
+ return (
152
+ Diagnostic(
153
+ constants.CONTEXT_BUDGET_POLICY_RULE_ID,
154
+ Severity.ERROR,
155
+ constants.CONTEXT_BUDGET_POLICY_REMOVED_MESSAGE.format(path=policy_path),
156
+ check_id=constants.CONTEXT_BUDGET_POLICY_REMOVAL_RULE_ID,
157
+ ),
158
+ )
@@ -11,6 +11,7 @@ from shared_tree_paths import resolve_shared_scripts_directory
11
11
  from . import (
12
12
  adapter_callers,
13
13
  adapter_configuration,
14
+ adapter_context_budget,
14
15
  adapter_contrast_framing,
15
16
  adapter_detectors,
16
17
  adapter_pairing,
@@ -498,3 +499,75 @@ def retired_hook_prose_diagnostics(
498
499
  return adapter_retired_hook_prose.retired_hook_prose_diagnostics(
499
500
  document, repository_root
500
501
  )
502
+
503
+
504
+ def accepts_context_markdown(document: Document) -> bool:
505
+ """Return whether the document is Markdown the context budget may select.
506
+
507
+ Args:
508
+ document: Candidate document.
509
+
510
+ Returns:
511
+ True for Markdown documents.
512
+ """
513
+ return adapter_context_budget.accepts_context_markdown(document)
514
+
515
+
516
+ def accepts_context_budget_policy(document: Document) -> bool:
517
+ """Return whether the document is the context budget policy file.
518
+
519
+ Args:
520
+ document: Candidate document.
521
+
522
+ Returns:
523
+ True for ``.claude/context-budget.json``.
524
+ """
525
+ return adapter_context_budget.accepts_context_budget_policy(document)
526
+
527
+
528
+ def context_budget_diagnostics(
529
+ document: Document, repository_root: Path
530
+ ) -> tuple[Diagnostic, ...]:
531
+ """Hold one changed context file to its kind limits and the over-budget list.
532
+
533
+ Args:
534
+ document: Current Markdown text and its prior text.
535
+ repository_root: Repository root holding the policy file.
536
+
537
+ Returns:
538
+ Context budget diagnostics.
539
+ """
540
+ return adapter_context_budget.context_budget_diagnostics(
541
+ document, repository_root, _hooks_module
542
+ )
543
+
544
+
545
+ def context_budget_policy_diagnostics(
546
+ document: Document, repository_root: Path
547
+ ) -> tuple[Diagnostic, ...]:
548
+ """Report a policy file change that loosens the context budget.
549
+
550
+ Args:
551
+ document: Current policy text and its prior text.
552
+ repository_root: Request repository root.
553
+
554
+ Returns:
555
+ Shrink-only diagnostics.
556
+ """
557
+ return adapter_context_budget.context_budget_policy_diagnostics(
558
+ document, repository_root, _hooks_module
559
+ )
560
+
561
+
562
+ def context_budget_policy_change_set_diagnostics(
563
+ document_set: DocumentSet,
564
+ ) -> tuple[Diagnostic, ...]:
565
+ """Reject a Git selection that removes the context budget policy path.
566
+
567
+ Args:
568
+ document_set: Changed documents and removed or renamed paths.
569
+
570
+ Returns:
571
+ Policy removal diagnostics.
572
+ """
573
+ return adapter_context_budget.context_budget_policy_change_set_diagnostics(document_set)
@@ -77,7 +77,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
77
77
  )
78
78
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
79
79
  ALL_TEST_FILE_PREFIXES = ("test_",)
80
- ALL_TEST_FILE_SUFFIXES = ("_test.py",)
80
+ ALL_TEST_FILE_SUFFIXES = ("_test.py", "_test_support.py")
81
81
  POLICY_LINT_DIRECTORY_NAME = "policy_lint"
82
82
  POLICY_LINT_RULES_TEST_PREFIX = "test_policy_lint_rules"
83
83
  POLICY_LINT_SELECTION_TEST_PREFIX = "test_policy_lint_selection"
@@ -200,3 +200,19 @@ ALL_INVENTORY_FILE_NAMES = frozenset(
200
200
  )
201
201
  ALL_UNCALLED_EXEMPT_FILE_NAMES = frozenset({"__init__.py", "conftest.py"})
202
202
  CALLER_NAME_PATTERN_TEMPLATE = r"(?<![\w-]){name}(?![\w-])"
203
+
204
+ CONTEXT_BUDGET_RULE_ID = "context-budget"
205
+ CONTEXT_BUDGET_POLICY_RULE_ID = "context-budget-policy"
206
+ CONTEXT_BUDGET_POLICY_REMOVAL_RULE_ID = "context-budget-policy-removal"
207
+ CONTEXT_BUDGET_POLICY_PATH = ".claude/context-budget.json"
208
+ CONTEXT_BUDGET_POLICY_REMOVED_MESSAGE = (
209
+ "{path}: context-budget: the policy file is removed. Restore it to preserve the budget."
210
+ )
211
+ CONTEXT_BUDGET_POLICY_FILE_MODULE = "context_budget.policy_file"
212
+ CONTEXT_BUDGET_FINDINGS_MODULE = "context_budget.findings"
213
+ CONTEXT_BUDGET_POLICY_CHANGES_MODULE = "context_budget.policy_changes"
214
+ CONTEXT_BUDGET_MODEL_MODULE = "context_budget.model"
215
+ CONTEXT_BUDGET_UNREADABLE_MESSAGE = (
216
+ "{path}: context-budget: the policy file does not parse ({error}). Fix it to"
217
+ " the documented shape."
218
+ )
@@ -4,6 +4,7 @@ from collections.abc import Callable
4
4
  from pathlib import PurePosixPath
5
5
 
6
6
  from . import adapters, model
7
+ from .config import constants
7
8
 
8
9
 
9
10
  def build_document_rule(
@@ -114,6 +115,16 @@ def _text_document_rules() -> tuple[model.Rule, ...]:
114
115
  adapters.accepts_instruction_markdown,
115
116
  adapters.retired_hook_prose_diagnostics,
116
117
  ),
118
+ build_document_rule(
119
+ "context-budget",
120
+ adapters.accepts_context_markdown,
121
+ adapters.context_budget_diagnostics,
122
+ ),
123
+ build_document_rule(
124
+ "context-budget-policy",
125
+ adapters.accepts_context_budget_policy,
126
+ adapters.context_budget_policy_diagnostics,
127
+ ),
117
128
  )
118
129
 
119
130
 
@@ -142,6 +153,12 @@ def default_registry() -> tuple[model.Rule, ...]:
142
153
  *_python_document_rules(),
143
154
  *_text_document_rules(),
144
155
  *_configuration_document_rules(),
156
+ model.ChangeSetRule(
157
+ constants.CONTEXT_BUDGET_POLICY_REMOVAL_RULE_ID,
158
+ frozenset({"changed"}),
159
+ frozenset({model.SelectionKind.STAGED, model.SelectionKind.BASE}),
160
+ adapters.context_budget_policy_change_set_diagnostics,
161
+ ),
145
162
  model.ChangeSetRule(
146
163
  "test-pairing",
147
164
  frozenset({"changed"}),