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.
- 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/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,251 @@
|
|
|
1
|
+
"""Compare a context file with its kind limits and its over-budget entry.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
unlisted file lines > limit -> file finding
|
|
6
|
+
unpointed section > detail limit -> section finding
|
|
7
|
+
listed file lines null, lines > limit -> file finding
|
|
8
|
+
lines N, lines > limit and lines > N -> grown finding
|
|
9
|
+
section heading not recorded -> section finding
|
|
10
|
+
CI ratchet changed file below its record N -> record the new count, or null
|
|
11
|
+
changed file lost a recorded section -> drop it from the entry
|
|
12
|
+
changed file within budget -> remove the entry
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
|
|
21
|
+
from context_budget.model import (
|
|
22
|
+
BaselineEntry,
|
|
23
|
+
BudgetFinding,
|
|
24
|
+
BudgetPolicy,
|
|
25
|
+
FileMeasure,
|
|
26
|
+
SectionScan,
|
|
27
|
+
)
|
|
28
|
+
from context_budget.sections import baseline_entry_for, measure_file
|
|
29
|
+
from hooks_constants.context_budget_constants import (
|
|
30
|
+
FILE_FINDING_TEMPLATE,
|
|
31
|
+
FILE_SCOPE,
|
|
32
|
+
GROWN_FINDING_TEMPLATE,
|
|
33
|
+
GROWN_PAST_PRIOR_TEMPLATE,
|
|
34
|
+
KEBAB_SEPARATOR,
|
|
35
|
+
LINES_KEY,
|
|
36
|
+
NON_KEBAB_CHARACTER_PATTERN,
|
|
37
|
+
RATCHET_LOWER_TEMPLATE,
|
|
38
|
+
RATCHET_REMOVE_TEMPLATE,
|
|
39
|
+
SECTION_FINDING_TEMPLATE,
|
|
40
|
+
SECTIONS_KEY,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _kebab(heading: str) -> str:
|
|
45
|
+
return NON_KEBAB_CHARACTER_PATTERN.sub(KEBAB_SEPARATOR, heading.lower()).strip(KEBAB_SEPARATOR)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _section_finding(relative_path: str, scan: SectionScan, limit: int) -> BudgetFinding:
|
|
49
|
+
return BudgetFinding(
|
|
50
|
+
relative_path,
|
|
51
|
+
scan.heading,
|
|
52
|
+
SECTION_FINDING_TEMPLATE.format(
|
|
53
|
+
path=relative_path,
|
|
54
|
+
heading=scan.heading,
|
|
55
|
+
count=scan.detail_line_count,
|
|
56
|
+
limit=limit,
|
|
57
|
+
kebab=_kebab(scan.heading),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _unrecorded_sections(
|
|
63
|
+
all_sections: Iterable[SectionScan], all_recorded_headings: Iterable[str]
|
|
64
|
+
) -> list[SectionScan]:
|
|
65
|
+
remaining_count_by_heading = Counter(all_recorded_headings)
|
|
66
|
+
all_unrecorded: list[SectionScan] = []
|
|
67
|
+
for each_scan in all_sections:
|
|
68
|
+
if remaining_count_by_heading[each_scan.heading] > 0:
|
|
69
|
+
remaining_count_by_heading[each_scan.heading] -= 1
|
|
70
|
+
else:
|
|
71
|
+
all_unrecorded.append(each_scan)
|
|
72
|
+
return all_unrecorded
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _entry_json(entry: BaselineEntry) -> str:
|
|
76
|
+
return json.dumps({LINES_KEY: entry.lines, SECTIONS_KEY: list(entry.all_section_headings)})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ratchet_message(
|
|
80
|
+
relative_path: str, measure: FileMeasure, current: BaselineEntry, recorded: BaselineEntry
|
|
81
|
+
) -> str:
|
|
82
|
+
return RATCHET_LOWER_TEMPLATE.format(
|
|
83
|
+
path=relative_path,
|
|
84
|
+
recorded_entry_json=_entry_json(recorded),
|
|
85
|
+
count=measure.line_count,
|
|
86
|
+
sections=json.dumps(list(current.all_section_headings)),
|
|
87
|
+
entry_json=_entry_json(current),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ratchet_findings(
|
|
92
|
+
relative_path: str, measure: FileMeasure, recorded: BaselineEntry
|
|
93
|
+
) -> list[BudgetFinding]:
|
|
94
|
+
current = baseline_entry_for(measure)
|
|
95
|
+
if current is None:
|
|
96
|
+
message = RATCHET_REMOVE_TEMPLATE.format(path=relative_path, count=measure.line_count)
|
|
97
|
+
return [BudgetFinding(relative_path, FILE_SCOPE, message)]
|
|
98
|
+
has_lost_section = bool(
|
|
99
|
+
Counter(recorded.all_section_headings) - Counter(current.all_section_headings)
|
|
100
|
+
)
|
|
101
|
+
has_dropped_lines = recorded.lines is not None and (
|
|
102
|
+
current.lines is None or current.lines < recorded.lines
|
|
103
|
+
)
|
|
104
|
+
if not has_dropped_lines and not has_lost_section:
|
|
105
|
+
return []
|
|
106
|
+
message = _ratchet_message(relative_path, measure, current, recorded)
|
|
107
|
+
return [BudgetFinding(relative_path, FILE_SCOPE, message)]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _line_limit_findings(relative_path: str, measure: FileMeasure) -> list[BudgetFinding]:
|
|
111
|
+
limit = measure.kind.line_limit or 0
|
|
112
|
+
if measure.line_count <= limit:
|
|
113
|
+
return []
|
|
114
|
+
message = FILE_FINDING_TEMPLATE.format(
|
|
115
|
+
path=relative_path, count=measure.line_count, kind=measure.kind.name, limit=limit
|
|
116
|
+
)
|
|
117
|
+
return [BudgetFinding(relative_path, FILE_SCOPE, message)]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _grown_findings(
|
|
121
|
+
relative_path: str, measure: FileMeasure, recorded: BaselineEntry, grown_template: str
|
|
122
|
+
) -> list[BudgetFinding]:
|
|
123
|
+
if recorded.lines is None:
|
|
124
|
+
return _line_limit_findings(relative_path, measure)
|
|
125
|
+
limit = measure.kind.line_limit or 0
|
|
126
|
+
if measure.line_count <= max(recorded.lines, limit):
|
|
127
|
+
return []
|
|
128
|
+
message = grown_template.format(
|
|
129
|
+
path=relative_path,
|
|
130
|
+
recorded=recorded.lines,
|
|
131
|
+
count=measure.line_count,
|
|
132
|
+
kind=measure.kind.name,
|
|
133
|
+
limit=limit,
|
|
134
|
+
)
|
|
135
|
+
return [BudgetFinding(relative_path, FILE_SCOPE, message)]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _file_scope_findings(
|
|
139
|
+
relative_path: str,
|
|
140
|
+
measure: FileMeasure,
|
|
141
|
+
recorded: BaselineEntry | None,
|
|
142
|
+
is_ratchet_due: bool,
|
|
143
|
+
grown_template: str,
|
|
144
|
+
) -> tuple[list[BudgetFinding], list[SectionScan]]:
|
|
145
|
+
if recorded is None:
|
|
146
|
+
return _line_limit_findings(relative_path, measure), list(measure.all_over_limit_sections)
|
|
147
|
+
all_findings = _grown_findings(relative_path, measure, recorded, grown_template)
|
|
148
|
+
if is_ratchet_due:
|
|
149
|
+
all_findings.extend(_ratchet_findings(relative_path, measure, recorded))
|
|
150
|
+
all_new_sections = _unrecorded_sections(
|
|
151
|
+
measure.all_over_limit_sections, recorded.all_section_headings
|
|
152
|
+
)
|
|
153
|
+
return all_findings, all_new_sections
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def file_findings(
|
|
157
|
+
policy: BudgetPolicy, relative_path: str, text: str, ratchet_prior_text: str | None
|
|
158
|
+
) -> tuple[BudgetFinding, ...]:
|
|
159
|
+
"""Check one file against its kind and the over-budget list.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
policy: Budget policy.
|
|
163
|
+
relative_path: Repository-relative POSIX path.
|
|
164
|
+
text: Current file text.
|
|
165
|
+
ratchet_prior_text: Prior text when the CI ratchet applies, else None.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Findings in file-then-section order.
|
|
169
|
+
"""
|
|
170
|
+
measure = measure_file(policy, relative_path, text)
|
|
171
|
+
if measure is None:
|
|
172
|
+
return ()
|
|
173
|
+
recorded = policy.baseline_entry_by_path.get(relative_path)
|
|
174
|
+
is_ratchet_due = ratchet_prior_text is not None and ratchet_prior_text != text
|
|
175
|
+
grown_template = (
|
|
176
|
+
GROWN_PAST_PRIOR_TEMPLATE if policy.is_baseline_the_prior_text else GROWN_FINDING_TEMPLATE
|
|
177
|
+
)
|
|
178
|
+
all_findings, all_new_sections = _file_scope_findings(
|
|
179
|
+
relative_path, measure, recorded, is_ratchet_due, grown_template
|
|
180
|
+
)
|
|
181
|
+
return (
|
|
182
|
+
*all_findings,
|
|
183
|
+
*(_section_finding(relative_path, each, policy.section_detail_line_limit)
|
|
184
|
+
for each in all_new_sections),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _with_prior_as_baseline(
|
|
189
|
+
policy: BudgetPolicy, relative_path: str, prior_text: str
|
|
190
|
+
) -> BudgetPolicy:
|
|
191
|
+
prior_measure = measure_file(policy, relative_path, prior_text)
|
|
192
|
+
prior_entry = None if prior_measure is None else baseline_entry_for(prior_measure)
|
|
193
|
+
if prior_entry is None:
|
|
194
|
+
return policy
|
|
195
|
+
return BudgetPolicy(
|
|
196
|
+
policy.section_detail_line_limit,
|
|
197
|
+
policy.all_kinds,
|
|
198
|
+
policy.all_hooks,
|
|
199
|
+
{**policy.baseline_entry_by_path, relative_path: prior_entry},
|
|
200
|
+
policy.baseline_characters_by_hook_name,
|
|
201
|
+
is_baseline_the_prior_text=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _introduced(
|
|
206
|
+
all_post_findings: Iterable[BudgetFinding],
|
|
207
|
+
all_prior_findings: Iterable[BudgetFinding],
|
|
208
|
+
has_grown: bool,
|
|
209
|
+
) -> tuple[BudgetFinding, ...]:
|
|
210
|
+
remaining_count_by_scope = Counter(each.scope for each in all_prior_findings)
|
|
211
|
+
all_introduced: list[BudgetFinding] = []
|
|
212
|
+
for each_finding in all_post_findings:
|
|
213
|
+
if each_finding.scope == FILE_SCOPE:
|
|
214
|
+
is_new = has_grown
|
|
215
|
+
else:
|
|
216
|
+
is_new = remaining_count_by_scope[each_finding.scope] <= 0
|
|
217
|
+
remaining_count_by_scope[each_finding.scope] -= 1
|
|
218
|
+
if is_new:
|
|
219
|
+
all_introduced.append(each_finding)
|
|
220
|
+
return tuple(all_introduced)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def edit_findings(
|
|
224
|
+
policy: BudgetPolicy,
|
|
225
|
+
relative_path: str,
|
|
226
|
+
prior_text: str | None,
|
|
227
|
+
post_text: str,
|
|
228
|
+
is_prior_the_baseline: bool,
|
|
229
|
+
) -> tuple[BudgetFinding, ...]:
|
|
230
|
+
"""Return the findings an edit adds; a file finding needs added lines.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
policy: Budget policy.
|
|
234
|
+
relative_path: Repository-relative POSIX path.
|
|
235
|
+
prior_text: On-disk text before the edit, or None for a new file.
|
|
236
|
+
post_text: Text the edit leaves.
|
|
237
|
+
is_prior_the_baseline: Whether the prior text is the baseline entry.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Findings present after the edit and absent before it.
|
|
241
|
+
"""
|
|
242
|
+
if prior_text is None:
|
|
243
|
+
return file_findings(policy, relative_path, post_text, None)
|
|
244
|
+
effective_policy = policy
|
|
245
|
+
if is_prior_the_baseline:
|
|
246
|
+
effective_policy = _with_prior_as_baseline(policy, relative_path, prior_text)
|
|
247
|
+
return _introduced(
|
|
248
|
+
file_findings(effective_policy, relative_path, post_text, None),
|
|
249
|
+
file_findings(effective_policy, relative_path, prior_text, None),
|
|
250
|
+
len(post_text.splitlines()) > len(prior_text.splitlines()),
|
|
251
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Measure the text a context-adding hook injects and hold it to its budget.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
command ["env", "A=1", "python3", "hooks/x.py"]
|
|
6
|
+
runs <current python> hooks/x.py with A=1, PATH, and an empty home
|
|
7
|
+
stdout {"hookSpecificOutput": {"additionalContext": "..."}} -> its length
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from context_budget.model import BudgetPolicy, HookBudget
|
|
21
|
+
from hooks_constants.context_budget_constants import (
|
|
22
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
23
|
+
ALL_HOME_ENVIRONMENT_NAMES,
|
|
24
|
+
ALL_INHERITED_ENVIRONMENT_NAMES,
|
|
25
|
+
ALL_PYTHON_COMMAND_NAMES,
|
|
26
|
+
ENV_ASSIGNMENT_SEPARATOR,
|
|
27
|
+
ENV_COMMAND_NAME,
|
|
28
|
+
HOOK_FINDING_TEMPLATE,
|
|
29
|
+
HOOK_GROWN_TEMPLATE,
|
|
30
|
+
HOOK_MEASUREMENT_FAILED_TEMPLATE,
|
|
31
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
32
|
+
HOOK_TIMEOUT_SECONDS,
|
|
33
|
+
SYSTEM_MESSAGE_KEY,
|
|
34
|
+
UTF8_ENCODING,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HookMeasurementError(RuntimeError):
|
|
39
|
+
"""Raised when a hook command cannot provide a successful measurement."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _text_length(raw_value: object) -> int:
|
|
43
|
+
return len(raw_value) if isinstance(raw_value, str) else 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def injected_character_count(hook_stdout: str) -> int:
|
|
47
|
+
"""Count the characters a hook injects into the session.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
hook_stdout: The hook's standard output.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The length of ``additionalContext`` plus ``systemMessage`` when the
|
|
54
|
+
output is a JSON object, else the length of the output.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
parsed_output = json.loads(hook_stdout)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
return len(hook_stdout)
|
|
60
|
+
if not isinstance(parsed_output, dict):
|
|
61
|
+
return len(hook_stdout)
|
|
62
|
+
hook_specific_output = parsed_output.get(HOOK_SPECIFIC_OUTPUT_KEY)
|
|
63
|
+
context_length = 0
|
|
64
|
+
if isinstance(hook_specific_output, dict):
|
|
65
|
+
context_length = _text_length(hook_specific_output.get(ADDITIONAL_CONTEXT_KEY))
|
|
66
|
+
return context_length + _text_length(parsed_output.get(SYSTEM_MESSAGE_KEY))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def hook_findings(policy: BudgetPolicy, hook: HookBudget, character_count: int) -> tuple[str, ...]:
|
|
70
|
+
"""Check one hook's injected character count against its limit or baseline.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
policy: Budget policy holding the hook baseline.
|
|
74
|
+
hook: The measured hook.
|
|
75
|
+
character_count: Characters the hook injected.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A one-message tuple when the hook is over budget, else empty.
|
|
79
|
+
"""
|
|
80
|
+
recorded_count = policy.baseline_characters_by_hook_name.get(hook.name)
|
|
81
|
+
if recorded_count is not None:
|
|
82
|
+
if character_count <= recorded_count:
|
|
83
|
+
return ()
|
|
84
|
+
return (
|
|
85
|
+
HOOK_GROWN_TEMPLATE.format(
|
|
86
|
+
name=hook.name, count=character_count, recorded=recorded_count
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
if character_count <= hook.char_limit:
|
|
90
|
+
return ()
|
|
91
|
+
return (
|
|
92
|
+
HOOK_FINDING_TEMPLATE.format(name=hook.name, count=character_count, limit=hook.char_limit),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def split_command_environment(
|
|
97
|
+
all_command_parts: Sequence[str],
|
|
98
|
+
) -> tuple[dict[str, str], tuple[str, ...]]:
|
|
99
|
+
"""Lift a leading ``env NAME=value ...`` prefix out of a hook command.
|
|
100
|
+
|
|
101
|
+
::
|
|
102
|
+
|
|
103
|
+
["env", "A=1", "python3", "x.py"] -> ({"A": "1"}, ("python3", "x.py"))
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
all_command_parts: The policy's command vector.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The environment pairs and the remaining argument vector.
|
|
110
|
+
"""
|
|
111
|
+
if not all_command_parts or all_command_parts[0] != ENV_COMMAND_NAME:
|
|
112
|
+
return {}, tuple(all_command_parts)
|
|
113
|
+
value_by_name: dict[str, str] = {}
|
|
114
|
+
remaining_index = 1
|
|
115
|
+
for each_part in all_command_parts[1:]:
|
|
116
|
+
name, separator, value = each_part.partition(ENV_ASSIGNMENT_SEPARATOR)
|
|
117
|
+
if not separator or not name:
|
|
118
|
+
break
|
|
119
|
+
value_by_name[name] = value
|
|
120
|
+
remaining_index += 1
|
|
121
|
+
return value_by_name, tuple(all_command_parts[remaining_index:])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _hook_environment(home_directory: str, value_by_name: dict[str, str]) -> dict[str, str]:
|
|
125
|
+
environment = {
|
|
126
|
+
each_name: os.environ[each_name]
|
|
127
|
+
for each_name in ALL_INHERITED_ENVIRONMENT_NAMES
|
|
128
|
+
if each_name in os.environ
|
|
129
|
+
}
|
|
130
|
+
environment.update(dict.fromkeys(ALL_HOME_ENVIRONMENT_NAMES, home_directory))
|
|
131
|
+
environment.update(value_by_name)
|
|
132
|
+
return environment
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _run_hook_in_empty_home(
|
|
136
|
+
hook: HookBudget,
|
|
137
|
+
repository_root: Path,
|
|
138
|
+
) -> subprocess.CompletedProcess[str]:
|
|
139
|
+
value_by_name, all_arguments = split_command_environment(hook.all_command_parts)
|
|
140
|
+
if all_arguments and all_arguments[0] in ALL_PYTHON_COMMAND_NAMES:
|
|
141
|
+
all_arguments = (sys.executable, *all_arguments[1:])
|
|
142
|
+
with tempfile.TemporaryDirectory() as home_directory:
|
|
143
|
+
return subprocess.run(
|
|
144
|
+
list(all_arguments),
|
|
145
|
+
input=hook.stdin_text,
|
|
146
|
+
capture_output=True,
|
|
147
|
+
text=True,
|
|
148
|
+
encoding=UTF8_ENCODING,
|
|
149
|
+
cwd=repository_root,
|
|
150
|
+
env=_hook_environment(home_directory, value_by_name),
|
|
151
|
+
timeout=HOOK_TIMEOUT_SECONDS,
|
|
152
|
+
check=False,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run_hook_command(hook: HookBudget, repository_root: Path) -> str:
|
|
157
|
+
"""Run a hook with a fresh home; Python commands use this interpreter.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
hook: The hook to run.
|
|
161
|
+
repository_root: Directory the command runs from.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Successful standard output.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
HookMeasurementError: When the hook exits with a nonzero status.
|
|
168
|
+
"""
|
|
169
|
+
completed_process = _run_hook_in_empty_home(hook, repository_root)
|
|
170
|
+
if completed_process.returncode != 0:
|
|
171
|
+
raise HookMeasurementError(
|
|
172
|
+
HOOK_MEASUREMENT_FAILED_TEMPLATE.format(
|
|
173
|
+
name=hook.name,
|
|
174
|
+
status=completed_process.returncode,
|
|
175
|
+
stderr=completed_process.stderr.strip(),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return completed_process.stdout
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Frozen data shapes of the context budget check.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
BudgetPolicy
|
|
6
|
+
kinds ContextKind("skill entry", ("**/SKILL.md",), 200, True)
|
|
7
|
+
hooks HookBudget("startup print", ("python3", "x.py"), "{}", 1500)
|
|
8
|
+
baseline {"a/SKILL.md": BaselineEntry(743, ("Plan",)),
|
|
9
|
+
"b/SKILL.md": BaselineEntry(None, ("Steps",))}, {"startup print": 5200}
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ContextBudgetPolicyError(ValueError):
|
|
19
|
+
"""Raised when a policy file does not match the documented shape."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ContextKind:
|
|
24
|
+
"""One row of the kind table.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
name: Kind name used in messages.
|
|
28
|
+
all_patterns: Globs over repository-relative POSIX paths.
|
|
29
|
+
line_limit: Physical line limit, or None for a skipped kind.
|
|
30
|
+
is_section_rule_on: Whether the section rule applies.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
all_patterns: tuple[str, ...]
|
|
35
|
+
line_limit: int | None
|
|
36
|
+
is_section_rule_on: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class HookBudget:
|
|
41
|
+
"""One context-adding hook and its character limit.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
name: Hook name, the key of its baseline number.
|
|
45
|
+
all_command_parts: Argument vector run from the repository root.
|
|
46
|
+
stdin_text: JSON text written to the hook's standard input.
|
|
47
|
+
char_limit: Injected character limit.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
all_command_parts: tuple[str, ...]
|
|
52
|
+
stdin_text: str
|
|
53
|
+
char_limit: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class BaselineEntry:
|
|
58
|
+
"""The recorded over-budget state of one file.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
lines: Recorded physical line count when the file is over its kind's
|
|
62
|
+
line limit, or None when the file is listed only for its sections.
|
|
63
|
+
all_section_headings: Over-limit unpointed section headings, a multiset.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
lines: int | None
|
|
67
|
+
all_section_headings: tuple[str, ...]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class BudgetPolicy:
|
|
72
|
+
"""A parsed ``.claude/context-budget.json``.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
section_detail_line_limit: Detail lines a section may hold without a pointer.
|
|
76
|
+
all_kinds: Ordered kind table; the first matching kind wins.
|
|
77
|
+
all_hooks: Context-adding hooks to measure.
|
|
78
|
+
baseline_entry_by_path: Over-budget files by repository-relative path.
|
|
79
|
+
baseline_characters_by_hook_name: Over-budget hooks by name.
|
|
80
|
+
is_baseline_the_prior_text: Whether the baseline entry was measured from
|
|
81
|
+
the file's prior text because the repository has no policy file.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
section_detail_line_limit: int
|
|
85
|
+
all_kinds: tuple[ContextKind, ...]
|
|
86
|
+
all_hooks: tuple[HookBudget, ...] = ()
|
|
87
|
+
baseline_entry_by_path: Mapping[str, BaselineEntry] = field(default_factory=dict)
|
|
88
|
+
baseline_characters_by_hook_name: Mapping[str, int] = field(default_factory=dict)
|
|
89
|
+
is_baseline_the_prior_text: bool = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class SectionScan:
|
|
94
|
+
"""One Markdown section's measurements.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
heading: Heading text, or ``(top)`` for lines before the first heading.
|
|
98
|
+
detail_line_count: Detail lines in the section body.
|
|
99
|
+
has_pointer: Whether the body links to a relative target.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
heading: str
|
|
103
|
+
detail_line_count: int
|
|
104
|
+
has_pointer: bool
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class FileMeasure:
|
|
109
|
+
"""One context file measured against its kind.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
kind: The kind the path matched.
|
|
113
|
+
line_count: Physical lines, frontmatter included.
|
|
114
|
+
all_over_limit_sections: Unpointed sections over the detail limit.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
kind: ContextKind
|
|
118
|
+
line_count: int
|
|
119
|
+
all_over_limit_sections: tuple[SectionScan, ...]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class BudgetFinding:
|
|
124
|
+
"""One budget violation.
|
|
125
|
+
|
|
126
|
+
Attributes:
|
|
127
|
+
path: Repository-relative path the finding names.
|
|
128
|
+
scope: A section heading, or ``file`` for a whole-file finding.
|
|
129
|
+
message: The full message with its fix.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
path: str
|
|
133
|
+
scope: str
|
|
134
|
+
message: str
|