claude-dev-env 8.62.1 → 8.63.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.
@@ -0,0 +1,362 @@
1
+ """Walk one checkout and record a row for each file an agent loads.
2
+
3
+ ::
4
+
5
+ audit_checkout(Path("repo"), None, None, ())
6
+ -> [ContextRow(path="CLAUDE.md", kind=instructions, trigger=session-start, ...),
7
+ ContextRow(path=".claude/rules/api.md", kind=rule, trigger=path-match, ...)]
8
+
9
+ Instruction files, rules, skills, saved hook text, linked docs, and module
10
+ docstrings each get rows. Links out of each text file are followed in turn.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ from collections import deque
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from context_audit_constants.config import constants
21
+ from context_audit_constants.config.constants import CapKey, DepthMode, RowKind, Trigger
22
+ from context_sources import (
23
+ ContextRow,
24
+ RowSource,
25
+ find_import_targets,
26
+ find_relative_links,
27
+ list_tracked_files,
28
+ measure_row,
29
+ parse_frontmatter,
30
+ read_text,
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class SkillHome:
36
+ """Where a skill folder sits and when its description loads."""
37
+
38
+ location: str
39
+ trigger: Trigger
40
+ note: str
41
+
42
+
43
+ class ContextInventory:
44
+ """Collect one row per loaded file through ``collect`` and return them."""
45
+
46
+ def __init__(
47
+ self, root: Path, rules_directory: Path | None, skills_directory: Path | None
48
+ ) -> None:
49
+ self.root = root.resolve()
50
+ self.rules_directory = rules_directory
51
+ self.skills_directory = skills_directory
52
+ self.all_rows: list[ContextRow] = []
53
+ self.all_seen_keys: set[tuple[str, RowKind]] = set()
54
+ self.link_queue: deque[tuple[Path, str, int]] = deque()
55
+
56
+ def collect(self, all_hook_text_files: tuple[Path, ...]) -> list[ContextRow]:
57
+ """Return the rows for every loaded file in discovery order.
58
+
59
+ Args:
60
+ all_hook_text_files: Saved text a SessionStart hook prints.
61
+
62
+ Returns:
63
+ The inventory rows.
64
+ """
65
+ all_paths = list_tracked_files(self.root)
66
+ self._add_instructions(all_paths)
67
+ self._add_rules(all_paths)
68
+ self._add_skills(all_paths)
69
+ for each_hook_text_file in all_hook_text_files:
70
+ self._add_hook_text(each_hook_text_file)
71
+ self._follow_links()
72
+ for each_path in all_paths:
73
+ if each_path.suffix == constants.PYTHON_SUFFIX:
74
+ self._add_docstring(each_path, constants.LOADER_DOCSTRING)
75
+ return self.all_rows
76
+
77
+ def _relative(self, path: Path) -> str:
78
+ return path.resolve().relative_to(self.root).as_posix()
79
+
80
+ def _add(
81
+ self,
82
+ path: Path,
83
+ source: RowSource,
84
+ cap_key: CapKey | None,
85
+ text: str,
86
+ link_depth: int = constants.FIRST_LINK_DEPTH,
87
+ ) -> ContextRow | None:
88
+ key = (source.path, source.kind)
89
+ if key in self.all_seen_keys:
90
+ return None
91
+ self.all_seen_keys.add(key)
92
+ is_markdown = path.suffix in constants.ALL_TEXT_SUFFIXES
93
+ all_links = find_relative_links(path, text, self.root) if is_markdown else []
94
+ cap = constants.ALL_LINE_CAP_BY_KEY[cap_key] if cap_key else None
95
+ row = measure_row(source, text, cap, len(all_links))
96
+ self.all_rows.append(row)
97
+ for each_link in all_links:
98
+ self.link_queue.append((each_link, row.path, link_depth))
99
+ return row
100
+
101
+ def _source(
102
+ self, path: Path, kind: RowKind, loader: str, trigger: Trigger, note: str = ""
103
+ ) -> RowSource:
104
+ return RowSource(self._relative(path), kind, loader, trigger, note)
105
+
106
+ def _add_instructions(self, all_paths: list[Path]) -> None:
107
+ for each_path in sorted(all_paths):
108
+ if each_path.name not in constants.ALL_INSTRUCTION_FILE_NAMES:
109
+ continue
110
+ folder = each_path.parent
111
+ is_root = folder == self.root or (
112
+ folder.name == constants.CLAUDE_FOLDER_NAME
113
+ and folder.parent == self.root
114
+ )
115
+ trigger = Trigger.SESSION_START if is_root else Trigger.FOLDER_ENTER
116
+ cap_key = (
117
+ CapKey.ROOT_INSTRUCTIONS if is_root else CapKey.NESTED_INSTRUCTIONS
118
+ )
119
+ is_agents_file = each_path.name == constants.AGENTS_FILE_NAME
120
+ note = constants.NOTE_CODEX_AGENTS if is_agents_file else ""
121
+ loader = self._instruction_loader(each_path)
122
+ source = self._source(
123
+ each_path, RowKind.INSTRUCTIONS, loader, trigger, note
124
+ )
125
+ text = read_text(each_path)
126
+ self._add(each_path, source, cap_key, text)
127
+ self._add_imports(each_path, text, trigger, cap_key)
128
+
129
+ def _instruction_loader(self, path: Path) -> str:
130
+ if path.name != constants.AGENTS_FILE_NAME:
131
+ return constants.LOADER_HARNESS
132
+ sibling_claude = path.parent / constants.CLAUDE_FILE_NAME
133
+ if not sibling_claude.exists():
134
+ return constants.LOADER_AGENTS_NATIVE
135
+ if constants.AGENTS_IMPORT_LINE in read_text(sibling_claude):
136
+ return constants.LOADER_AGENTS_IMPORTED
137
+ return constants.LOADER_AGENTS_SHADOWED
138
+
139
+ def _add_imports(
140
+ self, importer: Path, text: str, trigger: Trigger, cap_key: CapKey
141
+ ) -> None:
142
+ loader = constants.LOADER_IMPORT_TEMPLATE.format(
143
+ source=self._relative(importer)
144
+ )
145
+ for each_import in find_import_targets(importer, text, self.root):
146
+ if each_import.name in constants.ALL_INSTRUCTION_FILE_NAMES:
147
+ continue
148
+ source = self._source(
149
+ each_import, RowKind.IMPORT, loader, trigger, constants.NOTE_IMPORT
150
+ )
151
+ imported_text = read_text(each_import)
152
+ if self._add(each_import, source, cap_key, imported_text) is not None:
153
+ self._add_imports(each_import, imported_text, trigger, cap_key)
154
+
155
+ def _add_rules(self, all_paths: list[Path]) -> None:
156
+ for each_path in all_paths:
157
+ is_installed_rule = (
158
+ self.rules_directory is not None
159
+ and self.rules_directory in each_path.parents
160
+ )
161
+ if each_path.suffix != constants.MARKDOWN_SUFFIX or not (
162
+ is_installed_rule or self._is_project_rule(each_path)
163
+ ):
164
+ continue
165
+ text = read_text(each_path)
166
+ is_path_scoped = constants.PATHS_FIELD in parse_frontmatter(text)
167
+ loader = (
168
+ constants.LOADER_RULE_INSTALLED
169
+ if is_installed_rule
170
+ else constants.LOADER_RULE_PROJECT
171
+ )
172
+ is_global = is_installed_rule and not is_path_scoped
173
+ note = constants.NOTE_INSTALLED_RULE if is_global else ""
174
+ trigger = Trigger.PATH_MATCH if is_path_scoped else Trigger.SESSION_START
175
+ source = self._source(each_path, RowKind.RULE, loader, trigger, note)
176
+ self._add(each_path, source, CapKey.RULE, text)
177
+
178
+ def _is_project_rule(self, path: Path) -> bool:
179
+ all_prefix_parts = constants.ALL_PROJECT_RULES_PATH_PARTS
180
+ return (
181
+ path.relative_to(self.root).parts[: len(all_prefix_parts)]
182
+ == all_prefix_parts
183
+ )
184
+
185
+ def _skill_home(self, skill_path: Path) -> SkillHome | None:
186
+ parts = skill_path.relative_to(self.root).parts
187
+ if len(parts) < constants.MINIMUM_SKILL_PATH_PARTS or any(
188
+ constants.ARCHIVE_FOLDER_MARKER in each_part
189
+ or each_part in constants.ALL_FIXTURE_FOLDER_NAMES
190
+ for each_part in parts
191
+ ):
192
+ return None
193
+ if (
194
+ self.skills_directory is not None
195
+ and self.skills_directory in skill_path.parents
196
+ ):
197
+ return SkillHome(
198
+ constants.LOCATION_INSTALLED_SKILLS, Trigger.SESSION_START, ""
199
+ )
200
+ return _folder_skill_home(parts)
201
+
202
+ def _add_skills(self, all_paths: list[Path]) -> None:
203
+ all_resolved_skill_paths: set[Path] = set()
204
+ for each_path in sorted(all_paths):
205
+ if each_path.name != constants.SKILL_FILE_NAME:
206
+ continue
207
+ resolved_path = each_path.resolve()
208
+ home = self._skill_home(each_path)
209
+ if home is None or resolved_path in all_resolved_skill_paths:
210
+ continue
211
+ all_resolved_skill_paths.add(resolved_path)
212
+ text = read_text(each_path)
213
+ self._add_skill_description(each_path, parse_frontmatter(text), home)
214
+ source = self._source(
215
+ each_path,
216
+ RowKind.SKILL_BODY,
217
+ constants.LOADER_SKILL_INVOCATION,
218
+ Trigger.SKILL_INVOKE,
219
+ )
220
+ self._add(each_path, source, CapKey.SKILL_BODY, text)
221
+ self._add_skill_references(each_path)
222
+
223
+ def _add_skill_description(
224
+ self, skill_path: Path, field_by_name: dict[str, str], home: SkillHome
225
+ ) -> None:
226
+ disable_text = field_by_name.get(constants.DISABLE_MODEL_INVOCATION_FIELD, "")
227
+ if disable_text.lower() == constants.TRUE_TEXT:
228
+ return
229
+ full_size = _description_size(field_by_name)
230
+ listed_size = min(full_size, constants.DESCRIPTION_LISTING_CHARACTER_LIMIT)
231
+ is_truncated = full_size > constants.DESCRIPTION_LISTING_CHARACTER_LIMIT
232
+ truncation_note = constants.NOTE_DESCRIPTION_TRUNCATED if is_truncated else ""
233
+ is_over_cap = full_size > constants.DESCRIPTION_CHARACTER_CAP
234
+ relative_path = self._relative(skill_path)
235
+ self.all_seen_keys.add((relative_path, RowKind.SKILL_DESCRIPTION))
236
+ self.all_rows.append(
237
+ ContextRow(
238
+ path=relative_path,
239
+ kind=RowKind.SKILL_DESCRIPTION,
240
+ loader=constants.LOADER_SKILL_LISTING_TEMPLATE.format(location=home.location),
241
+ trigger=home.trigger,
242
+ bytes=listed_size,
243
+ lines=constants.DESCRIPTION_LINE_COUNT,
244
+ est_tokens=round(listed_size / constants.BYTES_PER_ESTIMATED_TOKEN),
245
+ links_out=0,
246
+ depth_mode=DepthMode.CARRIES if is_over_cap else DepthMode.POINTS,
247
+ cap=constants.DESCRIPTION_CHARACTER_CAP,
248
+ note=constants.NOTE_SEPARATOR.join(
249
+ each_note for each_note in (home.note, truncation_note) if each_note
250
+ ),
251
+ )
252
+ )
253
+
254
+ def _add_skill_references(self, skill_path: Path) -> None:
255
+ loader = constants.LOADER_SKILL_REFERENCE_TEMPLATE.format(
256
+ source=self._relative(skill_path)
257
+ )
258
+ for each_other in sorted(skill_path.parent.rglob("*")):
259
+ if (
260
+ each_other.is_file()
261
+ and each_other.name != constants.SKILL_FILE_NAME
262
+ and each_other.suffix in constants.ALL_TEXT_SUFFIXES
263
+ and not constants.ALL_SKIPPED_PATH_PARTS.intersection(each_other.parts)
264
+ ):
265
+ source = self._source(
266
+ each_other, RowKind.SKILL_REFERENCE, loader, Trigger.ON_LINK
267
+ )
268
+ self._add(each_other, source, None, read_text(each_other))
269
+
270
+ def _add_hook_text(self, hook_text_file: Path) -> None:
271
+ source = RowSource(
272
+ hook_text_file.name,
273
+ RowKind.HOOK_TEXT,
274
+ constants.LOADER_HOOK_TEXT,
275
+ Trigger.SESSION_START,
276
+ constants.NOTE_HOOK_TEXT,
277
+ )
278
+ cap = constants.ALL_LINE_CAP_BY_KEY[CapKey.ROOT_INSTRUCTIONS]
279
+ self.all_rows.append(measure_row(source, read_text(hook_text_file), cap, 0))
280
+
281
+ def _follow_links(self) -> None:
282
+ while self.link_queue:
283
+ each_path, linked_from, depth = self.link_queue.popleft()
284
+ if each_path.suffix == constants.PYTHON_SUFFIX:
285
+ loader = constants.LOADER_LINKED_DOCSTRING_TEMPLATE.format(
286
+ source=linked_from
287
+ )
288
+ self._add_docstring(each_path, loader)
289
+ continue
290
+ if each_path.suffix not in constants.ALL_TEXT_SUFFIXES or self._is_recorded(
291
+ each_path
292
+ ):
293
+ continue
294
+ source = self._source(
295
+ each_path,
296
+ RowKind.LINKED_DOC,
297
+ constants.LOADER_LINK_TEMPLATE.format(source=linked_from),
298
+ Trigger.ON_LINK,
299
+ constants.NOTE_LINK_DEPTH_TEMPLATE.format(depth=depth),
300
+ )
301
+ self._add(each_path, source, None, read_text(each_path), depth + 1)
302
+
303
+ def _is_recorded(self, path: Path) -> bool:
304
+ relative_path = self._relative(path)
305
+ return any(each_key[0] == relative_path for each_key in self.all_seen_keys)
306
+
307
+ def _add_docstring(self, path: Path, loader: str) -> None:
308
+ try:
309
+ tree = ast.parse(read_text(path))
310
+ except (SyntaxError, ValueError):
311
+ return
312
+ docstring = ast.get_docstring(tree, clean=False)
313
+ if docstring:
314
+ source = self._source(path, RowKind.DOCSTRING, loader, Trigger.FILE_OPEN)
315
+ self._add(path, source, CapKey.DOCSTRING, docstring)
316
+
317
+
318
+ def _folder_skill_home(all_parts: tuple[str, ...]) -> SkillHome | None:
319
+ has_skills_folder = constants.SKILLS_FOLDER_NAME in all_parts
320
+ if (
321
+ all_parts[0] in constants.ALL_SKILL_HOME_FOLDER_NAMES
322
+ and all_parts[1] == constants.SKILLS_FOLDER_NAME
323
+ ):
324
+ return SkillHome(constants.LOCATION_PROJECT_SKILLS, Trigger.SESSION_START, "")
325
+ if constants.CLAUDE_FOLDER_NAME in all_parts and has_skills_folder:
326
+ return SkillHome(constants.LOCATION_NESTED_SKILLS, Trigger.FOLDER_ENTER, "")
327
+ if all_parts[0] == constants.PLUGINS_FOLDER_NAME and has_skills_folder:
328
+ return SkillHome(
329
+ constants.LOCATION_PLUGIN_SKILLS,
330
+ Trigger.SESSION_START,
331
+ constants.NOTE_PLUGIN_SKILL,
332
+ )
333
+ return None
334
+
335
+
336
+ def _description_size(field_by_name: dict[str, str]) -> int:
337
+ description = constants.DESCRIPTION_JOIN_TEMPLATE.format(
338
+ description=field_by_name.get(constants.DESCRIPTION_FIELD, ""),
339
+ when_to_use=field_by_name.get(constants.WHEN_TO_USE_FIELD, ""),
340
+ )
341
+ return len(description.strip().encode(constants.TEXT_ENCODING))
342
+
343
+
344
+ def audit_checkout(
345
+ root: Path,
346
+ rules_directory: Path | None,
347
+ skills_directory: Path | None,
348
+ all_hook_text_files: tuple[Path, ...],
349
+ ) -> list[ContextRow]:
350
+ """Return one row for each file an agent loads from the checkout at *root*.
351
+
352
+ Args:
353
+ root: A git checkout.
354
+ rules_directory: A tracked folder installed as the user rules folder.
355
+ skills_directory: A tracked folder installed as the user skills folder.
356
+ all_hook_text_files: Saved text a SessionStart hook prints.
357
+
358
+ Returns:
359
+ The inventory rows in discovery order.
360
+ """
361
+ inventory = ContextInventory(root, rules_directory, skills_directory)
362
+ return inventory.collect(all_hook_text_files)
@@ -0,0 +1,176 @@
1
+ """Format inventory rows as a markdown report with a cleanup list.
2
+
3
+ ::
4
+
5
+ # Context audit: my-repo
6
+ 412 loaded files. Startup loads 96 lines, about 1830 tokens, from 9 files.
7
+ ## Load by trigger
8
+ ## Carries depth over cap
9
+ ## Empty instruction stubs
10
+ ## Long skills to split
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from context_audit_constants.config import constants
16
+ from context_audit_constants.config.constants import DepthMode, RowKind, Trigger
17
+ from context_sources import ContextRow
18
+
19
+
20
+ def format_trigger_table(all_rows: list[ContextRow]) -> list[str]:
21
+ """Return the load-by-trigger table lines.
22
+
23
+ Args:
24
+ all_rows: Inventory rows.
25
+
26
+ Returns:
27
+ Markdown table lines, one row per trigger present.
28
+ """
29
+ all_lines = [
30
+ constants.REPORT_TRIGGER_HEADING,
31
+ "",
32
+ constants.REPORT_TRIGGER_HEADER,
33
+ constants.REPORT_FOUR_COLUMN_RULE,
34
+ ]
35
+ for each_trigger in Trigger:
36
+ all_matching = [
37
+ each_row for each_row in all_rows if each_row.trigger == each_trigger
38
+ ]
39
+ if all_matching:
40
+ all_lines.append(
41
+ constants.REPORT_TRIGGER_ROW_TEMPLATE.format(
42
+ trigger=each_trigger.value,
43
+ files=len(all_matching),
44
+ lines=sum(each_row.lines for each_row in all_matching),
45
+ tokens=sum(each_row.est_tokens for each_row in all_matching),
46
+ )
47
+ )
48
+ return all_lines
49
+
50
+
51
+ def format_carries_section(all_carries: list[ContextRow]) -> list[str]:
52
+ """Return the section that lists files over their cap.
53
+
54
+ Args:
55
+ all_carries: Rows in carries depth mode, skill bodies excluded.
56
+
57
+ Returns:
58
+ Markdown lines for the section.
59
+ """
60
+ all_lines = ["", constants.REPORT_CARRIES_HEADING, ""]
61
+ if not all_carries:
62
+ return [*all_lines, constants.REPORT_NONE_LINE]
63
+ all_lines += [constants.REPORT_CARRIES_HEADER, constants.REPORT_SIX_COLUMN_RULE]
64
+ return all_lines + [
65
+ constants.REPORT_CARRIES_ROW_TEMPLATE.format(
66
+ path=each_row.path,
67
+ kind=each_row.kind.value,
68
+ trigger=each_row.trigger.value,
69
+ lines=each_row.lines,
70
+ cap=each_row.cap,
71
+ tokens=each_row.est_tokens,
72
+ )
73
+ for each_row in all_carries
74
+ ]
75
+
76
+
77
+ def format_stub_section(all_stubs: list[ContextRow]) -> list[str]:
78
+ """Return the section that lists empty instruction files.
79
+
80
+ Args:
81
+ all_stubs: Empty instruction, import, and rule rows.
82
+
83
+ Returns:
84
+ Markdown lines for the section.
85
+ """
86
+ all_lines = ["", constants.REPORT_EMPTY_HEADING, ""]
87
+ all_stub_lines = [
88
+ constants.REPORT_EMPTY_ROW_TEMPLATE.format(
89
+ path=each_row.path, kind=each_row.kind.value
90
+ )
91
+ for each_row in all_stubs
92
+ ]
93
+ return all_lines + (all_stub_lines or [constants.REPORT_NONE_LINE])
94
+
95
+
96
+ def format_split_section(all_long_skills: list[ContextRow]) -> list[str]:
97
+ """Return the section that lists skill bodies over their cap.
98
+
99
+ Args:
100
+ all_long_skills: Skill body rows in carries depth mode.
101
+
102
+ Returns:
103
+ Markdown lines for the section.
104
+ """
105
+ all_lines = ["", constants.REPORT_SPLIT_HEADING, ""]
106
+ if not all_long_skills:
107
+ return [*all_lines, constants.REPORT_NONE_LINE]
108
+ all_lines += [constants.REPORT_SPLIT_HEADER, constants.REPORT_FOUR_COLUMN_RULE]
109
+ return all_lines + [
110
+ constants.REPORT_SPLIT_ROW_TEMPLATE.format(
111
+ path=each_row.path,
112
+ lines=each_row.lines,
113
+ cap=each_row.cap,
114
+ tokens=each_row.est_tokens,
115
+ )
116
+ for each_row in all_long_skills
117
+ ]
118
+
119
+
120
+ def format_cleanup_list(all_rows: list[ContextRow]) -> list[str]:
121
+ """Return the over-cap, empty-stub, and long-skill sections, largest first.
122
+
123
+ Args:
124
+ all_rows: Inventory rows.
125
+
126
+ Returns:
127
+ Markdown lines for the three sections.
128
+ """
129
+ all_over_cap = sorted(
130
+ (each_row for each_row in all_rows if each_row.depth_mode == DepthMode.CARRIES),
131
+ key=lambda each_row: each_row.est_tokens,
132
+ reverse=True,
133
+ )
134
+ all_long_skills = [
135
+ each_row for each_row in all_over_cap if each_row.kind == RowKind.SKILL_BODY
136
+ ]
137
+ all_carries = [
138
+ each_row for each_row in all_over_cap if each_row.kind != RowKind.SKILL_BODY
139
+ ]
140
+ all_stubs = [
141
+ each_row
142
+ for each_row in all_rows
143
+ if each_row.depth_mode == DepthMode.EMPTY and each_row.kind in constants.ALL_STUB_KINDS
144
+ ]
145
+ all_carries_lines = format_carries_section(all_carries)
146
+ return all_carries_lines + format_stub_section(all_stubs) + format_split_section(all_long_skills)
147
+
148
+
149
+ def format_report(checkout_name: str, all_rows: list[ContextRow]) -> str:
150
+ """Return the markdown report: summary, load by trigger, and cleanup list.
151
+
152
+ Args:
153
+ checkout_name: The name shown in the title.
154
+ all_rows: Inventory rows.
155
+
156
+ Returns:
157
+ The report text.
158
+ """
159
+ all_session_start = [
160
+ each_row for each_row in all_rows if each_row.trigger == Trigger.SESSION_START
161
+ ]
162
+ summary = constants.REPORT_SUMMARY_TEMPLATE.format(
163
+ row_count=len(all_rows),
164
+ lines=sum(each_row.lines for each_row in all_session_start),
165
+ tokens=sum(each_row.est_tokens for each_row in all_session_start),
166
+ files=len(all_session_start),
167
+ )
168
+ all_lines = [
169
+ constants.REPORT_TITLE_TEMPLATE.format(name=checkout_name),
170
+ "",
171
+ summary,
172
+ "",
173
+ ]
174
+ all_lines += format_trigger_table(all_rows)
175
+ all_lines += format_cleanup_list(all_rows)
176
+ return constants.LINE_BREAK.join(all_lines) + constants.LINE_BREAK