capt-hook 4.3.1__tar.gz → 4.4.0__tar.gz

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 (104) hide show
  1. {capt_hook-4.3.1 → capt_hook-4.4.0}/PKG-INFO +1 -1
  2. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/__init__.py +3 -0
  3. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/conditions.py +38 -1
  4. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/general/capt-hook.toml +1 -1
  5. capt_hook-4.4.0/captain_hook/packs/general/models.py +67 -0
  6. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/primitives/__init__.py +1 -1
  7. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/primitives/rewrite.py +38 -1
  8. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +6 -3
  9. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/testing/helpers.py +16 -2
  10. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/testing/types.py +7 -1
  11. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/types.py +42 -0
  12. {capt_hook-4.3.1 → capt_hook-4.4.0}/pyproject.toml +1 -1
  13. {capt_hook-4.3.1 → capt_hook-4.4.0}/LICENSE +0 -0
  14. {capt_hook-4.3.1 → capt_hook-4.4.0}/README.md +0 -0
  15. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  16. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/__main__.py +0 -0
  17. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/app.py +0 -0
  18. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/ast_grep.py +0 -0
  19. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/classifiers/__init__.py +0 -0
  20. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/classifiers/conductor.py +0 -0
  21. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/classifiers/droid.py +0 -0
  22. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/classifiers/native.py +0 -0
  23. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/cli.py +0 -0
  24. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/command.py +0 -0
  25. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/context.py +0 -0
  26. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/decisions.py +0 -0
  27. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/dispatch.py +0 -0
  28. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/durable.py +0 -0
  29. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/events.py +0 -0
  30. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/fields.py +0 -0
  31. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/file.py +0 -0
  32. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/loader.py +0 -0
  33. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/log.py +0 -0
  34. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/__init__.py +0 -0
  35. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/general/__init__.py +0 -0
  36. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/general/commands.py +0 -0
  37. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/general/docs.py +0 -0
  38. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/general/plans.py +0 -0
  39. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/general/prompts.py +0 -0
  40. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/general/review.py +0 -0
  41. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/general/tasks.py +0 -0
  42. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/go/__init__.py +0 -0
  43. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  44. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/go/testing.py +0 -0
  45. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/go/toolchain.py +0 -0
  46. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/manager.py +0 -0
  47. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/python/__init__.py +0 -0
  48. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  49. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/python/style.py +0 -0
  50. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/python/testing.py +0 -0
  51. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/python/toolchain.py +0 -0
  52. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/steering/__init__.py +0 -0
  53. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  54. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/packs/steering/steering.py +0 -0
  55. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/primitives/commands.py +0 -0
  56. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/primitives/lint.py +0 -0
  57. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/primitives/llm.py +0 -0
  58. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/primitives/nudge.py +0 -0
  59. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/primitives/workflow.py +0 -0
  60. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/prompt.py +0 -0
  61. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/py.typed +0 -0
  62. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/__init__.py +0 -0
  63. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/cli.py +0 -0
  64. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/dashboard.py +0 -0
  65. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/fix.py +0 -0
  66. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/formats.py +0 -0
  67. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/judge.py +0 -0
  68. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/pipeline.py +0 -0
  69. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/repo.py +0 -0
  70. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/scan.py +0 -0
  71. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/settings.py +0 -0
  72. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/store.py +0 -0
  73. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/review/sync.py +0 -0
  74. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/session.py +0 -0
  75. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/settings.py +0 -0
  76. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/signals/__init__.py +0 -0
  77. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/signals/nlp.py +0 -0
  78. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  79. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  80. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  81. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  82. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  83. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  84. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  85. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  86. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  87. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  88. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  89. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  90. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/state.py +0 -0
  91. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/style/__init__.py +0 -0
  92. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/style/ast_grep.py +0 -0
  93. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/style/matchers.py +0 -0
  94. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/style/scope.py +0 -0
  95. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/style/types.py +0 -0
  96. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/tasks.py +0 -0
  97. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  98. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/testing/__init__.py +0 -0
  99. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/testing/session_cache.py +0 -0
  100. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/util/__init__.py +0 -0
  101. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/util/fs.py +0 -0
  102. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/util/http.py +0 -0
  103. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/util/model_cache.py +0 -0
  104. {capt_hook-4.3.1 → capt_hook-4.4.0}/captain_hook/util/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 4.3.1
3
+ Version: 4.4.0
4
4
  Summary: Declarative hook framework for Claude Code
5
5
  Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
6
  Author: Yasyf Mohamedali
@@ -52,6 +52,7 @@ from captain_hook.primitives import (
52
52
  prompt_check,
53
53
  rewrite_code,
54
54
  rewrite_command,
55
+ set_tool_input,
55
56
  warn_command,
56
57
  )
57
58
 
@@ -90,8 +91,10 @@ from captain_hook.types import (
90
91
  TCondition,
91
92
  TestFile,
92
93
  Tool,
94
+ ToolInput,
93
95
  TouchedFile,
94
96
  UsedSkill,
95
97
  Waiting,
98
+ WorkflowScript,
96
99
  )
97
100
  from captain_hook.util import read_json, resolve_binary
@@ -3,10 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ from pathlib import Path
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from cc_transcript.models import OtherEvent
9
- from cc_transcript.tools import tool_name_matches
10
+ from cc_transcript.tools import WorkflowCall, tool_name_matches
10
11
 
11
12
  from captain_hook.types import (
12
13
  Agent,
@@ -23,9 +24,11 @@ from captain_hook.types import (
23
24
  TCondition,
24
25
  TestFile,
25
26
  Tool,
27
+ ToolInput,
26
28
  TouchedFile,
27
29
  UsedSkill,
28
30
  Waiting,
31
+ WorkflowScript,
29
32
  )
30
33
 
31
34
  if TYPE_CHECKING:
@@ -80,6 +83,32 @@ def is_waiting(evt: BaseHookEvent) -> bool:
80
83
  )
81
84
 
82
85
 
86
+ def workflow_script_source(evt: BaseHookEvent) -> str | None:
87
+ if not (call := evt.as_input(WorkflowCall)):
88
+ return None
89
+ if call.script is not None:
90
+ return call.script
91
+ if not call.script_path:
92
+ return None
93
+ try:
94
+ path = Path(call.script_path)
95
+ if not path.is_file() or path.stat().st_size > 1_000_000:
96
+ return None
97
+ return path.read_text(errors="ignore")
98
+ except OSError:
99
+ return None
100
+
101
+
102
+ def matches_workflow_script(cond: WorkflowScript, evt: BaseHookEvent) -> bool:
103
+ if (source := workflow_script_source(evt)) is None:
104
+ return False
105
+ if cond.pattern is not None:
106
+ return bool(re.search(cond.pattern, source, re.MULTILINE))
107
+ return cond.model is not None and any(
108
+ re.search(cond.model, pinned) for pinned in re.findall(r"""model\s*:\s*['"]([^'"]*)['"]""", source)
109
+ )
110
+
111
+
83
112
  def is_project_file(evt: BaseHookEvent) -> bool:
84
113
  if not (file := evt.file):
85
114
  return False
@@ -104,6 +133,14 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
104
133
  and evt.content
105
134
  and re.search(pattern, evt.content, re.MULTILINE)
106
135
  )
136
+ case ToolInput(field, pattern):
137
+ return bool(
138
+ evt.tool_name
139
+ and isinstance(value := evt.input.raw.get(field), str)
140
+ and re.search(pattern, value, re.MULTILINE)
141
+ )
142
+ case WorkflowScript() as workflow_script:
143
+ return matches_workflow_script(workflow_script, evt)
107
144
  case Pattern(pattern, lang, project_only):
108
145
  from captain_hook.ast_grep import lang_for_path
109
146
  from captain_hook.ast_grep import matches as ast_grep_matches
@@ -1,4 +1,4 @@
1
1
  name = "general"
2
- version = "0.1.0"
2
+ version = "0.2.0"
3
3
  description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, and a code-review gate."
4
4
  hooks = "."
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import (
4
+ Agent,
5
+ Allow,
6
+ Block,
7
+ Event,
8
+ Input,
9
+ Rewrite,
10
+ Tool,
11
+ ToolInput,
12
+ Warn,
13
+ WorkflowScript,
14
+ hook,
15
+ nudge,
16
+ set_tool_input,
17
+ )
18
+
19
+ hook(
20
+ Event.PreToolUse,
21
+ only_if=[Tool("Agent|Task"), ToolInput("model", r"(?i)\bhaiku\b")],
22
+ skip_if=[ToolInput("prompt", r"(?i)\b(classif|label|tag|categoriz|per.file|one (fact|thing))")],
23
+ message=(
24
+ "This subagent is pinned to haiku. Route a subagent to haiku only for a single-fact "
25
+ "mechanical step — classifying, labeling, or tagging one thing per item. Anything that "
26
+ "carries judgment should run on sonnet; use model='sonnet', or drop model entirely to "
27
+ "inherit the session model. See CLAUDE.md § Plan Execution & Orchestration (Models)."
28
+ ),
29
+ block=True,
30
+ tests={
31
+ Input(model="haiku", content="implement the retry backoff in the client"): Block(),
32
+ Input(model="haiku", content="classify each file's language"): Allow(),
33
+ Input(content="implement the retry backoff in the client"): Allow(),
34
+ Input(model="sonnet", content="implement the retry backoff in the client"): Allow(),
35
+ },
36
+ )
37
+
38
+ set_tool_input(
39
+ "model",
40
+ "sonnet",
41
+ tool="Agent|Task",
42
+ only_if=[Agent("Explore|claude-code-guide")],
43
+ note=(
44
+ "Upgraded this recon subagent from the silent haiku default to sonnet, per the Models "
45
+ "table in CLAUDE.md § Plan Execution & Orchestration (Models)."
46
+ ),
47
+ tests={
48
+ Input(agent_type="Explore"): Rewrite(),
49
+ Input(agent_type="Explore", model="haiku"): Allow(),
50
+ Input(agent_type="general-purpose"): Allow(),
51
+ },
52
+ )
53
+
54
+ nudge(
55
+ """
56
+ This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
57
+ map steps; a judgment-bearing stage should inherit the session model or route up. See
58
+ CLAUDE.md § Plan Execution & Orchestration (Models).
59
+ """,
60
+ only_if=[Tool("Workflow"), WorkflowScript(model="haiku")],
61
+ events=Event.PreToolUse,
62
+ max_fires=2,
63
+ tests={
64
+ Input(tool="Workflow", tool_input={"script": "steps:\n - agent: reviewer\n model: 'haiku'\n"}): Warn(),
65
+ Input(tool="Workflow", tool_input={"script": "steps:\n - agent: reviewer\n model: 'sonnet'\n"}): Allow(),
66
+ },
67
+ )
@@ -12,4 +12,4 @@ from captain_hook.primitives.llm import (
12
12
  prompt_check,
13
13
  )
14
14
  from captain_hook.primitives.nudge import gate, nudge
15
- from captain_hook.primitives.rewrite import rewrite_code
15
+ from captain_hook.primitives.rewrite import rewrite_code, set_tool_input
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
3
+ from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from captain_hook import ast_grep
6
6
  from captain_hook.app import on
@@ -62,3 +62,40 @@ def rewrite_code(
62
62
 
63
63
  handler.__name__ = handler.__qualname__ = hook_name("rewrite_code", None, f"{pattern}=>{replace}")
64
64
  on(Event.PreToolUse, only_if=[Tool("Edit|Write|MultiEdit|NotebookEdit"), *only_if], tests=tests)(handler)
65
+
66
+
67
+ def set_tool_input(
68
+ field: str,
69
+ value: Any,
70
+ *,
71
+ tool: str,
72
+ only_if: Sequence[TCondition] = (),
73
+ skip_if: Sequence[TCondition] = (),
74
+ note: str | None = None,
75
+ tests: InlineTests | None = None,
76
+ ) -> None:
77
+ """Register a ``PreToolUse`` hook that fills a MISSING top-level input field with ``value``.
78
+
79
+ When ``field`` is absent from the matched tool's input, the input is rewritten to
80
+ ``{**raw, field: value}`` and allowed, with ``note`` surfaced as ``additionalContext``. A field
81
+ already present — even falsy — is left untouched, so an explicit choice is never clobbered.
82
+
83
+ Args:
84
+ field: The top-level tool-input key to fill.
85
+ value: The value to set when the field is absent.
86
+ tool: Tool-name pattern to gate on, e.g. ``"Agent|Task"``.
87
+ only_if: Extra conditions ANDed onto the built-in tool guard.
88
+ skip_if: Conditions that skip the hook when any matches.
89
+ note: Advisory context surfaced alongside the rewrite.
90
+ tests: Inline tests for the registered hook.
91
+
92
+ Example:
93
+ >>> set_tool_input("model", "sonnet", tool="Agent|Task", only_if=[Agent("Explore")], note="upgraded")
94
+ """
95
+
96
+ def handler(evt: PreToolUseEvent) -> HookResponse:
97
+ raw = evt.input.raw
98
+ return None if field in raw else evt.rewrite({**raw, field: value}, note=note)
99
+
100
+ handler.__name__ = handler.__qualname__ = hook_name("set_tool_input", None, f"{tool}:{field}")
101
+ on(Event.PreToolUse, only_if=[Tool(tool), *only_if], skip_if=skip_if, tests=tests)(handler)
@@ -20,10 +20,10 @@ from __future__ import annotations
20
20
 
21
21
  from captain_hook import (
22
22
  Allow, BaseHookEvent, Block, Event, HookResult, InlineTests, Input, Prompt,
23
- RanCommand, Rewrite, Signal, Signals, SourceEdits, TestFile, Tool, TouchedFile,
24
- TranscriptFixture, Warn,
23
+ RanCommand, Rewrite, Signal, Signals, SourceEdits, TestFile, Tool, ToolInput, TouchedFile,
24
+ TranscriptFixture, Warn, WorkflowScript,
25
25
  block_command, gate, hook, lint, llm_gate, llm_nudge, nudge, on,
26
- prompt_check, rewrite_command, warn_command, workflow, Artifact, Step, text_matches,
26
+ prompt_check, rewrite_command, set_tool_input, warn_command, workflow, Artifact, Step, text_matches,
27
27
  )
28
28
  from captain_hook.types import Command
29
29
  ```
@@ -70,6 +70,7 @@ def handler(evt: BaseHookEvent) -> HookResult | None:
70
70
  | `block_command` | `(pattern, *, reason, hint=None, tests=None)` | `PreToolUse` + `Tool("Bash")`; message `"BLOCKED: {reason}. {hint}."` |
71
71
  | `warn_command` | `(pattern, *, message, tests=None, events=Event.PostToolUse)` | warns, never blocks |
72
72
  | `rewrite_command` | `(pattern, replace, *, note=None, tests=None)` | `PreToolUse` + `Tool("Bash")`; `re.sub(pattern, replace, command)` then allows with the rewritten command |
73
+ | `set_tool_input` | `(field, value, *, tool, only_if=(), skip_if=(), note=None, tests=None)` | `PreToolUse` + `Tool(tool)`; fills a **missing** top-level input field with `value` and allows, never clobbering a present one |
73
74
  | `gate` | `(message, *, when=None, only_if=(), skip_if=(), events=None, max_fires=None, tests=None)` | `Stop \| SubagentStop`, `max_fires=1`; blocks |
74
75
  | `nudge` | `(message, *, when=None, signals=None, only_if=(), skip_if=(), block=False, events=None, max_fires=None, tests=None)` | `PostToolUse` (with signals) else `PreToolUse`; `max_fires` 3 / 1; warns |
75
76
  | `lint` | `(check, *, message, trigger=None, sep=", ", block=False, events=None, tests=None, max_shown=5)` | `PostToolUse`, `Tool("Edit\|Write")` + `*.py`, skips test files |
@@ -128,6 +129,8 @@ evaluated first.
128
129
  | Filter by file path | `FilePath("*.py", "*.pyi")` |
129
130
  | Filter by bash command text | `Command(r"git\s+push")` — from `captain_hook.types` |
130
131
  | Filter by file content being written | `Content(r"print\(")` (multiline regex over Edit new / Write content) |
132
+ | Filter by one raw tool-input field | `ToolInput("model", r"(?i)\bhaiku\b")` (multiline regex over one top-level field of any tool) |
133
+ | Filter by a Workflow script | `WorkflowScript(model="haiku")` (inline `script`, or the `script_path` file's contents) |
131
134
  | Filter by subagent type | `Agent("cleanup")` |
132
135
  | Match only test files | `TestFile()` (`test_*.py`, `conftest.py`) |
133
136
  | Python source edits (skips tests by default) | `SourceEdits(lang="py")`; `lang` also `ts`, `go`, `rs`, ... |
@@ -105,7 +105,12 @@ def make_tool_input(
105
105
  old: str | None,
106
106
  offset: int | None = None,
107
107
  limit: int | None = None,
108
+ agent_type: str | None = None,
109
+ model: str | None = None,
110
+ tool_input: dict[str, Any] | None = None,
108
111
  ) -> dict[str, Any]:
112
+ if tool_input is not None:
113
+ return tool_input
109
114
  match tool:
110
115
  case "Bash" | "Execute":
111
116
  return omit_none({"command": command, "timeout": None, "description": None})
@@ -118,7 +123,7 @@ def make_tool_input(
118
123
  case "Read":
119
124
  return omit_none({"file_path": file or "", "limit": limit, "offset": offset})
120
125
  case "Agent" | "Task":
121
- return omit_none({"prompt": content or "", "subagent_type": None})
126
+ return omit_none({"prompt": content or "", "subagent_type": agent_type, "model": model})
122
127
  case _:
123
128
  return omit_none({"command": command, "file_path": file, "new_string": content, "old_string": old})
124
129
 
@@ -134,6 +139,8 @@ def mock_tool_event(
134
139
  offset: int | None = None,
135
140
  limit: int | None = None,
136
141
  agent_type: str | None = None,
142
+ model: str | None = None,
143
+ tool_input: dict[str, Any] | None = None,
137
144
  permission_mode: str | None = None,
138
145
  transcript: Session | None = None,
139
146
  transcript_path: str | Path | None = None,
@@ -143,7 +150,12 @@ def mock_tool_event(
143
150
  return cast(
144
151
  ToolHookEvent,
145
152
  event.event_class(
146
- _raw={"tool_name": tool, "tool_input": make_tool_input(tool, command, file, content, old, offset, limit)}
153
+ _raw={
154
+ "tool_name": tool,
155
+ "tool_input": make_tool_input(
156
+ tool, command, file, content, old, offset, limit, agent_type, model, tool_input
157
+ ),
158
+ }
147
159
  | ({"agent_type": agent_type} if agent_type else {})
148
160
  | ({"permission_mode": permission_mode} if permission_mode else {}),
149
161
  ctx=build_context(transcript, transcript_path, session_dir, project_root),
@@ -325,6 +337,8 @@ def input_to_event(
325
337
  offset=inp.offset,
326
338
  limit=inp.limit,
327
339
  agent_type=inp.agent_type,
340
+ model=inp.model,
341
+ tool_input=inp.tool_input,
328
342
  **ctx_kw,
329
343
  )
330
344
 
@@ -85,8 +85,12 @@ class Input:
85
85
  content: New file content for ``Edit``/``Write``.
86
86
  old: Pre-edit content for ``Edit`` and ``diff_lint``.
87
87
  tool: Tool name when no condition pins it.
88
+ tool_input: Verbatim raw tool-input mapping — an escape hatch that wins
89
+ over the input synthesized from the other fields.
88
90
  prompt: ``UserPromptSubmit`` text.
89
- agent_type: Subagent type for subagent events.
91
+ agent_type: Subagent type for subagent events (an Agent/Task call's
92
+ ``subagent_type``).
93
+ model: Model for an Agent/Task call's ``model`` input field.
90
94
  reason: ``SessionEnd`` reason.
91
95
  permission_mode: Permission mode, e.g. ``"plan"`` for plan-mode gating.
92
96
  offset: ``Read`` call offset.
@@ -101,8 +105,10 @@ class Input:
101
105
  content: str | None = None
102
106
  old: str | None = None
103
107
  tool: str | None = None
108
+ tool_input: dict[str, Any] | None = None
104
109
  prompt: str | None = None
105
110
  agent_type: str | None = None
111
+ model: str | None = None
106
112
  reason: str | None = None
107
113
  permission_mode: str | None = None
108
114
  offset: int | None = None
@@ -169,6 +169,46 @@ class Content:
169
169
  project_only: bool = True
170
170
 
171
171
 
172
+ @dataclass(frozen=True, slots=True)
173
+ class ToolInput:
174
+ """Condition matching one top-level field of the raw tool input against a regex.
175
+
176
+ Reads a single top-level key of the current event's tool input and searches its value with
177
+ ``re.MULTILINE``. Works for any tool; false for non-tool events, a missing field, or a
178
+ non-string value.
179
+
180
+ Example:
181
+ >>> hook(Event.PreToolUse, only_if=[ToolInput("model", r"(?i)\\bhaiku\\b")], message="...", block=True)
182
+ """
183
+
184
+ field: str
185
+ pattern: str
186
+
187
+
188
+ @dataclass(frozen=True, slots=True)
189
+ class WorkflowScript:
190
+ """Condition matching a ``Workflow`` tool's script source.
191
+
192
+ ``model`` matches the model names pinned in the script's ``agent()`` opts
193
+ (``model: 'haiku'`` and friends) — no hand-written quote-aware regex; ``pattern``
194
+ searches the raw script source (``re.MULTILINE``). Provide exactly one. The source is
195
+ the inline ``script``, or — when ``script`` is None and ``script_path`` points at a
196
+ file — that file's contents. A missing or unreadable path, or a file larger than
197
+ ~1 MiB, never matches and never raises. False for any non-Workflow tool.
198
+
199
+ Example:
200
+ >>> nudge("no haiku steps", only_if=[Tool("Workflow"), WorkflowScript(model="haiku")],
201
+ ... events=Event.PreToolUse)
202
+ """
203
+
204
+ pattern: str | None = None
205
+ model: str | None = None
206
+
207
+ def __post_init__(self) -> None:
208
+ if (self.pattern is None) == (self.model is None):
209
+ raise ValueError("WorkflowScript takes exactly one of pattern or model")
210
+
211
+
172
212
  @dataclass(frozen=True, slots=True)
173
213
  class Pattern:
174
214
  """Condition matching the edit's new content against an ast-grep **structural** pattern.
@@ -379,6 +419,8 @@ TCondition = (
379
419
  | FilePath
380
420
  | Command
381
421
  | Content
422
+ | ToolInput
423
+ | WorkflowScript
382
424
  | Pattern
383
425
  | Agent
384
426
  | UsedSkill
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "capt-hook"
3
3
  # Inert sentinel — the real version is stamped from the release tag (uv version --frozen).
4
- version = "4.3.1"
4
+ version = "4.4.0"
5
5
  description = "Declarative hook framework for Claude Code"
6
6
  readme = "README.md"
7
7
  license = "PolyForm-Noncommercial-1.0.0"
File without changes
File without changes
File without changes
File without changes
File without changes