capt-hook 4.3.1__tar.gz → 4.5.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.5.0}/PKG-INFO +1 -1
  2. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/__init__.py +3 -0
  3. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/conditions.py +38 -1
  4. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/capt-hook.toml +1 -1
  5. capt_hook-4.5.0/captain_hook/packs/general/models.py +67 -0
  6. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/__init__.py +1 -1
  7. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/rewrite.py +38 -1
  8. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/cli.py +2 -3
  9. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/dashboard.py +64 -8
  10. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/fix.py +18 -4
  11. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/pipeline.py +48 -5
  12. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/scan.py +10 -4
  13. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/store.py +80 -0
  14. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +6 -3
  15. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/state.py +8 -1
  16. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/testing/helpers.py +16 -2
  17. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/testing/types.py +7 -1
  18. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/types.py +42 -0
  19. {capt_hook-4.3.1 → capt_hook-4.5.0}/pyproject.toml +1 -1
  20. {capt_hook-4.3.1 → capt_hook-4.5.0}/LICENSE +0 -0
  21. {capt_hook-4.3.1 → capt_hook-4.5.0}/README.md +0 -0
  22. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  23. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/__main__.py +0 -0
  24. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/app.py +0 -0
  25. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/ast_grep.py +0 -0
  26. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/classifiers/__init__.py +0 -0
  27. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/classifiers/conductor.py +0 -0
  28. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/classifiers/droid.py +0 -0
  29. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/classifiers/native.py +0 -0
  30. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/cli.py +0 -0
  31. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/command.py +0 -0
  32. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/context.py +0 -0
  33. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/decisions.py +0 -0
  34. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/dispatch.py +0 -0
  35. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/durable.py +0 -0
  36. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/events.py +0 -0
  37. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/fields.py +0 -0
  38. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/file.py +0 -0
  39. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/loader.py +0 -0
  40. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/log.py +0 -0
  41. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/__init__.py +0 -0
  42. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/__init__.py +0 -0
  43. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/commands.py +0 -0
  44. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/docs.py +0 -0
  45. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/plans.py +0 -0
  46. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/prompts.py +0 -0
  47. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/review.py +0 -0
  48. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/tasks.py +0 -0
  49. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/go/__init__.py +0 -0
  50. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  51. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/go/testing.py +0 -0
  52. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/go/toolchain.py +0 -0
  53. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/manager.py +0 -0
  54. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/__init__.py +0 -0
  55. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  56. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/style.py +0 -0
  57. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/testing.py +0 -0
  58. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/toolchain.py +0 -0
  59. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/steering/__init__.py +0 -0
  60. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  61. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/steering/steering.py +0 -0
  62. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/commands.py +0 -0
  63. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/lint.py +0 -0
  64. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/llm.py +0 -0
  65. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/nudge.py +0 -0
  66. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/workflow.py +0 -0
  67. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/prompt.py +0 -0
  68. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/py.typed +0 -0
  69. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/__init__.py +0 -0
  70. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/formats.py +0 -0
  71. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/judge.py +0 -0
  72. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/repo.py +0 -0
  73. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/settings.py +0 -0
  74. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/sync.py +0 -0
  75. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/session.py +0 -0
  76. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/settings.py +0 -0
  77. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/signals/__init__.py +0 -0
  78. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/signals/nlp.py +0 -0
  79. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  80. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  81. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  82. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  83. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  84. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  85. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  86. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  87. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  88. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  89. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  90. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  91. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/__init__.py +0 -0
  92. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/ast_grep.py +0 -0
  93. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/matchers.py +0 -0
  94. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/scope.py +0 -0
  95. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/types.py +0 -0
  96. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/tasks.py +0 -0
  97. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  98. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/testing/__init__.py +0 -0
  99. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/testing/session_cache.py +0 -0
  100. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/__init__.py +0 -0
  101. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/fs.py +0 -0
  102. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/http.py +0 -0
  103. {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/model_cache.py +0 -0
  104. {capt_hook-4.3.1 → capt_hook-4.5.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.5.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)
@@ -124,10 +124,9 @@ def run_hook() -> None:
124
124
  @click.option("--cwd", "cwd", default=None, help="The session's working directory (default: the process cwd)")
125
125
  def spawn(transcript: Path, cwd: str | None) -> None:
126
126
  """Run the detached reviewer pass over one ended session (spawned by ``review run``)."""
127
- from captain_hook.review.pipeline import review_session
128
- from captain_hook.review.settings import ReviewSettings
127
+ from captain_hook.review.pipeline import spawn_session
129
128
 
130
- click.echo(asyncio.run(review_session(transcript, cwd=cwd or os.getcwd(), settings=ReviewSettings())))
129
+ click.echo(asyncio.run(spawn_session(transcript, cwd=cwd or os.getcwd())))
131
130
 
132
131
 
133
132
  @review.command()
@@ -12,7 +12,8 @@ background so the view appears instantly and updates when ``gh`` returns.
12
12
  from __future__ import annotations
13
13
 
14
14
  import asyncio
15
- from datetime import UTC, datetime
15
+ import json
16
+ from datetime import UTC, datetime, timedelta
16
17
  from enum import StrEnum
17
18
  from typing import TYPE_CHECKING
18
19
 
@@ -20,6 +21,7 @@ from rich.console import Console, Group
20
21
  from rich.spinner import Spinner
21
22
  from rich.text import Text
22
23
 
24
+ from captain_hook.review.pipeline import review_log_path
23
25
  from captain_hook.review.store import CandidateKind, CandidateStatus
24
26
 
25
27
  if TYPE_CHECKING:
@@ -27,12 +29,13 @@ if TYPE_CHECKING:
27
29
 
28
30
  from captain_hook.review.repo import RepoKey
29
31
  from captain_hook.review.settings import ReviewSettings
30
- from captain_hook.review.store import CandidateView
32
+ from captain_hook.review.store import CandidateView, SpawnHealth
31
33
 
32
34
  BAR_FILLED = "█"
33
35
  BAR_EMPTY = "░"
34
36
  DETAIL_WIDTH = 80
35
37
  KIND_STYLE = {CandidateKind.CREATE: "cyan", CandidateKind.FIX: "magenta"}
38
+ SPAWN_STALE_AFTER = timedelta(days=7)
36
39
 
37
40
 
38
41
  class Stage(StrEnum):
@@ -151,6 +154,49 @@ def candidate_block(view: CandidateView, settings: ReviewSettings) -> Renderable
151
154
  )
152
155
 
153
156
 
157
+ def relative(stamp: str) -> str:
158
+ minutes = int((datetime.now(UTC) - datetime.fromisoformat(stamp)).total_seconds()) // 60
159
+ match minutes:
160
+ case m if m < 60:
161
+ return f"{m}m ago"
162
+ case m if m < 1440:
163
+ return f"{m // 60}h ago"
164
+ case m:
165
+ return f"{m // 1440}d ago"
166
+
167
+
168
+ def spawn_stale(stamp: str) -> bool:
169
+ return datetime.now(UTC) - datetime.fromisoformat(stamp) > SPAWN_STALE_AFTER
170
+
171
+
172
+ def health_line(health: SpawnHealth) -> RenderableType:
173
+ match health.last:
174
+ case None:
175
+ return Text("reviewer has never run — check the SessionEnd hook wiring", style="yellow")
176
+ case {"ok": 0, "error": error}:
177
+ return Group(
178
+ Text.assemble(
179
+ ("REVIEWER FAILING", "bold red"),
180
+ (
181
+ f" {health.consecutive_failures} consecutive since {health.failing_since}"
182
+ f" · {trim(str(error))}",
183
+ "red",
184
+ ),
185
+ ),
186
+ Text(f" see {review_log_path()}", style="dim"),
187
+ )
188
+ case {"finished_at": finished} if spawn_stale(str(finished)):
189
+ return Text(
190
+ f"reviewer last ran {relative(str(finished))} — check the SessionEnd hook wiring", style="yellow"
191
+ )
192
+ case last:
193
+ return Text(
194
+ f"reviewer ok · last run {relative(str(last['finished_at']))}"
195
+ f" · judged {json.loads(str(last['report_json']))['judged']}",
196
+ style="dim",
197
+ )
198
+
199
+
154
200
  def header(repo: RepoKey, views: list[CandidateView], settings: ReviewSettings, *, watching: bool) -> RenderableType:
155
201
  open_n = sum(1 for v in views if stage_of(v) is Stage.PR_OPEN)
156
202
  line = Text.assemble(
@@ -168,9 +214,15 @@ def header(repo: RepoKey, views: list[CandidateView], settings: ReviewSettings,
168
214
 
169
215
 
170
216
  def render(
171
- views: list[CandidateView], *, repo: RepoKey, settings: ReviewSettings, watching: bool, syncing: bool = False
217
+ views: list[CandidateView],
218
+ *,
219
+ repo: RepoKey,
220
+ settings: ReviewSettings,
221
+ watching: bool,
222
+ health: SpawnHealth,
223
+ syncing: bool = False,
172
224
  ) -> RenderableType:
173
- """The whole dashboard frame: header, then a section per non-empty lifecycle stage."""
225
+ """The whole dashboard frame: reviewer health, header, then a section per non-empty lifecycle stage."""
174
226
  sections = [
175
227
  block
176
228
  for stage, title, desc, style in SECTIONS
@@ -183,7 +235,9 @@ def render(
183
235
  ]
184
236
  empty = [] if views else [Text("No corrections tracked yet — they appear here as you correct Claude.", style="dim")]
185
237
  spinner = [Spinner("dots", text=Text("syncing open PRs with GitHub…", style="dim"))] if syncing else []
186
- return Group(header(repo, views, settings, watching=watching), Text(""), *sections, *empty, *spinner)
238
+ return Group(
239
+ health_line(health), header(repo, views, settings, watching=watching), Text(""), *sections, *empty, *spinner
240
+ )
187
241
 
188
242
 
189
243
  async def run_status(repo: RepoKey, *, sync: bool) -> None:
@@ -198,17 +252,19 @@ async def run_status(repo: RepoKey, *, sync: bool) -> None:
198
252
  settings = ReviewSettings()
199
253
  console = Console()
200
254
  async with await ReviewStore.open(settings.db_path) as store:
255
+ health = await store.spawn_health()
201
256
  watching = await store.watching(repo)
202
257
  views = await store.overview(repo, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)
203
258
  if not (sync and any(stage_of(v) is Stage.PR_OPEN for v in views)):
204
- console.print(render(views, repo=repo, settings=settings, watching=watching))
259
+ console.print(render(views, repo=repo, settings=settings, watching=watching, health=health))
205
260
  return
206
261
  with Live(
207
- render(views, repo=repo, settings=settings, watching=watching, syncing=True), console=console
262
+ render(views, repo=repo, settings=settings, watching=watching, health=health, syncing=True),
263
+ console=console,
208
264
  ) as live:
209
265
  await sync_open_prs(store, repo, settings=settings)
210
266
  fresh = await store.overview(repo, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)
211
- live.update(render(fresh, repo=repo, settings=settings, watching=watching))
267
+ live.update(render(fresh, repo=repo, settings=settings, watching=watching, health=health))
212
268
 
213
269
 
214
270
  def status_command(repo: RepoKey, *, sync: bool) -> None:
@@ -25,9 +25,14 @@ shapes (Stop feedback, blocking errors) fall back to
25
25
  :meth:`~cc_transcript.decisions.DecisionLog.attribute_nearest` with the
26
26
  decision's recorded message as the tiebreak — the only place message-substring
27
27
  matching survives. The PR target resolves primitive-aware: a ``nudge()``/``gate()``
28
- fire records the primitive file as its ``source_file``, so the real user hook
29
- comes from the decision ``kind``'s module prefix. Unattributable or unresolvable
30
- complaints are dropped precision over recall.
28
+ fire records the primitive file as its ``source_file``, so the real hook comes
29
+ from the decision ``kind``'s module prefix a ``<pack>.<module>`` prefix naming
30
+ a module the installed builtin pack actually ships targets the pack source inside
31
+ captain-hook itself (``captain_hook/packs/<pack>/<module>.py``), while any other
32
+ module prefix — including a packaged user hook whose package merely shares a
33
+ builtin pack's name — targets the repo-local ``.claude/hooks/<module>.py``.
34
+ Unattributable or unresolvable complaints (including legacy kinds whose prefix is
35
+ not a module path, e.g. ``<frozen importlib``) are dropped — precision over recall.
31
36
  """
32
37
 
33
38
  from __future__ import annotations
@@ -59,6 +64,7 @@ TIGHT_PROXIMITY_TURNS = 1
59
64
  STOP_FEEDBACK_PREFIX = "Stop hook feedback:\n"
60
65
  PRIMITIVES_DIR = "captain_hook/primitives/"
61
66
  HOOKS_DIR = ".claude/hooks"
67
+ PACKS_DIR = "captain_hook/packs"
62
68
 
63
69
  HOOK_VOCAB_RE = re.compile(r"\b(?:hook|reminder|nudge|gate|guard)s?\b", re.IGNORECASE)
64
70
 
@@ -199,12 +205,20 @@ def attribute_fingerprint(
199
205
 
200
206
 
201
207
  def resolve_target(decision: Decision) -> tuple[str, str] | None:
208
+ from captain_hook.packs.manager import builtin_packs
209
+
202
210
  if PRIMITIVES_DIR not in decision.source_file:
203
211
  return decision.source_file, decision.kind
204
212
  module, sep, _ = decision.kind.partition(":")
205
213
  if not sep or not module:
206
214
  return None
207
- return f"{HOOKS_DIR}/{module.rsplit('.', 1)[-1]}.py", decision.kind
215
+ match module.split("."):
216
+ case [pack, mod] if (pack_dir := builtin_packs().get(pack)) and (pack_dir / f"{mod}.py").is_file():
217
+ return f"{PACKS_DIR}/{pack}/{mod}.py", decision.kind
218
+ case parts if all(part.isidentifier() for part in parts):
219
+ return f"{HOOKS_DIR}/{parts[-1]}.py", decision.kind
220
+ case _:
221
+ return None
208
222
 
209
223
 
210
224
  def complaint_signal(marker: Marker, turns_back: int) -> CandidateSignal:
@@ -5,10 +5,11 @@ path — a non-zero or hanging hook wedges the user's session — so
5
5
  :func:`guard_and_spawn` does nothing but parse, guard, and detach
6
6
  ``capt-hook review spawn`` via ``Popen(start_new_session=True)``; no database
7
7
  reads, no scanning, no ``gh``, and no heavy imports happen on that path. The
8
- detached child (:func:`review_session`) does the real work: resolve the repo,
9
- check it is watched, scan the transcript incrementally, run the judge pass,
10
- sync open PR states, and — when at least one candidate crosses its
11
- thresholds — spawn the headless brain that drafts the PRs.
8
+ detached child (:func:`spawn_session`, wrapping :func:`review_session`) does
9
+ the real work: resolve the repo, check it is watched, scan the transcript
10
+ incrementally, run the judge pass, sync open PR states, and — when at least
11
+ one candidate crosses its thresholds — spawn the headless brain that drafts
12
+ the PRs, recording each run's outcome for the status dashboard's health line.
12
13
  """
13
14
 
14
15
  from __future__ import annotations
@@ -17,7 +18,8 @@ import json
17
18
  import os
18
19
  import subprocess
19
20
  import sys
20
- from dataclasses import dataclass
21
+ from dataclasses import asdict, dataclass
22
+ from datetime import UTC, datetime
21
23
  from pathlib import Path
22
24
  from typing import TYPE_CHECKING, TypeGuard
23
25
 
@@ -247,3 +249,44 @@ async def review_session(transcript: Path, *, cwd: str, settings: ReviewSettings
247
249
  eligible=eligible,
248
250
  brain=bool(eligible),
249
251
  )
252
+
253
+
254
+ async def spawn_session(transcript: Path, *, cwd: str, settings: ReviewSettings | None = None) -> SpawnReport:
255
+ """Runs :func:`review_session` and records its outcome — the ``review spawn`` entry.
256
+
257
+ Wraps the whole reviewer pass — settings construction, repo resolve, and
258
+ store open included (the historical crash sites were inside them) — and
259
+ records exactly one ``spawn_runs`` row per run into a freshly opened store,
260
+ so ``capt-hook status`` surfaces a silently crashing reviewer. A crash
261
+ records ``ok=0`` and re-raises, so the traceback still lands in the spawn
262
+ log; the catch is ``BaseException`` because anyio cancellation shapes are
263
+ not ``Exception``. When settings construction itself is the crash, the row
264
+ lands at the default db path.
265
+
266
+ Args:
267
+ transcript: The ended session's transcript file.
268
+ cwd: The session's working directory, used to resolve the repo.
269
+ settings: The reviewer settings; constructed inside the recording
270
+ boundary when omitted, so a settings/env failure still records.
271
+
272
+ Returns:
273
+ The recorded :class:`SpawnReport` for this pass.
274
+ """
275
+ from captain_hook.review.settings import ReviewSettings, resolve_review_db_path
276
+ from captain_hook.review.store import ReviewStore
277
+
278
+ started = datetime.now(UTC)
279
+ try:
280
+ settings = settings or ReviewSettings()
281
+ report = await review_session(transcript, cwd=cwd, settings=settings)
282
+ except BaseException as exc:
283
+ async with await ReviewStore.open(settings.db_path if settings else resolve_review_db_path()) as store:
284
+ await store.record_spawn_run(
285
+ str(transcript), started_at=started, ok=False, error=f"{type(exc).__name__}: {exc}"
286
+ )
287
+ raise
288
+ async with await ReviewStore.open(settings.db_path) as store:
289
+ await store.record_spawn_run(
290
+ str(transcript), started_at=started, ok=True, report_json=json.dumps(asdict(report))
291
+ )
292
+ return report
@@ -64,9 +64,9 @@ from cc_transcript.models import UserEvent
64
64
  from cc_transcript.parser import TranscriptParser
65
65
 
66
66
  from captain_hook.decisions import decisions_db_path, open_decision_log
67
- from captain_hook.review.fix import HOOK_COMPLAINT, iter_hook_complaint_signals
67
+ from captain_hook.review.fix import HOOK_COMPLAINT, PACKS_DIR, iter_hook_complaint_signals
68
68
  from captain_hook.review.formats import review_spec
69
- from captain_hook.review.repo import resolve_repo_key
69
+ from captain_hook.review.repo import RepoKey, resolve_repo_key
70
70
  from captain_hook.review.store import CandidateKind
71
71
 
72
72
  if TYPE_CHECKING:
@@ -77,13 +77,15 @@ if TYPE_CHECKING:
77
77
  from cc_transcript.mining.signals import MiningSignal
78
78
  from cc_transcript.models import TranscriptEvent
79
79
 
80
- from captain_hook.review.repo import RepoKey
81
80
  from captain_hook.review.settings import ReviewSettings
82
81
  from captain_hook.review.store import ReviewStore
83
82
 
84
83
  REVIEWER_MARKER = "capt-hook-session-reviewer"
85
84
  """The token the reviewer's own headless sessions carry in their first user message."""
86
85
 
86
+ CAPTAIN_HOOK_REPO = RepoKey("github.com/yasyf/captain-hook")
87
+ """Captain-hook's own repo key: fix candidates targeting a builtin pack file open upstream here."""
88
+
87
89
  SPEC_DETECTORS = frozenset({"transcript_message", "plan_reentry", "review_comment"})
88
90
 
89
91
  REVIEWER_MINING_SPEC = MiningSpec(review=review_spec())
@@ -238,6 +240,10 @@ def transcript_repo(events: Sequence[TranscriptEvent]) -> RepoKey | None:
238
240
  )
239
241
 
240
242
 
243
+ def fix_repo(repo_key: RepoKey, target_source_file: str) -> RepoKey:
244
+ return CAPTAIN_HOOK_REPO if target_source_file.startswith(f"{PACKS_DIR}/") else repo_key
245
+
246
+
241
247
  def transcript_cwd(events: Sequence[TranscriptEvent]) -> Path | None:
242
248
  return next(
243
249
  (Path(meta.cwd) for event in events if (meta := event_meta(event)) is not None if meta.cwd is not None),
@@ -287,7 +293,7 @@ async def ingest(
287
293
  for sig, candidate in kept:
288
294
  candidate_id = (
289
295
  await store.ensure_candidate(
290
- repo_key,
296
+ fix_repo(repo_key, str(sig.evidence["target_source_file"])),
291
297
  kind=CandidateKind.FIX,
292
298
  rule=dedup_key(*rule_parts(sig)),
293
299
  source_kind=sig.kind,
@@ -75,6 +75,16 @@ CREATE TABLE IF NOT EXISTS repos (
75
75
  repo_key TEXT PRIMARY KEY,
76
76
  watching INTEGER NOT NULL
77
77
  );
78
+ CREATE TABLE IF NOT EXISTS spawn_runs (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ started_at TEXT NOT NULL,
81
+ finished_at TEXT NOT NULL,
82
+ transcript TEXT NOT NULL,
83
+ ok INTEGER NOT NULL,
84
+ error TEXT,
85
+ report_json TEXT,
86
+ CHECK ((ok = 1) = (error IS NULL))
87
+ );
78
88
  """
79
89
 
80
90
  INSERT_CANDIDATE = """
@@ -138,6 +148,21 @@ ORDER BY l.confidence DESC, o.id DESC
138
148
  LIMIT 1
139
149
  """
140
150
 
151
+ INSERT_SPAWN_RUN = """
152
+ INSERT INTO spawn_runs (started_at, finished_at, transcript, ok, error, report_json)
153
+ VALUES (?, ?, ?, ?, ?, ?)
154
+ """
155
+
156
+ SPAWN_STREAK_QUERY = """
157
+ WITH streak AS (
158
+ SELECT id, started_at FROM spawn_runs
159
+ WHERE id > COALESCE((SELECT MAX(id) FROM spawn_runs WHERE ok = 1), 0)
160
+ )
161
+ SELECT
162
+ (SELECT COUNT(*) FROM streak) AS consecutive_failures,
163
+ (SELECT started_at FROM streak ORDER BY id LIMIT 1) AS failing_since
164
+ """
165
+
141
166
 
142
167
  class InvalidTransition(Exception):
143
168
  """Raised when a candidate status move is outside :data:`TRANSITIONS`."""
@@ -241,6 +266,21 @@ class CandidateView:
241
266
  summary: str | None
242
267
 
243
268
 
269
+ @dataclass(frozen=True, slots=True)
270
+ class SpawnHealth:
271
+ """The detached reviewer's run health — what the status dashboard's top line reads.
272
+
273
+ Attributes:
274
+ last: The newest ``spawn_runs`` row, or ``None`` before the first recorded run.
275
+ consecutive_failures: How many runs have failed since the last success.
276
+ failing_since: The failing streak's first ``started_at``, or ``None`` while healthy.
277
+ """
278
+
279
+ last: dict[str, object] | None
280
+ consecutive_failures: int
281
+ failing_since: str | None
282
+
283
+
244
284
  class ReviewStore(VerdictStoreMixin, FeedbackStore):
245
285
  """The session reviewer's persistent store over a :class:`FileStateStore`.
246
286
 
@@ -560,3 +600,43 @@ class ReviewStore(VerdictStoreMixin, FeedbackStore):
560
600
  await self.candidate_view(row, settings=settings, prompt_version=prompt_version)
561
601
  for row in await self.candidates(repo)
562
602
  ]
603
+
604
+ async def record_spawn_run(
605
+ self,
606
+ transcript: str,
607
+ *,
608
+ started_at: datetime,
609
+ ok: bool,
610
+ error: str | None = None,
611
+ report_json: str | None = None,
612
+ ) -> None:
613
+ """Records one detached reviewer run's outcome — the only spawn-health write.
614
+
615
+ ``finished_at`` is stamped here, so the row's span is start-to-record.
616
+
617
+ Args:
618
+ transcript: The reviewed session's transcript path.
619
+ started_at: When the run started.
620
+ ok: Whether the run finished cleanly.
621
+ error: The crash's ``TypeName: message`` line (failed runs only).
622
+ report_json: The run's serialized ``SpawnReport`` (clean runs only).
623
+ """
624
+ await self.store.conn.execute(
625
+ INSERT_SPAWN_RUN,
626
+ (started_at.astimezone(UTC).isoformat(), now(), transcript, int(ok), error, report_json),
627
+ )
628
+
629
+ async def spawn_health(self) -> SpawnHealth:
630
+ """Returns the detached reviewer's run health — the only spawn-health read.
631
+
632
+ The failing streak is every run after the last clean one, so a single
633
+ success resets both ``consecutive_failures`` and ``failing_since``.
634
+ """
635
+ last_cur = await self.store.conn.execute("SELECT * FROM spawn_runs ORDER BY id DESC LIMIT 1")
636
+ streak_cur = await self.store.conn.execute(SPAWN_STREAK_QUERY)
637
+ streak = [dict(row) async for row in streak_cur][0]
638
+ return SpawnHealth(
639
+ last=rows[0] if (rows := [dict(row) async for row in last_cur]) else None,
640
+ consecutive_failures=int(streak["consecutive_failures"]),
641
+ failing_since=str(since) if (since := streak["failing_since"]) is not None else None,
642
+ )
@@ -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`, ... |
@@ -23,6 +23,7 @@ if TYPE_CHECKING:
23
23
  from captain_hook.types import Signals
24
24
 
25
25
  FRAMEWORK_DIR = str(Path(__file__).resolve().parent)
26
+ PACKS_DIR = str(Path(FRAMEWORK_DIR) / "packs")
26
27
  CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "captain-hook"
27
28
  SPACY_MODEL = "en_core_web_sm"
28
29
 
@@ -134,6 +135,8 @@ def text_hash(text: str) -> str:
134
135
 
135
136
 
136
137
  def package_aware_stem(p: Path) -> str:
138
+ if str(p).startswith(PACKS_DIR):
139
+ return f"{p.parent.name}.{p.stem}"
137
140
  if (
138
141
  p.name != "__init__.py"
139
142
  and not str(p).startswith(FRAMEWORK_DIR)
@@ -144,11 +147,15 @@ def package_aware_stem(p: Path) -> str:
144
147
  return p.stem
145
148
 
146
149
 
150
+ def framework_frame(filename: str) -> bool:
151
+ return filename.startswith(FRAMEWORK_DIR) and not filename.startswith(PACKS_DIR)
152
+
153
+
147
154
  def caller_stem() -> str:
148
155
  frame: FrameType | None = inspect.currentframe()
149
156
  if frame:
150
157
  frame = frame.f_back
151
- while frame and frame.f_code.co_filename.startswith(FRAMEWORK_DIR):
158
+ while frame and framework_frame(frame.f_code.co_filename):
152
159
  frame = frame.f_back
153
160
  return package_aware_stem(Path(frame.f_code.co_filename)) if frame else "unknown"
154
161
 
@@ -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.5.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