claude-dev-env 8.54.0 → 8.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/features/README.md +32 -0
- package/hooks/features/bash-dispatchers.md +40 -0
- package/hooks/features/conduct-gates.md +45 -0
- package/hooks/features/lifecycle-cleanup.md +40 -0
- package/hooks/features/observability.md +31 -0
- package/hooks/features/post-write-validation.md +28 -0
- package/hooks/features/session-context.md +40 -0
- package/hooks/features/spawn-routing.md +34 -0
- package/hooks/features/write-edit-blocking.md +34 -0
- package/hooks/test_hook_feature_map.py +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Hook feature map
|
|
2
|
+
|
|
3
|
+
Open this map before changing, debugging, or adding a hook. Pick the family that owns the behavior, then use its registration and proof command to check the change.
|
|
4
|
+
|
|
5
|
+
## Where hooks run
|
|
6
|
+
|
|
7
|
+
- Claude hook registrations live in [`hooks.json`](../hooks.json). The four `ALL_*HOSTED_HOOK_ENTRIES` rosters in [`hooks_constants/`](../hooks_constants/) name scripts run inside dispatchers. The Write and Edit dispatcher also calls `blocking/state_description_blocker.py` directly.
|
|
8
|
+
- Policy lint loads several modules under `hooks/blocking/` through [`adapter_detectors.py`](../../scripts/policy_lint/adapter_detectors.py) and [`adapter_pairing.py`](../../scripts/policy_lint/adapter_pairing.py). A module in that directory is not necessarily a Claude hook.
|
|
9
|
+
- Reinstall pruning comes from `FOLDED_HOOK_RELATIVE_PATHS`, `POST_FOLDED_HOOK_RELATIVE_PATHS`, and `RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS` in [`bin/install.mjs`](../../bin/install.mjs).
|
|
10
|
+
- Git hook entry points and their tests live in [`hooks/git-hooks/`](../git-hooks/).
|
|
11
|
+
|
|
12
|
+
## Feature entry contract
|
|
13
|
+
|
|
14
|
+
Each family page starts with a title and an agent-facing behavior summary. Its four sections appear in order. `Checks` names each registered script once, `When it fires` gives the event, matcher, timeout, and hosted tool names, `Proving it` pairs an input with a repository-root command and expected observation, and `Gotchas` records traps near that family.
|
|
15
|
+
|
|
16
|
+
## Conventions
|
|
17
|
+
|
|
18
|
+
- Script paths in `Checks` start at `hooks/`. Commands in `Proving it` start at the repository root.
|
|
19
|
+
- The dispatcher is the registration point for each hosted script. Use its roster to check which tool names reach the script.
|
|
20
|
+
- A command that runs pytest uses the named adjacent test file. Read that test before changing the behavior it covers.
|
|
21
|
+
- `hooks/hooks.json` and the four hosted rosters decide membership. The map test checks that every registered script appears once.
|
|
22
|
+
|
|
23
|
+
## Families
|
|
24
|
+
|
|
25
|
+
- [Write and Edit blocking](./write-edit-blocking.md) covers the mutation dispatcher, edit advisors, and description gate.
|
|
26
|
+
- [Bash dispatchers](./bash-dispatchers.md) covers command rewriting and the post-call reminder.
|
|
27
|
+
- [Post-write validation](./post-write-validation.md) covers the after-write dispatcher and formatter.
|
|
28
|
+
- [Conduct gates](./conduct-gates.md) covers chat replies, edit markers, step notes, checked claims, and the pull request lifecycle skill.
|
|
29
|
+
- [Session context](./session-context.md) covers skill reminders, startup guidance, and the auto mode denial quick fix.
|
|
30
|
+
- [Spawn routing](./spawn-routing.md) covers spawn readiness, pacing, and model selection.
|
|
31
|
+
- [Observability](./observability.md) covers instruction loads, edited files, and investigation resets.
|
|
32
|
+
- [Lifecycle cleanup](./lifecycle-cleanup.md) covers nested checkouts, worktree setup, and session cleanup.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Bash dispatchers
|
|
2
|
+
|
|
3
|
+
This family rewrites a Git Bash command when path conversion would change a revision argument. After shell calls, it can inject a pull request checklist into the agent's context.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `blocking/bash_pre_tool_use_dispatcher.py` combines hosted shell decisions and forwards an updated command when a hosted rewriter allows it.
|
|
8
|
+
- `blocking/msys_rev_path_rewriter.py` adds a narrow `MSYS2_ARG_CONV_EXCL` prefix for affected revision and path tokens.
|
|
9
|
+
- `blocking/headless_claude_broker_gate.py` denies a headless `claude -p` or `claude --print` call and names the broker command to run instead.
|
|
10
|
+
- `blocking/gh_global_account_switch_gate.py` denies `gh auth switch`, `gh auth login`, and `gh auth logout` and names the per-command `GH_TOKEN` form to run instead.
|
|
11
|
+
- `blocking/bash_post_call_dispatcher.py` runs hosted observers and joins their context output without blocking the call.
|
|
12
|
+
- `advisory/pr_done_reminder.py` adds a pull request checklist after a successful push or pull request creation.
|
|
13
|
+
|
|
14
|
+
## When it fires
|
|
15
|
+
|
|
16
|
+
- `blocking/bash_pre_tool_use_dispatcher.py` runs on `PreToolUse`, matcher `Bash`, timeout `60` seconds in `hooks.json`.
|
|
17
|
+
- `blocking/msys_rev_path_rewriter.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds. `ALL_BASH_HOSTED_HOOK_ENTRIES` selects the `Bash` tool.
|
|
18
|
+
- `blocking/headless_claude_broker_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
|
|
19
|
+
- `blocking/gh_global_account_switch_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
|
|
20
|
+
- `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
|
|
21
|
+
- `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
|
|
22
|
+
|
|
23
|
+
## Proving it
|
|
24
|
+
|
|
25
|
+
Preconditions:
|
|
26
|
+
|
|
27
|
+
- Run each command from the repository root with Python and pytest available. The test fixtures isolate shell payloads.
|
|
28
|
+
|
|
29
|
+
- **Pre-call dispatch.** Input is a clean Bash command. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_pre_tool_use_dispatcher.py -q`. The adjacent test observes the combined decision and hosted precedence.
|
|
30
|
+
- **Revision rewrite.** Input is a Git command with `origin/main:.claude/settings.json`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_msys_rev_path_rewriter.py -q`. The adjacent test observes the `origin/main:` exclusion prefix.
|
|
31
|
+
- **Broker gate.** Input is a `claude -p task` command. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_headless_claude_broker_gate.py -q`. The adjacent test observes a denial that names the broker command.
|
|
32
|
+
- **Post-call dispatch.** Input is a Bash call that yields hosted context. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_post_call_dispatcher.py -q`. The adjacent test observes a joined context payload and exit code zero.
|
|
33
|
+
- **Pull request reminder.** Input is a completed push or pull request creation. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_pr_done_reminder.py -q`. The adjacent test checks the reminder trigger and checklist verdict.
|
|
34
|
+
|
|
35
|
+
## Gotchas
|
|
36
|
+
|
|
37
|
+
- The pre-call dispatcher stops after a denial. It continues after ask and allow decisions so a later hosted hook can deny.
|
|
38
|
+
- The post-call dispatcher runs every hosted observer and passes only context. Its name avoids a substring collision in `bin/install.test.mjs`.
|
|
39
|
+
- The post-call roster includes `PowerShell`; the reminder script checks both tool names even though its docstring describes Bash calls.
|
|
40
|
+
- `bin/install.mjs` includes folded and retired shell hook paths for registration cleanup.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Conduct gates
|
|
2
|
+
|
|
3
|
+
This family stops a chat reply, edited message, or tool call when its payload breaks a session rule. Each gate gives the agent a reason it can act on.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, and requires a readable link when the text names a pull request number.
|
|
8
|
+
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
9
|
+
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
10
|
+
- `blocking/session_title_stop_gate.py` blocks a turn end once when no session title call succeeded in that turn.
|
|
11
|
+
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
12
|
+
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
13
|
+
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
14
|
+
|
|
15
|
+
## When it fires
|
|
16
|
+
|
|
17
|
+
- `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message`, timeout `10` seconds in `hooks.json`.
|
|
18
|
+
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
19
|
+
- `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
|
|
20
|
+
- `blocking/session_title_stop_gate.py` runs on `Stop`, no matcher, timeout `10` seconds in `hooks.json`.
|
|
21
|
+
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
22
|
+
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
23
|
+
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
|
|
24
|
+
|
|
25
|
+
## Proving it
|
|
26
|
+
|
|
27
|
+
Preconditions:
|
|
28
|
+
|
|
29
|
+
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
30
|
+
|
|
31
|
+
- **Reply length.** Input is a reply with four sentences or a plain pull request number. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count or link reason.
|
|
32
|
+
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
|
33
|
+
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
34
|
+
- **Title at turn end.** Input is a remote session transcript whose last turn set no title. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_stop_gate.py -q`. The adjacent test observes a block; a turn with a successful title call passes.
|
|
35
|
+
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
36
|
+
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
37
|
+
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
38
|
+
|
|
39
|
+
## Gotchas
|
|
40
|
+
|
|
41
|
+
- `blocking/step_note_gate.py` is off by default and polls for the transcript record. Subagent calls and unreadable records pass.
|
|
42
|
+
- `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
|
|
43
|
+
- The reply gate counts each nonempty list line as a sentence. Link targets and code spans add no words. Its banned words come from `reply-banned-words.json` in the Claude home, or the file `CLAUDE_REPLY_BANNED_WORDS_PATH` names, with built-in defaults.
|
|
44
|
+
- The edit marker gate scans replacement cards as well as the message text.
|
|
45
|
+
- The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Lifecycle cleanup
|
|
2
|
+
|
|
3
|
+
This family runs nested checkout hooks, refreshes a worktree base, and clears stale session files. Its cleanup paths let the agent start and end a session without leftover hook state.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `lifecycle/nested_project_hooks.py` runs hook registrations from checkouts nested one level below a session directory.
|
|
8
|
+
- `lifecycle/enter_worktree_origin_prefetch.py` fetches the default branch before a fresh worktree is created.
|
|
9
|
+
- `lifecycle/session_end_cleanup.py` removes old context cache files, temporary files, and transcript backups at session end.
|
|
10
|
+
- `session/plugin_data_dir_cleanup.py` removes empty plugin data directories at session start.
|
|
11
|
+
- `session/session_env_cleanup.py` clears the current Windows session environment directory and stale siblings at session start.
|
|
12
|
+
- `session/session_edit_tracker_cleanup.py` clears this session's edit tracker on a fresh start or session end.
|
|
13
|
+
|
|
14
|
+
## When it fires
|
|
15
|
+
|
|
16
|
+
- `lifecycle/nested_project_hooks.py` runs on `PreToolUse` matcher `*` at `120` seconds and `SessionStart` matcher empty at `1800` seconds in `hooks.json`.
|
|
17
|
+
- `lifecycle/enter_worktree_origin_prefetch.py` runs on `PreToolUse`, matcher `EnterWorktree`, timeout `25` seconds in `hooks.json`.
|
|
18
|
+
- `lifecycle/session_end_cleanup.py` runs on `SessionEnd`, matcher empty, timeout `3` seconds in `hooks.json`.
|
|
19
|
+
- `session/plugin_data_dir_cleanup.py` and `session/session_env_cleanup.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
|
|
20
|
+
- `session/session_edit_tracker_cleanup.py` runs on `SessionStart`, matcher empty, timeout `10` seconds and `SessionEnd`, matcher empty, timeout `3` seconds in `hooks.json`.
|
|
21
|
+
|
|
22
|
+
## Proving it
|
|
23
|
+
|
|
24
|
+
Preconditions:
|
|
25
|
+
|
|
26
|
+
- Run each command from the repository root with Python and pytest available. The tests and direct cache probe use disposable directories.
|
|
27
|
+
|
|
28
|
+
- **Nested checkout hooks.** Input is a session started above a child checkout with its own hooks. Run `python -m pytest packages/claude-dev-env/hooks/lifecycle/test_nested_project_hooks.py -q`. The adjacent test observes the child hook running in its checkout.
|
|
29
|
+
- **Worktree fetch.** Input is an `EnterWorktree` call without a path. Run `python -m pytest packages/claude-dev-env/hooks/lifecycle/test_enter_worktree_origin_prefetch.py -q`. The adjacent test observes a fetch attempt and a zero exit even when a remote is absent.
|
|
30
|
+
- **Session-end cache.** Input is an aged `claude-ctx-` cache file in a disposable directory. Run `python -c 'import os, runpy, tempfile; from pathlib import Path; directory=tempfile.TemporaryDirectory(); root=Path(directory.name); entry=root/"claude-ctx-old.json"; entry.write_text("{}"); os.utime(entry,(0,0)); module=runpy.run_path("packages/claude-dev-env/hooks/lifecycle/session_end_cleanup.py"); module["purge_old_entries"](str(root),7); assert not entry.exists(); directory.cleanup(); print("stale cache entry removed")'`. The command prints `stale cache entry removed`. This script has no adjacent behavior test.
|
|
31
|
+
- **Plugin data cleanup.** Input is an empty plugin data directory. Run `python -m pytest packages/claude-dev-env/hooks/session/test_plugin_data_dir_cleanup.py -q`. The adjacent test observes its removal and preserves directories with files.
|
|
32
|
+
- **Session environment cleanup.** Input is a current Windows session environment directory or an aged sibling. Run `python -m pytest packages/claude-dev-env/hooks/session/test_session_env_cleanup.py -q`. The adjacent test observes removal while recent siblings remain.
|
|
33
|
+
- **Edit tracker cleanup.** Input is a fresh SessionStart or SessionEnd payload. Run `python -m pytest packages/claude-dev-env/hooks/session/test_session_edit_tracker_cleanup.py -q`. The adjacent test observes removal for that session and preservation on resume.
|
|
34
|
+
|
|
35
|
+
## Gotchas
|
|
36
|
+
|
|
37
|
+
- The nested checkout runner forwards child denials and asks. It drops child allow decisions so a child cannot widen permission.
|
|
38
|
+
- Worktree prefetch applies to fresh creation and exits zero after a failed fetch.
|
|
39
|
+
- Session-end cleanup has no adjacent behavior test. Its direct probe calls `purge_old_entries` with an isolated directory.
|
|
40
|
+
- The session edit tracker survives a compact or resume. Cleanup targets the current session only.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Observability
|
|
2
|
+
|
|
3
|
+
This family records instruction loads and file edits for later checks. Delegation also clears the lead session's investigation timer so the agent can continue its investigation.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `observability/instructions_loaded_logger.py` writes selected instruction-load fields to a JSONL log.
|
|
8
|
+
- `observability/session_file_edit_tracker.py` records edited file paths in a per-session tracker for the stage gate.
|
|
9
|
+
- `workflow/investigation_tracker_reset.py` clears the investigation tracker after delegation.
|
|
10
|
+
|
|
11
|
+
## When it fires
|
|
12
|
+
|
|
13
|
+
- `observability/instructions_loaded_logger.py` runs on `InstructionsLoaded`, matcher `session_start|nested_traversal|path_glob_match|include|compact`, timeout `10` seconds in `hooks.json`.
|
|
14
|
+
- `observability/session_file_edit_tracker.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `30` seconds in `hooks.json`.
|
|
15
|
+
- `workflow/investigation_tracker_reset.py` runs on `PostToolUse`, matcher `Agent|Task|TeamCreate`, timeout `30` seconds in `hooks.json`.
|
|
16
|
+
|
|
17
|
+
## Proving it
|
|
18
|
+
|
|
19
|
+
Preconditions:
|
|
20
|
+
|
|
21
|
+
- Run each command from the repository root with Python and pytest available. The tests redirect log and tracker paths to disposable directories.
|
|
22
|
+
|
|
23
|
+
- **Instruction log.** Input is an `InstructionsLoaded` payload with a file path and load reason. Run `python -m pytest packages/claude-dev-env/hooks/observability/test_instructions_loaded_logger.py -q`. The adjacent test observes one JSONL record with the selected fields.
|
|
24
|
+
- **Edit tracker.** Input is a Write payload with a file path and session ID. Run `python -m pytest packages/claude-dev-env/hooks/observability/test_session_file_edit_tracker.py -q`. The adjacent test observes the resolved path in that session's tracker.
|
|
25
|
+
- **Investigation reset.** Input is a completed Agent, Task, or TeamCreate call. Run `python -m pytest packages/claude-dev-env/hooks/workflow/test_investigation_tracker_reset.py -q`. The adjacent test observes removal of the tracker file.
|
|
26
|
+
|
|
27
|
+
## Gotchas
|
|
28
|
+
|
|
29
|
+
- The instruction logger keeps only selected payload fields. It exits zero if logging fails.
|
|
30
|
+
- The edit tracker never blocks a tool call. Its per-session file is read later by the stage gate.
|
|
31
|
+
- The investigation reset applies to delegation tools. Its companion investigation gate lives outside this Claude hook registration map.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Post-write validation
|
|
2
|
+
|
|
3
|
+
This family checks a completed Write or Edit call and formats eligible new source files. The agent receives a combined block decision if a hosted check blocks.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `validation/post_tool_use_dispatcher.py` runs the post-write roster in order and combines any block reasons.
|
|
8
|
+
- `workflow/auto_formatter.py` formats eligible untracked source files after a Write and never blocks the write.
|
|
9
|
+
|
|
10
|
+
## When it fires
|
|
11
|
+
|
|
12
|
+
- `validation/post_tool_use_dispatcher.py` runs on `PostToolUse`, matcher `Write|Edit`, timeout `180` seconds in `hooks.json`.
|
|
13
|
+
- `workflow/auto_formatter.py` runs inside that dispatcher on `PostToolUse`, matcher `Write|Edit`, timeout `180` seconds, as named by `ALL_POST_HOSTED_HOOK_ENTRIES`. Its eligibility check accepts only a Write of an untracked source file.
|
|
14
|
+
|
|
15
|
+
## Proving it
|
|
16
|
+
|
|
17
|
+
Preconditions:
|
|
18
|
+
|
|
19
|
+
- Run each command from the repository root with Python and pytest available. The formatter tests make disposable repositories.
|
|
20
|
+
|
|
21
|
+
- **Post-write dispatch.** Input is an Edit of plain text. Run `python -m pytest packages/claude-dev-env/hooks/validation/test_post_tool_use_dispatcher.py -q`. The adjacent test observes an allow result and the hosted formatter's selection.
|
|
22
|
+
- **Formatter eligibility.** Input is a Write of an untracked source file. Run `python -m pytest packages/claude-dev-env/hooks/workflow/test_auto_formatter.py -q`. The adjacent test observes formatting for eligible writes and no edit of a tracked file.
|
|
23
|
+
|
|
24
|
+
## Gotchas
|
|
25
|
+
|
|
26
|
+
- The current post-write roster contains only `workflow/auto_formatter.py`. The dispatcher's docstring mentions other hosted work from an older roster, so use the roster for membership.
|
|
27
|
+
- The formatter can change the file after the original Write. A later hosted script would read the formatted file because the dispatcher preserves roster order.
|
|
28
|
+
- Formatter eligibility protects the hook tree and checks whether the file is tracked.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Session context
|
|
2
|
+
|
|
3
|
+
This family injects reminders and task guidance when an agent starts, resumes, submits a prompt, spawns a helper, or hits an auto mode denial. The startup scripts leave the session quiet when their opt-in conditions fail.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `session/skill_loaded_reminder.py` injects a poteto-mode reminder when the skill needs loading or reloading.
|
|
8
|
+
- `session/task_tool_prompt.py` injects a task tracking directive at session start.
|
|
9
|
+
- `session/working_style_prompt.py` injects the session's working style at session start.
|
|
10
|
+
- `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
|
|
11
|
+
- `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
|
|
12
|
+
- `session/issue_tracker_session_starter.py` injects issue tracker guidance when enabled for a registered repository.
|
|
13
|
+
- `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
|
|
14
|
+
|
|
15
|
+
## When it fires
|
|
16
|
+
|
|
17
|
+
- `session/skill_loaded_reminder.py` runs on `PreToolUse` matcher `Agent|Task` at `10` seconds, `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
|
|
18
|
+
- `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, `session/orchestrator_auto_starter.py`, and `session/issue_tracker_session_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
|
|
19
|
+
- `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
|
|
20
|
+
|
|
21
|
+
## Proving it
|
|
22
|
+
|
|
23
|
+
Preconditions:
|
|
24
|
+
|
|
25
|
+
- Run each command from the repository root with Python and pytest available. Enable opt-in flags only inside the tests.
|
|
26
|
+
|
|
27
|
+
- **Skill reminder.** Input is a helper spawn whose prompt lacks poteto-mode. Run `python -m pytest packages/claude-dev-env/hooks/session/test_skill_loaded_reminder.py -q`. The adjacent test observes the reminder and leaves an already prepared prompt unchanged.
|
|
28
|
+
- **Task tracking.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_task_tool_prompt.py -q`. The adjacent test observes a `SessionStart` context directive that names the task tool.
|
|
29
|
+
- **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
|
|
30
|
+
- **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
|
|
31
|
+
- **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
|
|
32
|
+
- **Issue tracker start.** Input is SessionStart with its opt-in flag and a registered checkout. Run `python -m pytest packages/claude-dev-env/hooks/session/test_issue_tracker_session_starter.py -q`. The adjacent test observes issue tracker context.
|
|
33
|
+
- **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
|
|
34
|
+
|
|
35
|
+
## Gotchas
|
|
36
|
+
|
|
37
|
+
- `session/skill_loaded_reminder.py` has four registration points. Its tests cover helper prompt rewriting and repeated prompt suppression.
|
|
38
|
+
- The advisor prompt depends on settings and an environment disable flag. The two starter scripts each use separate opt-in checks.
|
|
39
|
+
- The issue tracker starter requires the checkout to appear in the project path registry.
|
|
40
|
+
- `advisory/auto_mode_denial_quick_fix.py` lives under `advisory/` but belongs here because its only output is agent context and a one-line user message.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Spawn routing
|
|
2
|
+
|
|
3
|
+
This family reminds the agent to gather context before a spawn, adjusts thread requests when usage exceeds pace, and selects an allowed subagent model.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `routing/spawn_readiness_hook.py` adds context to a spawn that comes before a read step, or before an answered question or a settled-scope line. The spawn still runs.
|
|
8
|
+
- `routing/thread_spawn_pace_hook.py` reshapes a thread request when usage is over pace or unreadable.
|
|
9
|
+
- `routing/subagent_model_pin_hook.py` moves every Agent and Task subagent to Opus. A spawn that names Opus or Fable runs unchanged.
|
|
10
|
+
- `routing/subagent_model_routing.mjs` allows a selected Luna model, remaps eligible requests, and denies unsupported model routing.
|
|
11
|
+
|
|
12
|
+
## When it fires
|
|
13
|
+
|
|
14
|
+
- `routing/spawn_readiness_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, and `mcp__hearthbot__start_thread_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
|
|
15
|
+
- `routing/thread_spawn_pace_hook.py` runs on `PreToolUse`, matcher `mcp__hearthbot__start_thread_session`, timeout `30` seconds in `hooks.json`.
|
|
16
|
+
- `routing/subagent_model_pin_hook.py` runs on `PreToolUse`, matcher `Agent|Task`, timeout `10` seconds in `hooks.json`.
|
|
17
|
+
- `routing/subagent_model_routing.mjs` runs on `PreToolUse`, matcher `multi_agent_v1__spawn_agent`, timeout `10` seconds through the quoted Node command in `hooks.json`.
|
|
18
|
+
|
|
19
|
+
## Proving it
|
|
20
|
+
|
|
21
|
+
Preconditions:
|
|
22
|
+
|
|
23
|
+
- Run each command from the repository root. Python and pytest cover the two Python scripts; Node covers the model router.
|
|
24
|
+
|
|
25
|
+
- **Spawn readiness.** Input is a spawn after a read, a question, and its answer. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_readiness_hook.py -q`. The adjacent test observes no output for a ready spawn and reminder context for each missing step.
|
|
26
|
+
- **Thread pace.** Input is a thread spawn with usage over pace. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_thread_spawn_pace_hook.py -q`. The adjacent test observes updated tool input with the selected model, effort, and advisor line.
|
|
27
|
+
- **Model pin.** Input is an Agent spawn that names a Sonnet model. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_subagent_model_pin_hook.py -q`. The adjacent test observes updated tool input with model `opus`.
|
|
28
|
+
- **Model routing.** Input is a `multi_agent_v1__spawn_agent` payload with a model choice. Run `node --test packages/claude-dev-env/hooks/routing/subagent_model_routing.test.mjs`. The adjacent test observes an allow or deny decision with the routed model.
|
|
29
|
+
|
|
30
|
+
## Gotchas
|
|
31
|
+
|
|
32
|
+
- `routing/spawn_readiness_hook.py` also runs after the pace hook on thread spawns. A reshaped request still gets the readiness check.
|
|
33
|
+
- The readiness hook emits `additionalContext` only, so it never blocks. It passes subagent calls, read-only helper types, a non-object tool input, and unreadable transcripts.
|
|
34
|
+
- The model router allows an advisor flag to bypass the Luna restriction. Its ordinary path denies unresolved or unsupported models.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Write and Edit blocking
|
|
2
|
+
|
|
3
|
+
This family decides whether an agent's file mutation can run. It also warns about broad refactors and unsafe migration edits before the agent changes a file.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `blocking/pre_tool_use_dispatcher.py` combines hosted advisory output with its native description check and returns one permission decision.
|
|
8
|
+
- `advisory/refactor_guard.py` warns when an Edit or MultiEdit appears to rename code beyond the current change.
|
|
9
|
+
- `advisory/migration_safety_advisor.py` warns when an Edit or MultiEdit adds an unsafe Django migration operation.
|
|
10
|
+
- `blocking/state_description_blocker.py` rejects historical or comparative prose in supported comments, docstrings, and Markdown.
|
|
11
|
+
|
|
12
|
+
## When it fires
|
|
13
|
+
|
|
14
|
+
- `blocking/pre_tool_use_dispatcher.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds in `hooks.json`.
|
|
15
|
+
- `advisory/refactor_guard.py` and `advisory/migration_safety_advisor.py` run inside that dispatcher on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. `ALL_HOSTED_HOOK_ENTRIES` selects `Edit` and `MultiEdit` for each.
|
|
16
|
+
- `blocking/state_description_blocker.py` runs through the dispatcher's native evaluator on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. Its evaluator checks `Write`, `Edit`, and `MultiEdit` payloads and skips `apply_patch`.
|
|
17
|
+
|
|
18
|
+
## Proving it
|
|
19
|
+
|
|
20
|
+
Preconditions:
|
|
21
|
+
|
|
22
|
+
- Run each command from the repository root with Python and pytest available. The tests use disposable paths where they need files.
|
|
23
|
+
|
|
24
|
+
- **Dispatcher decision.** Input is a clean Write payload. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py -q`. The adjacent test checks that the dispatcher allows clean writes and carries denials from its checks.
|
|
25
|
+
- **Refactor warning.** Input is a MultiEdit whose second edit renames a function. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_refactor_guard.py -q`. The adjacent test reports both function names in the warning.
|
|
26
|
+
- **Migration warning.** Input is an Edit containing `migrations.RemoveField`. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_migration_safety_advisor.py -q`. The adjacent test observes an allow decision with a migration warning.
|
|
27
|
+
- **Description gate.** Input is a Write containing historical prose in a Python comment. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_state_description_blocker.py -q`. The adjacent test observes a denial reason.
|
|
28
|
+
|
|
29
|
+
## Gotchas
|
|
30
|
+
|
|
31
|
+
- `ALL_HOSTED_HOOK_ENTRIES` lists only the two nonblocking advisors. The dispatcher calls the description evaluator through its native hook table.
|
|
32
|
+
- The dispatcher runs hosted entries in roster order. A hosted advisory crash stays silent; a native blocking check can deny the write.
|
|
33
|
+
- `scripts/policy_lint/adapter_detectors.py` also loads `blocking/state_description_blocker.py`. Check that adapter when changing its evaluator.
|
|
34
|
+
- `bin/install.mjs` names folded and retired script paths so reinstall can remove stale registrations.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Keep the hook feature map aligned with live registrations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent
|
|
10
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
12
|
+
|
|
13
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
14
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
|
|
15
|
+
)
|
|
16
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import ALL_BASH_HOSTED_HOOK_ENTRIES
|
|
17
|
+
from hooks_constants.post_tool_use_dispatcher_constants import ALL_POST_HOSTED_HOOK_ENTRIES
|
|
18
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ALL_HOSTED_HOOK_ENTRIES
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
FEATURES_DIRECTORY = HOOKS_DIRECTORY / "features"
|
|
22
|
+
SCRIPT_PATTERN = re.compile(r"\$\{CLAUDE_PLUGIN_ROOT\}/hooks/([^\s\"]+)")
|
|
23
|
+
CHECK_PATTERN = re.compile(r"^- `([^`]+)`", re.MULTILINE)
|
|
24
|
+
FAMILY_LINK_PATTERN = re.compile(r"\]\(\./([a-z0-9-]+\.md)\)")
|
|
25
|
+
SECTION_PATTERN = re.compile(r"^## (.+)$", re.MULTILINE)
|
|
26
|
+
EXPECTED_SECTIONS = ["Checks", "When it fires", "Proving it", "Gotchas"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def registered_hook_commands() -> list[str]:
|
|
30
|
+
"""Return every command string registered in hooks.json."""
|
|
31
|
+
registrations = json.loads((HOOKS_DIRECTORY / "hooks.json").read_text(encoding="utf-8"))
|
|
32
|
+
return [
|
|
33
|
+
hook["command"]
|
|
34
|
+
for all_groups in registrations["hooks"].values()
|
|
35
|
+
for group in all_groups
|
|
36
|
+
for hook in group["hooks"]
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def registered_script_paths() -> set[str]:
|
|
41
|
+
"""Return paths named by the hook registration and hosted rosters."""
|
|
42
|
+
script_paths = {"blocking/state_description_blocker.py"}
|
|
43
|
+
for command in registered_hook_commands():
|
|
44
|
+
matching_paths = SCRIPT_PATTERN.findall(command)
|
|
45
|
+
assert matching_paths, f"Hook command has no hooks/ script: {command}"
|
|
46
|
+
script_paths.update(matching_paths)
|
|
47
|
+
|
|
48
|
+
for roster in (
|
|
49
|
+
ALL_HOSTED_HOOK_ENTRIES,
|
|
50
|
+
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
51
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
|
|
52
|
+
ALL_POST_HOSTED_HOOK_ENTRIES,
|
|
53
|
+
):
|
|
54
|
+
script_paths.update(entry.script_relative_path for entry in roster)
|
|
55
|
+
return script_paths
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def family_paths() -> list[Path]:
|
|
59
|
+
"""Return the Markdown pages that hold script checks."""
|
|
60
|
+
return sorted(path for path in FEATURES_DIRECTORY.glob("*.md") if path.name != "README.md")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_paths(family_path: Path) -> list[str]:
|
|
64
|
+
"""Read script paths from one family's Checks section."""
|
|
65
|
+
page = family_path.read_text(encoding="utf-8")
|
|
66
|
+
checks = page.split("## Checks\n", 1)
|
|
67
|
+
assert len(checks) == 2, f"Missing Checks section in {family_path.name}"
|
|
68
|
+
checks_section = checks[1].split("\n## ", 1)[0]
|
|
69
|
+
return CHECK_PATTERN.findall(checks_section)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_every_registered_hook_appears_once() -> None:
|
|
73
|
+
mapped_paths = Counter(path for family_path in family_paths() for path in check_paths(family_path))
|
|
74
|
+
registered_paths = registered_script_paths()
|
|
75
|
+
for script_path in sorted(registered_paths):
|
|
76
|
+
assert mapped_paths[script_path] == 1, (
|
|
77
|
+
f"Registered script {script_path} appears {mapped_paths[script_path]} times in Checks"
|
|
78
|
+
)
|
|
79
|
+
for script_path in sorted(mapped_paths):
|
|
80
|
+
assert script_path in registered_paths, f"Unregistered script {script_path} appears in Checks"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_every_check_names_an_existing_hook() -> None:
|
|
84
|
+
hooks_root = HOOKS_DIRECTORY.resolve()
|
|
85
|
+
for family_path in family_paths():
|
|
86
|
+
for script_path in check_paths(family_path):
|
|
87
|
+
candidate = (hooks_root / script_path).resolve()
|
|
88
|
+
assert candidate.is_relative_to(hooks_root), f"Script escapes hooks/: {script_path}"
|
|
89
|
+
assert candidate.is_file(), f"Script does not exist under hooks/: {script_path}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_readme_lists_every_family_once() -> None:
|
|
93
|
+
readme = (FEATURES_DIRECTORY / "README.md").read_text(encoding="utf-8")
|
|
94
|
+
families = readme.split("## Families\n", 1)
|
|
95
|
+
assert len(families) == 2, "README.md is missing the Families section"
|
|
96
|
+
listed_names = FAMILY_LINK_PATTERN.findall(families[1])
|
|
97
|
+
listed_counts = Counter(listed_names)
|
|
98
|
+
existing_names = {path.name for path in family_paths()}
|
|
99
|
+
for family_name in sorted(existing_names | set(listed_names)):
|
|
100
|
+
assert listed_counts[family_name] == 1, (
|
|
101
|
+
f"Family {family_name} appears {listed_counts[family_name]} times in README.md"
|
|
102
|
+
)
|
|
103
|
+
assert family_name in existing_names, f"README.md names missing family {family_name}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_every_family_has_four_sections_in_order() -> None:
|
|
107
|
+
for family_path in family_paths():
|
|
108
|
+
headings = SECTION_PATTERN.findall(family_path.read_text(encoding="utf-8"))
|
|
109
|
+
assert headings == EXPECTED_SECTIONS, f"{family_path.name} has sections {headings}"
|