claude-dev-env 2.28.1 → 2.30.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 (157) hide show
  1. package/.agents/agents/AGENTS.md +0 -31
  2. package/.agents/agents/clean-coder.md +1 -1
  3. package/.agents/agents/pr-description-writer.md +21 -6
  4. package/.agents/agents/reference/README.md +6 -0
  5. package/.agents/agents/reference/pr-description-illustrative-voice.md +45 -0
  6. package/.agents/agents/reference/pr-description-verification.md +19 -0
  7. package/.agents/agents/test_agent_frontmatter.py +3 -1
  8. package/.agents/agents/test_pr_description_writer_style.py +47 -0
  9. package/.agents/skills/AGENTS.md +0 -70
  10. package/.agents/skills/_shared/AGENTS.md +0 -44
  11. package/.agents/skills/_shared/advisor/AGENTS.md +0 -8
  12. package/.agents/skills/_shared/pr-loop/AGENTS.md +0 -57
  13. package/.agents/skills/_shared/pr-loop/prompts/AGENTS.md +0 -8
  14. package/.agents/skills/_shared/pr-loop/scripts/AGENTS.md +0 -34
  15. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/AGENTS.md +0 -23
  16. package/.agents/skills/autoconverge/AGENTS.md +0 -35
  17. package/.agents/skills/autoconverge/reference/AGENTS.md +0 -15
  18. package/.agents/skills/autoconverge/workflow/AGENTS.md +0 -25
  19. package/.agents/skills/autoconverge/workflow/autoconverge_report_constants/AGENTS.md +0 -15
  20. package/.agents/skills/everything-search/AGENTS.md +0 -16
  21. package/.agents/skills/fresh-branch/AGENTS.md +0 -13
  22. package/.agents/skills/grok-spawn/AGENTS.md +0 -27
  23. package/.agents/skills/orchestrator/.claude/CLAUDE.md +1 -0
  24. package/.agents/skills/orchestrator/AGENTS.md +1 -0
  25. package/.agents/skills/orchestrator/SKILL.md +44 -53
  26. package/.agents/skills/orchestrator/reference/.claude/CLAUDE.md +1 -0
  27. package/.agents/skills/orchestrator/reference/AGENTS.md +1 -0
  28. package/.agents/skills/orchestrator/reference/consult-the-orchestrator.md +70 -0
  29. package/.agents/skills/orchestrator/reference/executor-consult-block.md +62 -0
  30. package/.agents/skills/orchestrator/reference/host-detect.md +15 -0
  31. package/.agents/skills/orchestrator/test_orchestrator_skill_contract.py +62 -0
  32. package/.agents/skills/orchestrator-refresh/SKILL.md +16 -34
  33. package/.agents/skills/rebase/AGENTS.md +0 -31
  34. package/.agents/skills/session-log/AGENTS.md +0 -31
  35. package/.agents/skills/session-tidy/AGENTS.md +0 -35
  36. package/.agents/skills/skill-builder/AGENTS.md +0 -48
  37. package/.agents/skills/skill-builder/references/AGENTS.md +0 -24
  38. package/.agents/skills/skill-builder/templates/AGENTS.md +0 -13
  39. package/.agents/skills/skill-builder/workflows/AGENTS.md +0 -18
  40. package/.agents/skills/task-build/AGENTS.md +0 -28
  41. package/.agents/skills/update/AGENTS.md +0 -37
  42. package/.agents/skills-archived/AGENTS.md +0 -44
  43. package/.agents/skills-archived/anthropic-plan/AGENTS.md +0 -33
  44. package/.agents/skills-archived/anthropic-plan/scripts/AGENTS.md +0 -10
  45. package/.agents/skills-archived/anthropic-plan/scripts/anthropic_plan_scripts_constants/AGENTS.md +0 -15
  46. package/.agents/skills-archived/anthropic-plan/templates/AGENTS.md +0 -12
  47. package/.agents/skills-archived/anthropic-plan/workflow/AGENTS.md +0 -13
  48. package/.agents/skills-archived/auditing-claude-config/AGENTS.md +0 -20
  49. package/.agents/skills-archived/bugteam/AGENTS.md +0 -29
  50. package/.agents/skills-archived/bugteam/reference/AGENTS.md +0 -19
  51. package/.agents/skills-archived/bugteam/reference/obstacles/AGENTS.md +0 -23
  52. package/.agents/skills-archived/bugteam/scripts/AGENTS.md +0 -29
  53. package/.agents/skills-archived/bugteam/scripts/bugteam_scripts_constants/AGENTS.md +0 -17
  54. package/.agents/skills-archived/codex-review/AGENTS.md +0 -45
  55. package/.agents/skills-archived/codex-review/reference/AGENTS.md +0 -14
  56. package/.agents/skills-archived/codex-review/scripts/codex_review_scripts_constants/AGENTS.md +0 -17
  57. package/.agents/skills-archived/codex-review/test_skill_scaffold.py +13 -3
  58. package/.agents/skills-archived/copilot-review/AGENTS.md +0 -17
  59. package/.agents/skills-archived/pr-converge/AGENTS.md +0 -31
  60. package/.agents/skills-archived/pr-converge/pr_converge_skill_constants/AGENTS.md +0 -25
  61. package/.agents/skills-archived/pr-converge/reference/AGENTS.md +0 -27
  62. package/.agents/skills-archived/pr-converge/reference/obstacles/AGENTS.md +0 -22
  63. package/.agents/skills-archived/pr-converge/scripts/AGENTS.md +0 -45
  64. package/.agents/skills-archived/pr-converge/scripts/pr_converge_scripts_constants/AGENTS.md +0 -17
  65. package/.agents/skills-archived/pr-converge/workflows/AGENTS.md +0 -15
  66. package/.agents/skills-archived/recall/AGENTS.md +0 -29
  67. package/.agents/skills-archived/remember/AGENTS.md +0 -30
  68. package/AGENTS.md +0 -112
  69. package/_shared/AGENTS.md +0 -16
  70. package/_shared/advisor/AGENTS.md +0 -21
  71. package/_shared/advisor/advisor-protocol.md +4 -1
  72. package/_shared/advisor/reference/consult-format.md +4 -2
  73. package/_shared/pr-loop/AGENTS.md +0 -27
  74. package/_shared/pr-loop/code-rules-gate.md +1 -1
  75. package/_shared/pr-loop/scripts/AGENTS.md +0 -49
  76. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -2
  77. package/_shared/pr-loop/scripts/code_rules_gate_parts/AGENTS.md +0 -41
  78. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +8 -8
  79. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +2 -2
  80. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +1 -1
  81. package/_shared/pr-loop/scripts/codex_review_scripts_constants/AGENTS.md +0 -17
  82. package/_shared/pr-loop/scripts/pr_converge_scripts_constants/AGENTS.md +0 -17
  83. package/_shared/pr-loop/scripts/pr_converge_skill_constants/AGENTS.md +0 -25
  84. package/_shared/pr-loop/scripts/pr_loop_shared_constants/AGENTS.md +0 -25
  85. package/_shared/pr-loop/scripts/tests/AGENTS.md +0 -43
  86. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +15 -15
  87. package/_shared/process-tree/AGENTS.md +0 -40
  88. package/audit-rubrics/AGENTS.md +0 -42
  89. package/audit-rubrics/category_rubrics/AGENTS.md +0 -36
  90. package/audit-rubrics/prompts/AGENTS.md +0 -36
  91. package/bin/AGENTS.md +0 -118
  92. package/bin/install.test.mjs +1 -1
  93. package/commands/AGENTS.md +0 -14
  94. package/docs/AGENTS.md +0 -31
  95. package/docs/CODE_RULES.md +10 -0
  96. package/docs/references/AGENTS.md +0 -16
  97. package/hooks/AGENTS.md +0 -27
  98. package/hooks/advisory/AGENTS.md +0 -15
  99. package/hooks/blocking/AGENTS.md +0 -112
  100. package/hooks/blocking/claude_md_orphan_file_blocker_parts/AGENTS.md +0 -27
  101. package/hooks/blocking/code_rules_enforcer.py +433 -313
  102. package/hooks/blocking/config/AGENTS.md +0 -9
  103. package/hooks/blocking/inventory_intent_records/AGENTS.md +0 -25
  104. package/hooks/blocking/package_inventory_stale_blocker_parts/AGENTS.md +0 -25
  105. package/hooks/blocking/pii_prevention_blocker_parts/AGENTS.md +0 -23
  106. package/hooks/blocking/tdd_enforcer_parts/AGENTS.md +0 -29
  107. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +1 -1
  108. package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +1 -1
  109. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +117 -22
  110. package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +1 -1
  111. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +1 -1
  112. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +1 -1
  113. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +1 -1
  114. package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +1 -1
  115. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +1 -1
  116. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +1 -1
  117. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +2 -2
  118. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +3 -3
  119. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +1 -1
  120. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +1 -1
  121. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +117 -38
  122. package/hooks/blocking/test_code_rules_enforcer_output_snapshot.py +121 -0
  123. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +17 -15
  124. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +4 -2
  125. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
  126. package/hooks/blocking/test_code_rules_enforcer_stage.py +1 -1
  127. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +4 -4
  128. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +2 -2
  129. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +2 -2
  130. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -2
  131. package/hooks/blocking/test_code_rules_validation_phase.py +281 -0
  132. package/hooks/blocking/test_fable_spawn_gate.py +1 -1
  133. package/hooks/blocking/test_pre_tool_use_dispatcher.py +26 -11
  134. package/hooks/git-hooks/AGENTS.md +0 -31
  135. package/hooks/git-hooks/git_hooks_constants/AGENTS.md +0 -20
  136. package/hooks/hooks_constants/AGENTS.md +0 -99
  137. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  138. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +21 -0
  139. package/hooks/hooks_constants/validation_phase_constants.py +34 -0
  140. package/hooks/lifecycle/AGENTS.md +0 -17
  141. package/hooks/observability/AGENTS.md +0 -19
  142. package/hooks/session/AGENTS.md +0 -37
  143. package/hooks/validation/AGENTS.md +0 -19
  144. package/hooks/validators/AGENTS.md +0 -51
  145. package/hooks/validators/README.md +1 -1
  146. package/hooks/validators/config/hook_timing_harness_constants.py +16 -0
  147. package/hooks/validators/hook_timing_harness.py +287 -0
  148. package/hooks/validators/test_hook_timing_harness.py +251 -0
  149. package/hooks/workflow/AGENTS.md +0 -15
  150. package/output-styles/AGENTS.md +0 -14
  151. package/package.json +1 -1
  152. package/rules/AGENTS.md +0 -55
  153. package/scripts/AGENTS.md +0 -55
  154. package/scripts/dev_env_scripts_constants/AGENTS.md +0 -20
  155. package/scripts/sync_to_cursor/AGENTS.md +0 -22
  156. package/scripts/tests/AGENTS.md +0 -34
  157. package/system-prompts/AGENTS.md +0 -24
@@ -1,38 +1 @@
1
- # hooks/session
2
1
 
3
- SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup, starting the session's task-list maintenance loop, injecting working-style guidance, and clearing PR-author swap state at shutdown. Also holds the UserPromptSubmit hook. It adds a style reminder to each message.
4
-
5
- The working-style projection uses `~/.claude/rules/asd-ste100-language.md` for
6
- user-facing word choice, sentence style, tone, punctuation, and prose form.
7
-
8
- ## Key files
9
-
10
- | File | Event | What it does |
11
- |---|---|---|
12
- | `session_env_cleanup.py` | SessionStart | Removes the current session's pre-existing `~/.claude/session-env/<session_id>/` directory and prunes sibling entries older than the stale-age threshold. Prevents `EEXIST` errors from non-recursive `mkdir` calls in the Bash tool on Windows. |
13
- | `gh_pr_author_session_cleanup.py` | SessionEnd | Clears any PR-author swap state left over from the current session |
14
- | `session_edit_tracker_cleanup.py` | SessionStart, SessionEnd | Deletes the tracker file for the running Claude Code conversation from the system temp directory — at start for a clean slate and at end for a clean exit. A tracker is read only by the conversation that wrote it, so a live idle tracker is kept while a peer cleans up |
15
- | `plugin_data_dir_cleanup.py` | SessionStart | Removes empty plugin data directories at startup to prevent `EEXIST` when Claude Code recreates them |
16
- | `untracked_repo_detector.py` | — | Retired legacy script; no longer registered |
17
- | `task_list_loop_starter.py` | SessionStart | Emits an `additionalContext` directive telling Claude to keep the task list current on a 10-minute cadence, starting the `/loop` skill when one is not already running. Writes nothing and runs no tools itself. |
18
- | `orchestrator_auto_starter.py` | SessionStart | Opt-in (`CLAUDE_ORCHESTRATOR_AUTO_STARTER_ENABLED`) consumer of the shared SessionStart injector; emits orchestrator skill context when enabled. Manual `/orchestrator` unchanged. |
19
- | `issue_tracker_session_starter.py` | SessionStart | Opt-in (`CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED`) and repository-gated (git root in `~/.claude/project-paths.json`) issue-tracker skill context. |
20
- | `_path_setup.py` | — | Inserts the hooks directory on `sys.path` so SessionStart scripts import `hooks_constants` with top-level imports. |
21
- | `test_orchestrator_auto_starter.py` | — | Tests for `orchestrator_auto_starter.py` |
22
- | `test_issue_tracker_session_starter.py` | — | Tests for `issue_tracker_session_starter.py` |
23
- | `working_style_prompt.py` | SessionStart | Emits an `additionalContext` block with the fixed working-style prompt (running ledger, canonical language policy, outcome-first finish, scope discipline). Writes nothing and runs no tools itself. |
24
- | `style_reminder_prompt.py` | UserPromptSubmit | Adds the style reminder ("small words. few words. always. forever.") to every message, as `hookSpecificOutput.additionalContext`. Codex reads the same shape, so a manually wired Codex `hooks.json` can reuse this script too (see `docs/codex-compatibility.md`). Prints text and stops. |
25
- | `test_style_reminder_prompt.py` | — | Tests for `style_reminder_prompt.py` |
26
- | `test_gh_pr_author_session_cleanup.py` | — | Tests for `gh_pr_author_session_cleanup.py` |
27
- | `test_session_edit_tracker_cleanup.py` | — | Tests for `session_edit_tracker_cleanup.py` |
28
- | `test_session_env_cleanup.py` | — | Tests for `session_env_cleanup.py` |
29
- | `test_untracked_repo_detector.py` | — | Tests for `untracked_repo_detector.py` |
30
- | `test_task_list_loop_starter.py` | — | Tests for `task_list_loop_starter.py` |
31
- | `test__path_setup.py` | — | Tests for `_path_setup.py` |
32
- | `test_working_style_prompt.py` | — | Tests for `working_style_prompt.py` |
33
-
34
- ## Conventions
35
-
36
- - `session_env_cleanup.py` is Windows-specific in effect but safe to run on all platforms; it exits 0 when the target directory does not exist.
37
- - Constants (stale-age threshold, directory names) live in `hooks_constants/session_env_cleanup_constants.py`.
38
- - Tests run with `python -m pytest session/test_<name>.py`.
@@ -1,20 +1 @@
1
- # hooks/validation
2
1
 
3
- PostToolUse hooks that validate code quality after Claude writes or edits a file. Unlike the blocking hooks (which fire PreToolUse and can deny the write), these hooks run after the write and report errors that need a follow-up fix.
4
-
5
- ## Key files
6
-
7
- | File | Event | What it does |
8
- |---|---|---|
9
- | `mypy_validator.py` | PostToolUse (Write/Edit on `.py` files) | Runs mypy on the written file and blocks (via PostToolUse block decision) when type errors are found — catches missing attributes, wrong signatures, type mismatches, and import errors |
10
- | `hook_format_validator.py` | PostToolUse | Validates that a hook script's output JSON matches the expected Claude Code hook-output schema |
11
- | `test_mypy_validator.py` | — | Tests for `mypy_validator.py` |
12
- | `test_hook_format_validator.py` | — | Tests for `hook_format_validator.py` |
13
-
14
- ## Conventions
15
-
16
- - `mypy_validator.py` resolves the project root via `CLAUDE_PROJECT_ROOT` or `git rev-parse --show-toplevel`.
17
- - It works on both WSL and Windows.
18
- - Constants (timeouts, max displayed errors) are inline in `mypy_validator.py`; longer tunables go in `hooks_constants/`.
19
- - The `eval_*.txt` files in this directory are evaluation exports used during development — not runtime artifacts.
20
- - Tests run with `python -m pytest validation/test_<name>.py`.
@@ -1,52 +1 @@
1
- # hooks/validators
2
1
 
3
- A library of check modules used by the validation hooks. Each module focuses on one concern; `run_all_validators.py` runs them all and collects results. These modules do not hook into Claude Code directly — they are imported by the validation hooks.
4
-
5
- ## Core infrastructure
6
-
7
- | File | Role |
8
- |---|---|
9
- | `validator_base.py` | Defines the `Violation` dataclass (`file`, `line`, `message`) used by every check module |
10
- | `validator_defaults.py` | Default configuration values shared across check modules |
11
- | `exempt_paths.py` | Path exemption logic — paths that checks skip (e.g. vendored code) |
12
- | `output_formatter.py` | Formats `Violation` lists into human-readable output |
13
- | `run_all_validators.py` | Entry point — runs every check module and aggregates results |
14
- | `health_check.py` | Verifies that all validator dependencies (ruff, mypy) are reachable |
15
- | `pyproject_config_discovery.py` | Shared walk-up primitive that resolves a tool's pyproject.toml config from an original target path, matching the `[tool.<name>]` table the tool owns |
16
- | `system_temporary_roots.py` | Shared membership for OS temp roots (`gettempdir` plus `TEMP` / `TMP` / `TMPDIR` / `RUNNER_TEMP`); mypy walk stop and PreToolUse staging both call it |
17
- | `python_style_helpers.py` | Shared source-line splitting and function-discovery helpers imported by `python_style_checks.py` |
18
-
19
- ## Check modules
20
-
21
- | Module | What it checks |
22
- |---|---|
23
- | `abbreviation_checks.py` | Abbreviated names in Python code |
24
- | `code_quality_checks.py` | General code quality concerns (dead code, stub bodies, etc.) |
25
- | `comment_checks.py` | Inline comment presence and content |
26
- | `file_structure_checks.py` | File-level structural rules (line count, module layout) |
27
- | `git_checks.py` | Git-state checks (untracked files, merge conflicts) |
28
- | `magic_value_checks.py` | Magic numbers and strings |
29
- | `mypy_integration.py` | Runs mypy and converts its output to `Violation` objects; stops project-root walks at the system temp directory; detached gate files skip followed imports and time out as a skip (passed) |
30
- | `pr_reference_checks.py` | PR references in commit messages and changelogs |
31
- | `python_antipattern_checks.py` | Python-specific anti-patterns (bare `except`, `Any`, etc.) |
32
- | `python_style_checks.py` | Python style rules (naming, imports, type hints) |
33
- | `react_checks.py` | React/TSX-specific checks (class component patterns, PureComponent usage) |
34
- | `ruff_integration.py` | Runs ruff and converts its output to `Violation` objects |
35
- | `security_checks.py` | Security anti-patterns (hardcoded secrets, unsafe calls) |
36
- | `todo_checks.py` | TODO/FIXME markers without an associated issue reference |
37
- | `type_safety_checks.py` | Type-safety rules (no `Any`, no `cast`, no `# type: ignore`) |
38
- | `useless_test_checks.py` | Tests that check only existence or constant values |
39
-
40
- ## Subdirectory
41
-
42
- | Directory | Role |
43
- |---|---|
44
- | `config/` | Constants module (`directory_exemption_constants.py`) holding the exemption directory-segment names that `validate_proposed_file` staging keeps |
45
- | `test_files/` | Fixture files used by the validator tests — not checked-in test code |
46
-
47
- ## Conventions
48
-
49
- - Every check module exposes one or more functions that take file content or an AST and return a list of `Violation` objects.
50
- - Test files live beside the modules they test: `test_<module>.py`. Run with `python -m pytest validators/test_<name>.py`.
51
- - `conftest.py` provides shared test fixtures (sample files, fixture paths).
52
- - `README.md` in this directory documents the validator design and how to add a new check.
@@ -2,7 +2,7 @@
2
2
 
3
3
  AST-based Python style checks for code quality enforcement.
4
4
 
5
- The checks live in `python_style_checks.py`; the shared source-line splitting and function-discovery helpers they build on live in `python_style_helpers.py`.
5
+ The checks live in `python_style_checks.py`; the shared source-line splitting and function-discovery helpers they build on live in `python_style_helpers.py`. `hook_timing_harness.py` measures real wall-clock time for the Write/Edit hooks `hooks.json` registers, reporting p50 and p95 over a configurable run count.
6
6
 
7
7
  ## Checks Implemented
8
8
 
@@ -0,0 +1,16 @@
1
+ """Constants for hook_timing_harness.py.
2
+
3
+ Extracted to satisfy the constants-location rule: production constants outside
4
+ ``config/`` move here rather than sitting at module scope in the harness.
5
+ """
6
+
7
+ DEFAULT_RUN_COUNT = 7
8
+ P50_FRACTION = 0.50
9
+ P95_FRACTION = 0.95
10
+ DEFAULT_TARGET_RELATIVE_PATH = "hooks/blocking/code_rules_shared.py"
11
+ SUBPROCESS_TIMEOUT_SECONDS = 60.0
12
+ ALL_WRITE_EDIT_MATCHER_PATTERNS = frozenset({"Write|Edit", "Write|Edit|MultiEdit"})
13
+ ALL_TIMED_EVENT_NAMES = ("PreToolUse", "PostToolUse")
14
+ RUN_ALL_VALIDATORS_LABEL = "run_all_validators"
15
+ CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
16
+ PARENT_LEVELS_TO_PACKAGE_ROOT = 2
@@ -0,0 +1,287 @@
1
+ """Measure real wall-clock time for the hooks.json-registered Write/Edit hooks.
2
+
3
+ Each timed command is the literal ``hooks.json`` command string with
4
+ ``${CLAUDE_PLUGIN_ROOT}`` resolved to the real package root, run as its own
5
+ subprocess against a real repository file, timed over a configurable run
6
+ count, and reported as p50 and p95 milliseconds.
7
+
8
+ ::
9
+
10
+ $ python3 hook_timing_harness.py --runs 7
11
+ run_all_validators p50=1948.3ms p95=3020.1ms
12
+ pre_tool_use_dispatcher p50=180.2ms p95=230.4ms
13
+ post_tool_use_dispatcher p50=80.1ms p95=1200.5ms
14
+ session_file_edit_tracker p50=31.0ms p95=34.2ms
15
+
16
+ A target under the OS temp root would return before any validator ran, so this
17
+ harness refuses one. It always runs the whole command line ``hooks.json``
18
+ registers, never a single hosted check module standing in for it, so neither
19
+ shortcut can distort the numbers.
20
+
21
+ The timed payload carries no ``session_id``, so ``session_file_edit_tracker``
22
+ writes its edit record to the default-session file rather than to a
23
+ caller-specific one.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import importlib.util
30
+ import json
31
+ import math
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ import time
36
+ from pathlib import Path
37
+ from types import ModuleType
38
+ from typing import TextIO
39
+
40
+
41
+ def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
42
+ """Load and execute the module found at module_path, under module_name.
43
+
44
+ Loads by absolute path rather than a package-qualified import, so this
45
+ harness resolves its own constants module the same way whether it runs
46
+ standalone (``python3 hook_timing_harness.py``) or loaded by a test —
47
+ neither invocation shape needs ``hooks/validators`` on ``sys.path``.
48
+ """
49
+ specification = importlib.util.spec_from_file_location(module_name, module_path)
50
+ assert specification is not None
51
+ assert specification.loader is not None
52
+ module = importlib.util.module_from_spec(specification)
53
+ specification.loader.exec_module(module)
54
+ return module
55
+
56
+
57
+ _harness_constants = _load_module_from_path(
58
+ "hook_timing_harness_constants",
59
+ Path(__file__).resolve().parent / "config" / "hook_timing_harness_constants.py",
60
+ )
61
+ _system_temporary_roots = _load_module_from_path(
62
+ "hook_timing_harness_system_temporary_roots",
63
+ Path(__file__).resolve().parent / "system_temporary_roots.py",
64
+ )
65
+
66
+
67
+ def _hook_label_for_command(command: str) -> str:
68
+ """Return the short name a wall-time report row uses for one hook command.
69
+
70
+ ::
71
+
72
+ 'python3 .../pre_tool_use_dispatcher.py' -> 'pre_tool_use_dispatcher'
73
+ 'python3 -c "... run_all_validators ..." ...' -> 'run_all_validators'
74
+ """
75
+ if _harness_constants.RUN_ALL_VALIDATORS_LABEL in command:
76
+ return _harness_constants.RUN_ALL_VALIDATORS_LABEL
77
+ script_basename_pattern = re.compile(r"([A-Za-z0-9_]+)\.py")
78
+ all_basename_matches = script_basename_pattern.findall(command)
79
+ if not all_basename_matches:
80
+ raise ValueError(f"cannot derive a hook label from command: {command!r}")
81
+ return all_basename_matches[-1]
82
+
83
+
84
+ def _commands_in_matcher_group(all_matcher_group: dict[str, object]) -> list[tuple[str, str]]:
85
+ """Return the (label, command) pairs one hooks.json matcher group registers."""
86
+ if all_matcher_group.get("matcher") not in _harness_constants.ALL_WRITE_EDIT_MATCHER_PATTERNS:
87
+ return []
88
+ all_hosted_hooks = all_matcher_group.get("hooks", [])
89
+ if not isinstance(all_hosted_hooks, list):
90
+ return []
91
+ all_commands: list[tuple[str, str]] = []
92
+ for each_hook in all_hosted_hooks:
93
+ command = each_hook.get("command", "") if isinstance(each_hook, dict) else ""
94
+ all_commands.append((_hook_label_for_command(command), command))
95
+ return all_commands
96
+
97
+
98
+ def write_edit_hook_commands(hooks_json_path: Path) -> list[tuple[str, str]]:
99
+ """Return the (label, command) pairs hooks.json registers for Write/Edit.
100
+
101
+ Reads the real ``PreToolUse`` and ``PostToolUse`` sections and keeps only
102
+ the matcher groups that fire on a Write or Edit tool call, so the harness
103
+ times the same roster a live edit exercises rather than every hook in the
104
+ file (session, lifecycle, and Bash-only hooks included).
105
+
106
+ Args:
107
+ hooks_json_path: Path to the ``hooks.json`` file to read.
108
+
109
+ Returns:
110
+ One ``(label, command)`` pair per Write/Edit-matching hook, in the
111
+ file's own declaration order.
112
+ """
113
+ hooks_document = json.loads(hooks_json_path.read_text(encoding="utf-8"))
114
+ all_commands: list[tuple[str, str]] = []
115
+ for each_event_name in _harness_constants.ALL_TIMED_EVENT_NAMES:
116
+ for each_matcher_group in hooks_document["hooks"].get(each_event_name, []):
117
+ all_commands.extend(_commands_in_matcher_group(each_matcher_group))
118
+ return all_commands
119
+
120
+
121
+ def _is_under_directory(candidate: Path, directory: Path) -> bool:
122
+ """Return whether *candidate* equals *directory* or sits under it."""
123
+ return candidate == directory or directory in candidate.parents
124
+
125
+
126
+ def ensure_real_repository_target(
127
+ target_path: Path, repository_root: Path | None = None
128
+ ) -> Path:
129
+ """Return *target_path* resolved, after refusing an ephemeral scratch path.
130
+
131
+ A target under a system temporary root (``tempfile.gettempdir()`` plus any
132
+ set ``TEMP`` / ``TMP`` / ``TMPDIR`` / ``RUNNER_TEMP``) trips
133
+ ``is_ephemeral_path`` inside the hooks and returns before any validator
134
+ runs. A target inside *repository_root* is always real, even when the
135
+ checkout itself sits under a system temporary root.
136
+
137
+ Raises:
138
+ ValueError: When the path sits under a system temporary root and
139
+ outside *repository_root*.
140
+ """
141
+ resolved_target = target_path.resolve()
142
+ if repository_root is not None and _is_under_directory(
143
+ resolved_target, repository_root.resolve()
144
+ ):
145
+ return resolved_target
146
+ enclosing_temporary_root = _system_temporary_roots.enclosing_system_temporary_root(
147
+ resolved_target
148
+ )
149
+ if enclosing_temporary_root is None:
150
+ return resolved_target
151
+ raise ValueError(
152
+ f"refusing an ephemeral timing target under {enclosing_temporary_root}: "
153
+ f"{resolved_target}"
154
+ )
155
+
156
+
157
+ def _write_tool_payload(target_path: Path) -> str:
158
+ """Build a Write-tool PreToolUse/PostToolUse payload naming *target_path*."""
159
+ return json.dumps(
160
+ {
161
+ "tool_name": "Write",
162
+ "tool_input": {
163
+ "file_path": str(target_path),
164
+ "content": target_path.read_text(encoding="utf-8"),
165
+ },
166
+ }
167
+ )
168
+
169
+
170
+ def run_hosted_command_once_milliseconds(
171
+ command_template: str, package_root: Path, payload_text: str
172
+ ) -> float:
173
+ """Run one registered hooks.json command once and return its wall time.
174
+
175
+ Args:
176
+ command_template: The ``hooks.json`` command string, ``${CLAUDE_PLUGIN_ROOT}``
177
+ still unresolved.
178
+ package_root: The real package root ``${CLAUDE_PLUGIN_ROOT}`` resolves to.
179
+ payload_text: The JSON payload piped to the command's stdin.
180
+
181
+ Returns:
182
+ The wall-clock time the subprocess took, in milliseconds.
183
+ """
184
+ resolved_command = command_template.replace(
185
+ _harness_constants.CLAUDE_PLUGIN_ROOT_PLACEHOLDER, str(package_root)
186
+ )
187
+ started_at = time.perf_counter()
188
+ subprocess.run(
189
+ resolved_command,
190
+ shell=True,
191
+ input=payload_text,
192
+ capture_output=True,
193
+ text=True,
194
+ timeout=_harness_constants.SUBPROCESS_TIMEOUT_SECONDS,
195
+ check=False,
196
+ )
197
+ finished_at = time.perf_counter()
198
+ return (finished_at - started_at) * 1000.0
199
+
200
+
201
+ def percentile(
202
+ all_sorted_wall_times_milliseconds: list[float], fraction: float
203
+ ) -> float:
204
+ """Return the nearest-rank percentile of an already-sorted sample.
205
+
206
+ ::
207
+
208
+ percentile([10, 20, 30, 40, 50, 60, 70], 0.95) -> 70 (rank 7 of 7)
209
+ percentile([10, 20, 30, 40, 50, 60, 70], 0.50) -> 40 (rank 4 of 7)
210
+
211
+ Args:
212
+ all_sorted_wall_times_milliseconds: Ascending wall-time samples.
213
+ fraction: The percentile as a fraction between 0 and 1.
214
+
215
+ Returns:
216
+ The nearest-rank percentile value.
217
+
218
+ Raises:
219
+ ValueError: When the sample is empty.
220
+ """
221
+ if not all_sorted_wall_times_milliseconds:
222
+ raise ValueError("cannot compute a percentile of an empty sample")
223
+ sample_size = len(all_sorted_wall_times_milliseconds)
224
+ rank_index = math.ceil(fraction * sample_size) - 1
225
+ return all_sorted_wall_times_milliseconds[max(0, min(rank_index, sample_size - 1))]
226
+
227
+
228
+ def measure_hosted_command_wall_times(
229
+ hooks_json_path: Path,
230
+ package_root: Path,
231
+ target_path: Path,
232
+ run_count: int,
233
+ *,
234
+ repository_root: Path | None = None,
235
+ ) -> dict[str, list[float]]:
236
+ """Time every Write/Edit hooks.json command over *run_count* runs each.
237
+
238
+ ``repository_root`` is the root ``ensure_real_repository_target`` judges
239
+ ephemerality against; it defaults to ``package_root``.
240
+ """
241
+ real_target = ensure_real_repository_target(
242
+ target_path, repository_root if repository_root is not None else package_root
243
+ )
244
+ payload_text = _write_tool_payload(real_target)
245
+ all_wall_times_by_label: dict[str, list[float]] = {}
246
+ for each_label, each_command_template in write_edit_hook_commands(hooks_json_path):
247
+ all_wall_times_by_label[each_label] = [
248
+ run_hosted_command_once_milliseconds(each_command_template, package_root, payload_text)
249
+ for _ in range(run_count)
250
+ ]
251
+ return all_wall_times_by_label
252
+
253
+
254
+ def _report_line(label: str, all_wall_times_milliseconds: list[float]) -> str:
255
+ sorted_wall_times_milliseconds = sorted(all_wall_times_milliseconds)
256
+ p50 = percentile(sorted_wall_times_milliseconds, _harness_constants.P50_FRACTION)
257
+ p95 = percentile(sorted_wall_times_milliseconds, _harness_constants.P95_FRACTION)
258
+ return f"{label:<26} p50={p50:.1f}ms p95={p95:.1f}ms"
259
+
260
+
261
+ def main(
262
+ all_arguments: list[str],
263
+ report_stream: TextIO = sys.stdout,
264
+ repository_root: Path | None = None,
265
+ ) -> int:
266
+ """Run the harness CLI; report_stream and repository_root are injectable seams."""
267
+ package_root = Path(__file__).resolve().parents[
268
+ _harness_constants.PARENT_LEVELS_TO_PACKAGE_ROOT
269
+ ]
270
+ hooks_json_path = Path(__file__).resolve().parent.parent / "hooks.json"
271
+ default_target_path = package_root / _harness_constants.DEFAULT_TARGET_RELATIVE_PATH
272
+ parser = argparse.ArgumentParser(description=__doc__)
273
+ parser.add_argument("--runs", type=int, default=_harness_constants.DEFAULT_RUN_COUNT)
274
+ parser.add_argument("--target", type=Path, default=default_target_path)
275
+ arguments = parser.parse_args(all_arguments)
276
+ effective_repository_root = repository_root if repository_root is not None else package_root
277
+ all_wall_times_by_label = measure_hosted_command_wall_times(
278
+ hooks_json_path, package_root, arguments.target, arguments.runs,
279
+ repository_root=effective_repository_root,
280
+ )
281
+ for each_label, each_wall_times in all_wall_times_by_label.items():
282
+ report_stream.write(_report_line(each_label, each_wall_times) + "\n")
283
+ return 0
284
+
285
+
286
+ if __name__ == "__main__":
287
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,251 @@
1
+ """Tests for hook_timing_harness.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import io
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+ from types import ModuleType
11
+
12
+ import pytest
13
+
14
+
15
+ def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
16
+ """Load and execute the module found at module_path, under module_name."""
17
+ specification = importlib.util.spec_from_file_location(module_name, module_path)
18
+ assert specification is not None
19
+ assert specification.loader is not None
20
+ module = importlib.util.module_from_spec(specification)
21
+ specification.loader.exec_module(module)
22
+ return module
23
+
24
+
25
+ _VALIDATORS_DIRECTORY = Path(__file__).resolve().parent
26
+ _PACKAGE_ROOT = _VALIDATORS_DIRECTORY.parents[1]
27
+ _HOOKS_JSON_PATH = _VALIDATORS_DIRECTORY.parent / "hooks.json"
28
+ _REAL_TARGET_FILE = _PACKAGE_ROOT / "hooks" / "blocking" / "code_rules_shared.py"
29
+
30
+ if str(_VALIDATORS_DIRECTORY) not in sys.path:
31
+ sys.path.insert(0, str(_VALIDATORS_DIRECTORY))
32
+ hook_timing_harness = _load_module_from_path(
33
+ "hook_timing_harness", _VALIDATORS_DIRECTORY / "hook_timing_harness.py"
34
+ )
35
+
36
+
37
+ def test_hook_label_for_command_names_run_all_validators() -> None:
38
+ command = (
39
+ 'python3 -c "import sys; from validators.run_all_validators import main; '
40
+ 'sys.exit(main())" --pre-tool-use'
41
+ )
42
+ assert hook_timing_harness._hook_label_for_command(command) == "run_all_validators"
43
+
44
+
45
+ def test_hook_label_for_command_names_the_script_basename() -> None:
46
+ command = "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py"
47
+ assert hook_timing_harness._hook_label_for_command(command) == "pre_tool_use_dispatcher"
48
+
49
+
50
+ def test_write_edit_hook_commands_reads_the_real_hooks_json_roster() -> None:
51
+ all_commands = hook_timing_harness.write_edit_hook_commands(_HOOKS_JSON_PATH)
52
+ all_labels = [each_label for each_label, _each_command in all_commands]
53
+ assert all_labels == [
54
+ "run_all_validators",
55
+ "pre_tool_use_dispatcher",
56
+ "post_tool_use_dispatcher",
57
+ "session_file_edit_tracker",
58
+ ]
59
+
60
+
61
+ def test_percentile_returns_the_nearest_rank_value() -> None:
62
+ sample = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0]
63
+ assert hook_timing_harness.percentile(sample, 0.50) == 40.0
64
+ assert hook_timing_harness.percentile(sample, 0.95) == 70.0
65
+
66
+
67
+ def test_percentile_raises_on_an_empty_sample() -> None:
68
+ with pytest.raises(ValueError):
69
+ hook_timing_harness.percentile([], 0.50)
70
+
71
+
72
+ def test_ensure_real_repository_target_refuses_an_os_temp_path(
73
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
74
+ ) -> None:
75
+ monkeypatch.setenv("TMPDIR", str(tmp_path))
76
+ ephemeral_target = Path(tempfile.gettempdir()) / "scratch.py"
77
+ with pytest.raises(ValueError, match="ephemeral"):
78
+ hook_timing_harness.ensure_real_repository_target(ephemeral_target)
79
+
80
+
81
+ def test_ensure_real_repository_target_accepts_a_real_repository_file() -> None:
82
+ resolved = hook_timing_harness.ensure_real_repository_target(
83
+ _REAL_TARGET_FILE, _PACKAGE_ROOT
84
+ )
85
+ assert resolved == _REAL_TARGET_FILE.resolve()
86
+
87
+
88
+ def test_ensure_real_repository_target_accepts_a_repo_file_under_the_os_temp_root(
89
+ tmp_path: Path,
90
+ ) -> None:
91
+ """A checkout placed under the OS temp root still accepts its own real files.
92
+
93
+ Ephemerality is judged relative to the given repository root, not the OS
94
+ temp root alone, so a checkout that itself sits under the OS temp root (a
95
+ common sandbox layout) does not misclassify every real file inside it as
96
+ a throwaway scratch target.
97
+ """
98
+ repository_root = tmp_path / "repo"
99
+ real_file = repository_root / "hooks" / "blocking" / "code_rules_shared.py"
100
+ real_file.parent.mkdir(parents=True)
101
+ real_file.write_text("x = 1\n", encoding="utf-8")
102
+
103
+ resolved = hook_timing_harness.ensure_real_repository_target(real_file, repository_root)
104
+
105
+ assert resolved == real_file.resolve()
106
+
107
+
108
+ def test_ensure_real_repository_target_refuses_a_scratch_path_outside_the_repository_root(
109
+ tmp_path: Path,
110
+ ) -> None:
111
+ repository_root = tmp_path / "repo"
112
+ repository_root.mkdir()
113
+ scratch_target = tmp_path / "scratch.py"
114
+
115
+ with pytest.raises(ValueError, match="ephemeral"):
116
+ hook_timing_harness.ensure_real_repository_target(scratch_target, repository_root)
117
+
118
+
119
+ def test_ensure_real_repository_target_refuses_a_path_under_runner_temp_alone(
120
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
121
+ ) -> None:
122
+ """GitHub Actions puts pytest basetemp under RUNNER_TEMP, not gettempdir().
123
+
124
+ ``gettempdir()`` stays ``/tmp`` on the hosted runner while pytest's own
125
+ ``tmp_path`` lives under ``RUNNER_TEMP`` (``/home/runner/work/_temp``), a
126
+ directory ``gettempdir()`` never reports. A refusal that judges
127
+ ephemerality against ``gettempdir()`` alone misses that target and reads
128
+ the runner's own scratch file as a real repository file.
129
+ """
130
+ fake_os_temporary_root = tmp_path / "os-temp"
131
+ fake_os_temporary_root.mkdir()
132
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(fake_os_temporary_root))
133
+ runner_temporary_root = tmp_path / "runner-temp"
134
+ monkeypatch.setenv("RUNNER_TEMP", str(runner_temporary_root))
135
+ ephemeral_target = runner_temporary_root / "scratch.py"
136
+
137
+ with pytest.raises(ValueError, match="ephemeral"):
138
+ hook_timing_harness.ensure_real_repository_target(ephemeral_target)
139
+
140
+
141
+ def test_run_hosted_command_once_milliseconds_drives_the_real_tracker() -> None:
142
+ """A live subprocess run against a real repository file measures a positive time.
143
+
144
+ ::
145
+
146
+ session_file_edit_tracker is the fastest hosted command, so this is the
147
+ cheap live end-to-end proof: it drives the actual registered
148
+ ``hooks.json`` command line, not a standalone hosted check module.
149
+ """
150
+ all_commands = hook_timing_harness.write_edit_hook_commands(_HOOKS_JSON_PATH)
151
+ tracker_command = next(
152
+ each_command
153
+ for each_label, each_command in all_commands
154
+ if each_label == "session_file_edit_tracker"
155
+ )
156
+ payload_text = hook_timing_harness._write_tool_payload(_REAL_TARGET_FILE)
157
+ elapsed_milliseconds = hook_timing_harness.run_hosted_command_once_milliseconds(
158
+ tracker_command, _PACKAGE_ROOT, payload_text
159
+ )
160
+ assert 0.0 < elapsed_milliseconds < 5000.0
161
+
162
+
163
+ def test_measure_hosted_command_wall_times_covers_the_real_roster() -> None:
164
+ """A live run against the real hooks.json roster returns all four hooks.
165
+
166
+ Runs each registered command once against a real repository file,
167
+ including ``run_all_validators``, so this is the one place the suite pays
168
+ its full wall time to prove the roster end to end.
169
+ """
170
+ all_wall_times_by_label = hook_timing_harness.measure_hosted_command_wall_times(
171
+ _HOOKS_JSON_PATH, _PACKAGE_ROOT, _REAL_TARGET_FILE, 1
172
+ )
173
+ assert set(all_wall_times_by_label) == {
174
+ "run_all_validators",
175
+ "pre_tool_use_dispatcher",
176
+ "post_tool_use_dispatcher",
177
+ "session_file_edit_tracker",
178
+ }
179
+ assert all(
180
+ each_time > 0.0
181
+ for each_times in all_wall_times_by_label.values()
182
+ for each_time in each_times
183
+ )
184
+
185
+
186
+ def test_measure_hosted_command_wall_times_honors_a_repository_root_override(
187
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
188
+ ) -> None:
189
+ """The wrapper forwards repository_root to the delegate, not just accepts it.
190
+
191
+ ``ensure_real_repository_target`` already accepts ``repository_root``, but
192
+ until the wrapper's own signature exposes and forwards it, no caller of
193
+ ``measure_hosted_command_wall_times`` can reach that parameter — the exact
194
+ gap the wrapper-plumb-through check caught.
195
+ """
196
+ monkeypatch.setattr(hook_timing_harness, "write_edit_hook_commands", lambda path: [])
197
+ repository_root = tmp_path / "repo"
198
+ real_file = repository_root / "hooks" / "blocking" / "code_rules_shared.py"
199
+ real_file.parent.mkdir(parents=True)
200
+ real_file.write_text("x = 1\n", encoding="utf-8")
201
+
202
+ with pytest.raises(ValueError, match="ephemeral"):
203
+ hook_timing_harness.measure_hosted_command_wall_times(
204
+ _HOOKS_JSON_PATH, _PACKAGE_ROOT, real_file, 1
205
+ )
206
+
207
+ all_wall_times_by_label = hook_timing_harness.measure_hosted_command_wall_times(
208
+ _HOOKS_JSON_PATH, _PACKAGE_ROOT, real_file, 1, repository_root=repository_root
209
+ )
210
+ assert all_wall_times_by_label == {}
211
+
212
+
213
+ def test_main_writes_one_report_line_per_label(monkeypatch: pytest.MonkeyPatch) -> None:
214
+ fake_wall_times_by_label = {"pre_tool_use_dispatcher": [100.0, 200.0]}
215
+ monkeypatch.setattr(
216
+ hook_timing_harness,
217
+ "measure_hosted_command_wall_times",
218
+ lambda *args, **kwargs: fake_wall_times_by_label,
219
+ )
220
+ report_stream = io.StringIO()
221
+ exit_code = hook_timing_harness.main(["--runs", "2"], report_stream=report_stream)
222
+ report_text = report_stream.getvalue()
223
+ assert exit_code == 0
224
+ assert "pre_tool_use_dispatcher" in report_text
225
+ assert "p50=" in report_text and "p95=" in report_text
226
+
227
+
228
+ def test_main_forwards_its_own_repository_root_to_the_wrapper(
229
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
230
+ ) -> None:
231
+ """main() forwards a caller-supplied repository_root, not just accepts it.
232
+
233
+ Mirrors the wrapper-level gap: adding ``repository_root`` to
234
+ ``measure_hosted_command_wall_times`` alone leaves ``main`` as a second
235
+ wrapper dropping the same optional keyword.
236
+ """
237
+ all_captured_kwargs: dict[str, object] = {}
238
+
239
+ def _spy_measure_hosted_command_wall_times(*args: object, **kwargs: object) -> dict[str, list[float]]:
240
+ all_captured_kwargs.update(kwargs)
241
+ return {}
242
+
243
+ monkeypatch.setattr(
244
+ hook_timing_harness,
245
+ "measure_hosted_command_wall_times",
246
+ _spy_measure_hosted_command_wall_times,
247
+ )
248
+ override_root = tmp_path / "repo"
249
+ exit_code = hook_timing_harness.main([], repository_root=override_root)
250
+ assert exit_code == 0
251
+ assert all_captured_kwargs.get("repository_root") == override_root