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.
- {capt_hook-4.3.1 → capt_hook-4.5.0}/PKG-INFO +1 -1
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/__init__.py +3 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/conditions.py +38 -1
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/capt-hook.toml +1 -1
- capt_hook-4.5.0/captain_hook/packs/general/models.py +67 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/__init__.py +1 -1
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/rewrite.py +38 -1
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/cli.py +2 -3
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/dashboard.py +64 -8
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/fix.py +18 -4
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/pipeline.py +48 -5
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/scan.py +10 -4
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/store.py +80 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +6 -3
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/state.py +8 -1
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/testing/helpers.py +16 -2
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/testing/types.py +7 -1
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/types.py +42 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/pyproject.toml +1 -1
- {capt_hook-4.3.1 → capt_hook-4.5.0}/LICENSE +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/README.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/__main__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/app.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/cli.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/command.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/context.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/decisions.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/durable.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/events.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/fields.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/file.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/loader.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/log.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/packs/steering/steering.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/prompt.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/py.typed +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/session.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/settings.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/style/types.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/tasks.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/http.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/util/text.py +0 -0
|
@@ -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
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
128
|
-
from captain_hook.review.settings import ReviewSettings
|
|
127
|
+
from captain_hook.review.pipeline import spawn_session
|
|
129
128
|
|
|
130
|
-
click.echo(asyncio.run(
|
|
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
|
-
|
|
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],
|
|
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(
|
|
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),
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
check it is watched, scan the transcript
|
|
10
|
-
sync open PR states, and — when at least
|
|
11
|
-
thresholds — spawn the headless brain that drafts
|
|
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
|
+
)
|
{capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md
RENAMED
|
@@ -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
|
|
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":
|
|
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={
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md
RENAMED
|
File without changes
|
{capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md
RENAMED
|
File without changes
|
{capt_hook-4.3.1 → capt_hook-4.5.0}/captain_hook/skills/scanning-sessions/references/review-cli.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|