claude-dev-env 1.78.0 → 1.80.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 (63) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
  5. package/_shared/pr-loop/scripts/tests/CLAUDE.md +7 -0
  6. package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
  7. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
  8. package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
  9. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
  10. package/bin/install.mjs +1 -0
  11. package/bin/install.test.mjs +3 -2
  12. package/hooks/blocking/CLAUDE.md +3 -2
  13. package/hooks/blocking/code_rules_docstrings.py +609 -16
  14. package/hooks/blocking/code_rules_enforcer.py +41 -0
  15. package/hooks/blocking/code_rules_imports_logging.py +136 -1
  16. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  17. package/hooks/blocking/code_rules_paired_test.py +240 -22
  18. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  19. package/hooks/blocking/code_rules_unused_imports.py +7 -63
  20. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  21. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  22. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  23. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  24. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  25. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  26. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
  27. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  28. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
  29. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  30. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +96 -15
  31. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  32. package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
  33. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  34. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  36. package/hooks/hooks_constants/CLAUDE.md +1 -0
  37. package/hooks/hooks_constants/blocking_check_limits.py +79 -0
  38. package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
  39. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  40. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  41. package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
  42. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  43. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
  44. package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
  45. package/package.json +1 -1
  46. package/rules/CLAUDE.md +1 -0
  47. package/rules/docstring-prose-matches-implementation.md +57 -54
  48. package/rules/env-var-table-code-drift.md +24 -0
  49. package/rules/package-inventory-stale-entry.md +4 -4
  50. package/rules/paired-test-coverage.md +12 -5
  51. package/skills/autoconverge/SKILL.md +26 -5
  52. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  53. package/skills/autoconverge/workflow/converge.contract.test.mjs +56 -120
  54. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +45 -5
  55. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
  56. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
  57. package/skills/autoconverge/workflow/converge.mjs +110 -228
  58. package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
  59. package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
  60. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
  61. package/skills/pr-converge/SKILL.md +28 -2
  62. package/skills/pr-converge/reference/convergence-gates.md +13 -1
  63. package/skills/pr-converge/reference/state-schema.md +11 -0
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: blocks a markdown env-var table that names a non-reading code file.
3
+
4
+ A documentation file often carries a "Summary: Environment Variables" table whose
5
+ rows pair an environment variable with the code file that reads it, written as
6
+ ``| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | ... |``. When that
7
+ code file exists under the repository yet its source never references the
8
+ variable name, the row is stale: the table claims a consumer relationship the
9
+ code does not have, so a reader trusts the doc to a behavior the code lost. This
10
+ hook fires on Write, Edit, and MultiEdit targeting a ``.md`` file and blocks the
11
+ write when a row attributes a variable to a code file that exists yet does not
12
+ read it. A row whose code file cannot be resolved under the scan root is left
13
+ alone (the hook cannot prove the drift), and for an Edit the drift the file
14
+ already held on an untouched row is excluded so only drift the edit introduces is
15
+ reported.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import TextIO
23
+
24
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
25
+ if _hooks_dir not in sys.path:
26
+ sys.path.insert(0, _hooks_dir)
27
+
28
+ from hooks_constants.env_var_table_code_drift_constants import ( # noqa: E402
29
+ ALL_CODE_FILE_EXTENSIONS,
30
+ ALL_NOISE_DIRECTORY_NAMES,
31
+ BACKTICK_TOKEN_PATTERN,
32
+ CODE_FENCE_PATTERN,
33
+ DRIFT_ADDITIONAL_CONTEXT,
34
+ DRIFT_MESSAGE_TEMPLATE,
35
+ DRIFT_SYSTEM_MESSAGE,
36
+ ENV_VAR_NAME_PATTERN,
37
+ GIT_DIRECTORY_NAME,
38
+ MARKDOWN_FILE_EXTENSION,
39
+ MAX_DRIFT_ISSUES,
40
+ MAX_SUBTREE_FILES_SCANNED,
41
+ MINIMUM_ENV_VAR_ROW_CELL_COUNT,
42
+ SEPARATOR_CELL_PATTERN,
43
+ TABLE_ROW_PATTERN,
44
+ )
45
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
46
+ from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
47
+ apply_edits,
48
+ edits_for_tool,
49
+ )
50
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
51
+ read_hook_input_dictionary_from_stdin,
52
+ )
53
+
54
+
55
+ def is_markdown_file(file_path: str) -> bool:
56
+ """Return whether file_path names a markdown file.
57
+
58
+ Args:
59
+ file_path: The destination path of the write or edit.
60
+
61
+ Returns:
62
+ True when the path extension is ``.md`` (case-insensitive).
63
+ """
64
+ _, extension = os.path.splitext(file_path)
65
+ return extension.lower() == MARKDOWN_FILE_EXTENSION
66
+
67
+
68
+ def _row_cells(table_line: str) -> list[str]:
69
+ """Return the trimmed cells of one markdown table row.
70
+
71
+ Args:
72
+ table_line: A single line that begins with a pipe character.
73
+
74
+ Returns:
75
+ The text of each pipe-delimited cell, stripped, with the empty leading
76
+ and trailing segments a bounding pipe produces removed.
77
+ """
78
+ stripped_line = table_line.strip()
79
+ inner = stripped_line.strip("|")
80
+ return [each_cell.strip() for each_cell in inner.split("|")]
81
+
82
+
83
+ def _first_backtick_token(cell_text: str) -> str | None:
84
+ """Return the first backtick-wrapped token in a cell, when it has one.
85
+
86
+ Args:
87
+ cell_text: The trimmed text of a table cell.
88
+
89
+ Returns:
90
+ The text inside the first pair of backticks, or None when the cell
91
+ carries no backtick-wrapped token.
92
+ """
93
+ token_match = BACKTICK_TOKEN_PATTERN.search(cell_text)
94
+ if token_match is None:
95
+ return None
96
+ inner_text = token_match.group(1).strip()
97
+ return inner_text or None
98
+
99
+
100
+ def _env_var_name_in_cell(cell_text: str) -> str | None:
101
+ """Return the environment-variable name a cell names, when it names one.
102
+
103
+ Args:
104
+ cell_text: The trimmed text of a table cell.
105
+
106
+ Returns:
107
+ The UPPER_SNAKE variable name inside the first backticks, or None when
108
+ the cell names no variable-shaped token.
109
+ """
110
+ token = _first_backtick_token(cell_text)
111
+ if token is None:
112
+ return None
113
+ if ENV_VAR_NAME_PATTERN.match(token) is None:
114
+ return None
115
+ return token
116
+
117
+
118
+ def _code_file_reference_in_cell(cell_text: str) -> str | None:
119
+ """Return the code-file path a cell names, when it names one.
120
+
121
+ Args:
122
+ cell_text: The trimmed text of a table cell.
123
+
124
+ Returns:
125
+ The relative code-file path inside the first backticks, or None when the
126
+ token carries no recognized code-file extension.
127
+ """
128
+ token = _first_backtick_token(cell_text)
129
+ if token is None:
130
+ return None
131
+ _, extension = os.path.splitext(token)
132
+ if extension.lower() not in ALL_CODE_FILE_EXTENSIONS:
133
+ return None
134
+ return token
135
+
136
+
137
+ def _is_separator_row(all_cells: list[str]) -> bool:
138
+ """Return whether every cell is a markdown table header-separator cell.
139
+
140
+ Args:
141
+ all_cells: The trimmed cells of one table row.
142
+
143
+ Returns:
144
+ True when each cell holds only dashes, colons, and whitespace.
145
+ """
146
+ return all(SEPARATOR_CELL_PATTERN.match(each_cell) is not None for each_cell in all_cells)
147
+
148
+
149
+ def _resolve_scan_root(markdown_directory: Path) -> Path:
150
+ """Return the repository directory whose subtree bounds the code-file search.
151
+
152
+ Walks up from the markdown directory to the nearest ancestor holding a
153
+ ``.git`` entry, so a relative code path resolves against the repository root.
154
+ When no ancestor holds ``.git``, the markdown directory parent (or the
155
+ directory itself when it has no distinct parent) bounds the search.
156
+
157
+ Args:
158
+ markdown_directory: The directory that holds the target markdown file.
159
+
160
+ Returns:
161
+ The directory to walk when resolving a referenced code file.
162
+ """
163
+ for each_directory in (markdown_directory, *markdown_directory.parents):
164
+ if (each_directory / GIT_DIRECTORY_NAME).exists():
165
+ return each_directory
166
+ parent_directory = markdown_directory.parent
167
+ if parent_directory == markdown_directory:
168
+ return markdown_directory
169
+ return parent_directory
170
+
171
+
172
+ def _is_under_noise_directory(scan_root: Path, candidate_path: Path) -> bool:
173
+ """Return whether candidate_path lies inside a pruned noise directory.
174
+
175
+ Args:
176
+ scan_root: The directory the walk descends from.
177
+ candidate_path: A path the walk yielded under the scan root.
178
+
179
+ Returns:
180
+ True when any path segment below scan_root names a noise directory.
181
+ """
182
+ try:
183
+ relative_segments = candidate_path.relative_to(scan_root).parts
184
+ except ValueError:
185
+ relative_segments = candidate_path.parts
186
+ return any(each_segment in ALL_NOISE_DIRECTORY_NAMES for each_segment in relative_segments)
187
+
188
+
189
+ def _resolve_code_file(scan_root: Path, code_reference: str) -> Path | None:
190
+ """Return the on-disk code file a row reference names, when one resolves.
191
+
192
+ Resolves the relative reference against the scan root first; when that exact
193
+ path is absent, searches the subtree for a file whose path ends with the
194
+ referenced segments, so ``auth/google_auth.py`` resolves under a nested
195
+ package. A match inside a noise directory is pruned.
196
+
197
+ Args:
198
+ scan_root: The directory whose subtree bounds the search.
199
+ code_reference: The relative code-file path a table row names.
200
+
201
+ Returns:
202
+ The resolved code file, or None when no file matches under the scan root.
203
+ """
204
+ normalized_reference = code_reference.replace("\\", "/").lstrip("/")
205
+ direct_path = scan_root / normalized_reference
206
+ if direct_path.is_file():
207
+ return direct_path
208
+ reference_basename = os.path.basename(normalized_reference)
209
+ if not reference_basename:
210
+ return None
211
+ suffix_marker = "/" + normalized_reference
212
+ scanned_count = 0
213
+ for each_match in scan_root.rglob(reference_basename):
214
+ if _is_under_noise_directory(scan_root, each_match):
215
+ continue
216
+ scanned_count += 1
217
+ if scanned_count > MAX_SUBTREE_FILES_SCANNED:
218
+ return None
219
+ try:
220
+ if not each_match.is_file():
221
+ continue
222
+ except OSError:
223
+ continue
224
+ match_text = "/" + str(each_match).replace("\\", "/")
225
+ if match_text.endswith(suffix_marker):
226
+ return each_match
227
+ return None
228
+
229
+
230
+ def _code_file_reads_variable(code_file: Path, variable_name: str) -> bool | None:
231
+ """Return whether a code file source references variable_name.
232
+
233
+ Args:
234
+ code_file: The resolved on-disk code file.
235
+ variable_name: The environment-variable name to look for.
236
+
237
+ Returns:
238
+ True when the file text contains the variable name, False when it does
239
+ not, and None when the file cannot be read.
240
+ """
241
+ try:
242
+ source_text = code_file.read_text(encoding="utf-8")
243
+ except (OSError, UnicodeDecodeError):
244
+ return None
245
+ return variable_name in source_text
246
+
247
+
248
+ def find_drift_rows(content: str, markdown_directory: Path) -> list[str]:
249
+ """Return each env-var table row whose code file does not read the variable.
250
+
251
+ Walks the markdown content line by line, skipping lines inside a fenced code
252
+ block. A table row counts when its first cell names an UPPER_SNAKE variable and
253
+ a later cell names a code file with a recognized extension. The row drifts when
254
+ that code file resolves under the scan root yet its source never references the
255
+ variable name; a row whose code file does not resolve, or cannot be read, is
256
+ left alone. Each finding is rendered as ``VARIABLE -> code/path``.
257
+
258
+ Args:
259
+ content: The markdown content being written.
260
+ markdown_directory: The directory that holds the target markdown file.
261
+
262
+ Returns:
263
+ Each drifted row in first-seen order with duplicates removed, capped at
264
+ the issue budget.
265
+ """
266
+ scan_root = _resolve_scan_root(markdown_directory)
267
+ drift_rows: list[str] = []
268
+ already_reported: set[str] = set()
269
+ is_inside_code_fence = False
270
+ for each_line in content.splitlines():
271
+ if CODE_FENCE_PATTERN.match(each_line) is not None:
272
+ is_inside_code_fence = not is_inside_code_fence
273
+ continue
274
+ if is_inside_code_fence:
275
+ continue
276
+ if TABLE_ROW_PATTERN.match(each_line) is None:
277
+ continue
278
+ each_finding = _drift_finding_for_row(each_line, scan_root)
279
+ if each_finding is None or each_finding in already_reported:
280
+ continue
281
+ already_reported.add(each_finding)
282
+ drift_rows.append(each_finding)
283
+ if len(drift_rows) >= MAX_DRIFT_ISSUES:
284
+ break
285
+ return drift_rows
286
+
287
+
288
+ def _drift_finding_for_row(table_line: str, scan_root: Path) -> str | None:
289
+ """Return the drift finding one table row produces, when it drifts.
290
+
291
+ Args:
292
+ table_line: A single markdown table row.
293
+ scan_root: The directory whose subtree bounds the code-file search.
294
+
295
+ Returns:
296
+ A ``VARIABLE -> code/path`` finding when the code file resolves yet does
297
+ not read the variable, or None otherwise.
298
+ """
299
+ all_cells = _row_cells(table_line)
300
+ if len(all_cells) < MINIMUM_ENV_VAR_ROW_CELL_COUNT or _is_separator_row(all_cells):
301
+ return None
302
+ variable_name = _env_var_name_in_cell(all_cells[0])
303
+ if variable_name is None:
304
+ return None
305
+ for each_cell in all_cells[1:]:
306
+ code_reference = _code_file_reference_in_cell(each_cell)
307
+ if code_reference is None:
308
+ continue
309
+ code_file = _resolve_code_file(scan_root, code_reference)
310
+ if code_file is None:
311
+ return None
312
+ reads_variable = _code_file_reads_variable(code_file, variable_name)
313
+ if reads_variable is None or reads_variable:
314
+ return None
315
+ return variable_name + " -> " + code_reference
316
+ return None
317
+
318
+
319
+ def _read_existing_file_content(file_path: str) -> str | None:
320
+ """Return the current on-disk content of file_path, or None when unreadable.
321
+
322
+ Args:
323
+ file_path: The path of the file the edit targets.
324
+
325
+ Returns:
326
+ The file text, or None when the file is missing or cannot be decoded.
327
+ """
328
+ try:
329
+ return Path(file_path).read_text(encoding="utf-8")
330
+ except (OSError, UnicodeDecodeError):
331
+ return None
332
+
333
+
334
+ def _joined_edit_fragments(all_edits: list[dict]) -> str:
335
+ """Return the MultiEdit replacement fragments joined into one scannable block.
336
+
337
+ Each ``new_string`` value present as a non-empty string is kept, in list
338
+ order, and the kept fragments are joined with newlines so the resulting text
339
+ carries every table row the edit introduces. Used as the scan fallback when
340
+ the existing file cannot be read.
341
+
342
+ Args:
343
+ all_edits: The MultiEdit ``edits`` list.
344
+
345
+ Returns:
346
+ The joined replacement text, empty when no fragment is a non-empty string.
347
+ """
348
+ kept_fragments = [
349
+ each_edit.get("new_string", "")
350
+ for each_edit in all_edits
351
+ if isinstance(each_edit, dict)
352
+ and isinstance(each_edit.get("new_string", ""), str)
353
+ and each_edit.get("new_string", "")
354
+ ]
355
+ return "\n".join(kept_fragments)
356
+
357
+
358
+ def _candidate_content_and_baseline(
359
+ tool_name: str, tool_input: dict, file_path: str, markdown_directory: Path
360
+ ) -> tuple[str | None, set[str]]:
361
+ """Return the content to scan and the drift the file already held.
362
+
363
+ For Write the candidate is the full new content with no baseline, so every
364
+ drift it names is introduced. For Edit and MultiEdit the candidate is the
365
+ existing file with the replacements applied, and the baseline is the drift the
366
+ existing file already held, so a pre-existing drift on an untouched row is
367
+ excluded. When the existing file cannot be read, the joined new_string
368
+ fragments are scanned with no baseline.
369
+
370
+ Args:
371
+ tool_name: The intercepted tool, one of Write, Edit, MultiEdit.
372
+ tool_input: The tool input payload.
373
+ file_path: The destination path of the write or edit.
374
+ markdown_directory: The directory that holds the target markdown file.
375
+
376
+ Returns:
377
+ The candidate content to scan paired with the baseline drift set.
378
+ """
379
+ if tool_name == "Write":
380
+ content = tool_input.get("content", "")
381
+ return (content if isinstance(content, str) and content else None, set())
382
+ all_edits = edits_for_tool(tool_name, tool_input)
383
+ existing_content = _read_existing_file_content(file_path)
384
+ if existing_content is None:
385
+ joined_fragments = _joined_edit_fragments(all_edits)
386
+ return (joined_fragments or None, set())
387
+ baseline_drift = set(find_drift_rows(existing_content, markdown_directory))
388
+ return (apply_edits(existing_content, all_edits), baseline_drift)
389
+
390
+
391
+ def _build_block_payload(all_drift_rows: list[str], file_path: str) -> dict:
392
+ """Build the PreToolUse deny payload listing each drifted row.
393
+
394
+ Args:
395
+ all_drift_rows: The VARIABLE -> code/path findings to report.
396
+ file_path: The destination path of the markdown file.
397
+
398
+ Returns:
399
+ The hook-result dictionary the harness reads to deny the write.
400
+ """
401
+ formatted_drift = ", ".join("`" + each_row + "`" for each_row in all_drift_rows)
402
+ reason = DRIFT_MESSAGE_TEMPLATE.format(file=file_path, drift=formatted_drift)
403
+ return {
404
+ "hookSpecificOutput": {
405
+ "hookEventName": "PreToolUse",
406
+ "permissionDecision": "deny",
407
+ "permissionDecisionReason": reason,
408
+ "additionalContext": DRIFT_ADDITIONAL_CONTEXT,
409
+ },
410
+ "systemMessage": DRIFT_SYSTEM_MESSAGE,
411
+ "suppressOutput": True,
412
+ }
413
+
414
+
415
+ def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
416
+ """Write the hook result JSON to the given output stream.
417
+
418
+ Args:
419
+ all_hook_data: The hook-result dictionary to serialize.
420
+ output_stream: The stream the harness reads the decision from.
421
+ """
422
+ output_stream.write(json.dumps(all_hook_data) + "\n")
423
+ output_stream.flush()
424
+
425
+
426
+ def main() -> None:
427
+ """Read the PreToolUse payload from stdin and block a drifted env-var table."""
428
+ input_data = read_hook_input_dictionary_from_stdin()
429
+ if input_data is None:
430
+ sys.exit(0)
431
+
432
+ tool_name = input_data.get("tool_name", "")
433
+ if not isinstance(tool_name, str) or tool_name not in ("Write", "Edit", "MultiEdit"):
434
+ sys.exit(0)
435
+
436
+ tool_input = input_data.get("tool_input", {})
437
+ if not isinstance(tool_input, dict):
438
+ sys.exit(0)
439
+
440
+ file_path = tool_input.get("file_path", "")
441
+ if not isinstance(file_path, str) or not is_markdown_file(file_path):
442
+ sys.exit(0)
443
+
444
+ markdown_directory = Path(file_path).resolve().parent
445
+ if not markdown_directory.is_dir():
446
+ sys.exit(0)
447
+
448
+ candidate_content, baseline_drift = _candidate_content_and_baseline(
449
+ tool_name, tool_input, file_path, markdown_directory
450
+ )
451
+ if candidate_content is None:
452
+ sys.exit(0)
453
+
454
+ drift_rows = [
455
+ each_row
456
+ for each_row in find_drift_rows(candidate_content, markdown_directory)
457
+ if each_row not in baseline_drift
458
+ ]
459
+ if not drift_rows:
460
+ sys.exit(0)
461
+
462
+ block_payload = _build_block_payload(drift_rows, file_path)
463
+ log_hook_block(
464
+ calling_hook_name="env_var_table_code_drift_blocker.py",
465
+ hook_event="PreToolUse",
466
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
467
+ tool_name=tool_name,
468
+ offending_input_preview=file_path,
469
+ )
470
+ _emit_hook_result(block_payload, sys.stdout)
471
+ sys.exit(0)
472
+
473
+
474
+ if __name__ == "__main__":
475
+ main()
@@ -2,7 +2,8 @@
2
2
  """PreToolUse hook: blocks a new production file absent from its package inventory.
3
3
 
4
4
  A package directory documents its own files in a sibling inventory document — a
5
- ``README.md`` Layout table or a ``CLAUDE.md`` "Key files" list whose entries
5
+ ``README.md`` Layout table, a ``CLAUDE.md`` "Key files" list, or a skill
6
+ ``SKILL.md`` Layout table that maps the ``scripts/`` subdirectory — whose entries
6
7
  name each file in backticks. When a Write creates a new production code file in a
7
8
  directory whose inventory already names two or more sibling files but carries no
8
9
  entry naming the new file, the inventory and the directory disagree on the
@@ -24,6 +25,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
24
25
  if _hooks_dir not in sys.path:
25
26
  sys.path.insert(0, _hooks_dir)
26
27
 
28
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
27
29
  from hooks_constants.package_inventory_stale_blocker_constants import ( # noqa: E402
28
30
  ALL_EXEMPT_BASENAMES,
29
31
  ALL_EXEMPT_DIRECTORY_NAMES,
@@ -37,11 +39,12 @@ from hooks_constants.package_inventory_stale_blocker_constants import ( # noqa:
37
39
  MINIMUM_INVENTORY_ENTRY_COUNT,
38
40
  NON_FILENAME_TOKEN_PATTERN,
39
41
  PYTHON_FILE_EXTENSION,
42
+ SCRIPTS_SUBDIRECTORY_NAME,
43
+ SKILL_INVENTORY_DOCUMENT_NAME,
40
44
  STALE_INVENTORY_ADDITIONAL_CONTEXT,
41
45
  STALE_INVENTORY_MESSAGE_TEMPLATE,
42
46
  STALE_INVENTORY_SYSTEM_MESSAGE,
43
47
  )
44
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
45
48
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
46
49
  read_hook_input_dictionary_from_stdin,
47
50
  )
@@ -159,7 +162,7 @@ class _InventorySurvey:
159
162
 
160
163
  Attributes:
161
164
  present_inventory_names: The inventory document basenames present in the
162
- directory (``README.md`` and/or ``CLAUDE.md``).
165
+ directory (``README.md``, ``CLAUDE.md``, and/or ``SKILL.md``).
163
166
  named_basenames: Every bare filename the present inventories name.
164
167
  """
165
168
 
@@ -279,18 +282,48 @@ def _sibling_named_basenames(
279
282
  return sibling_basenames
280
283
 
281
284
 
285
+ def _parent_skill_inventory(package_directory: Path) -> _InventorySurvey | None:
286
+ """Return the parent skill ``SKILL.md`` survey for a ``scripts/`` directory.
287
+
288
+ A skill package keeps its ``SKILL.md`` at the skill root and maps the
289
+ ``scripts/`` subdirectory in a Layout table whose rows name files by their
290
+ ``scripts/<name>`` path. A production file landing in that ``scripts/``
291
+ directory is governed by the parent ``SKILL.md``, which sits one level up
292
+ rather than beside the file. This reads that parent ``SKILL.md`` and reports
293
+ the basenames it names. Any directory not named ``scripts/`` and any missing
294
+ or unreadable parent ``SKILL.md`` yield None.
295
+
296
+ Args:
297
+ package_directory: The directory that holds the file being written.
298
+
299
+ Returns:
300
+ The parent ``SKILL.md`` survey, or None when there is none to read.
301
+ """
302
+ if package_directory.name != SCRIPTS_SUBDIRECTORY_NAME:
303
+ return None
304
+ skill_inventory_path = package_directory.parent / SKILL_INVENTORY_DOCUMENT_NAME
305
+ inventory_content = _read_inventory_content(skill_inventory_path)
306
+ if inventory_content is None:
307
+ return None
308
+ return _InventorySurvey(
309
+ [SKILL_INVENTORY_DOCUMENT_NAME], inventory_named_basenames(inventory_content)
310
+ )
311
+
312
+
282
313
  def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
283
314
  """Return the maintained inventory survey a new file is absent from, or None.
284
315
 
285
- The file's directory inventories are surveyed, then the named basenames are
286
- filtered to those that exist as files in the directory — the inventory's own
316
+ The file's own directory inventories are surveyed and, when the file sits in
317
+ a ``scripts/`` subdirectory, the parent skill ``SKILL.md`` Layout table is
318
+ surveyed too; the named basenames union across both. They are then filtered
319
+ to those that exist as files in the file's directory — the inventory's own
287
320
  sibling files. The survey reports a stale inventory only when every condition
288
- holds: the directory carries at least one inventory document, those documents
289
- together name at least the minimum entry count of on-disk sibling files
290
- (marking them a maintained inventory rather than incidental prose that
291
- mentions files living elsewhere), and the inventory does not already name this
292
- file's basename. When any condition fails the file is in step with its
293
- inventory (or there is no inventory to be out of step with), so None results.
321
+ holds: at least one inventory document is present, those documents together
322
+ name at least the minimum entry count of on-disk sibling files (marking them
323
+ a maintained inventory rather than incidental prose that mentions files living
324
+ elsewhere), and no inventory already names this file's basename. When any
325
+ condition fails the file is in step with its inventory (or there is no
326
+ inventory to be out of step with), so None results.
294
327
 
295
328
  Args:
296
329
  file_path: The destination path of the write.
@@ -302,14 +335,20 @@ def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
302
335
  if not package_directory.is_dir():
303
336
  return None
304
337
  survey = survey_directory_inventories(package_directory)
305
- if not survey.present_inventory_names:
338
+ present_inventory_names = list(survey.present_inventory_names)
339
+ named_basenames = set(survey.named_basenames)
340
+ parent_skill_survey = _parent_skill_inventory(package_directory)
341
+ if parent_skill_survey is not None:
342
+ present_inventory_names += parent_skill_survey.present_inventory_names
343
+ named_basenames |= parent_skill_survey.named_basenames
344
+ if not present_inventory_names:
306
345
  return None
307
- sibling_basenames = _sibling_named_basenames(package_directory, survey.named_basenames)
346
+ sibling_basenames = _sibling_named_basenames(package_directory, named_basenames)
308
347
  if len(sibling_basenames) < MINIMUM_INVENTORY_ENTRY_COUNT:
309
348
  return None
310
- if os.path.basename(file_path) in survey.named_basenames:
349
+ if os.path.basename(file_path) in named_basenames:
311
350
  return None
312
- return _InventorySurvey(survey.present_inventory_names, sibling_basenames)
351
+ return _InventorySurvey(present_inventory_names, sibling_basenames)
313
352
 
314
353
 
315
354
  def _build_block_payload(file_path: str, survey: _InventorySurvey) -> dict: