capt-hook 6.1.0__tar.gz → 6.3.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-6.1.0 → capt_hook-6.3.0}/PKG-INFO +1 -1
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/__init__.py +1 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/conditions.py +6 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/capt-hook.toml +1 -1
- capt_hook-6.3.0/captain_hook/packs/general/models.py +447 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +3 -1
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +6 -3
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +1 -1
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/testing/helpers.py +25 -17
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/testing/types.py +4 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/pyproject.toml +1 -1
- capt_hook-6.1.0/captain_hook/packs/general/models.py +0 -221
- {capt_hook-6.1.0 → capt_hook-6.3.0}/LICENSE +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/README.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/__main__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/app.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/cli.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/command.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/context.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/contexts.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/decisions.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/durable.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/events.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/fields.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/file.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/loader.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/log.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/_lib.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/general/tombstones.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/packs/steering/steering.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/prompt.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/py.typed +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/store.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/session.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/settings.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/state.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/style/types.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/tasks.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/types.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/util/http.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/util/text.py +0 -0
|
@@ -23,6 +23,7 @@ from captain_hook import file, style, util
|
|
|
23
23
|
from captain_hook.app import hook, on
|
|
24
24
|
from captain_hook.ast_grep import COMMENT_TYPES
|
|
25
25
|
from captain_hook.command import CommandLine, ParsedCommand, Redirect
|
|
26
|
+
from captain_hook.conditions import workflow_opt_values, workflow_script_source
|
|
26
27
|
from captain_hook.context import HookContext
|
|
27
28
|
from captain_hook.contexts import AfterEdit, BeforeEdit, Introduced, PromptContext, apply_contexts
|
|
28
29
|
from captain_hook.durable import DurableSlot, DurableState, DurableStore
|
|
@@ -87,6 +87,12 @@ def is_waiting(evt: BaseHookEvent) -> bool:
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
def workflow_script_source(evt: BaseHookEvent) -> str | None:
|
|
90
|
+
"""The pending ``Workflow`` call's script source, or ``None``.
|
|
91
|
+
|
|
92
|
+
Returns the inline ``script`` when present, else reads the file at
|
|
93
|
+
``scriptPath``. A non-Workflow event, a missing/unreadable path, or a file
|
|
94
|
+
over ~1 MiB yields ``None``; never raises.
|
|
95
|
+
"""
|
|
90
96
|
if not (call := evt.as_input(WorkflowCall)):
|
|
91
97
|
return None
|
|
92
98
|
if call.script is not None:
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from captain_hook import (
|
|
7
|
+
Agent,
|
|
8
|
+
Allow,
|
|
9
|
+
BaseHookEvent,
|
|
10
|
+
Block,
|
|
11
|
+
Clause,
|
|
12
|
+
Event,
|
|
13
|
+
Input,
|
|
14
|
+
Phrase,
|
|
15
|
+
Rewrite,
|
|
16
|
+
TaskCall,
|
|
17
|
+
Tool,
|
|
18
|
+
ToolInput,
|
|
19
|
+
Warn,
|
|
20
|
+
WorkflowScript,
|
|
21
|
+
hook,
|
|
22
|
+
llm_gate,
|
|
23
|
+
llm_nudge,
|
|
24
|
+
nudge,
|
|
25
|
+
set_tool_input,
|
|
26
|
+
workflow_opt_values,
|
|
27
|
+
workflow_script_source,
|
|
28
|
+
)
|
|
29
|
+
from captain_hook.signals.nlp import nlp_scan
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class DelegatedSpawn:
|
|
34
|
+
"""Gating context: the pending Agent/Task call's model pin, agent type, and prompt."""
|
|
35
|
+
|
|
36
|
+
tag: str = "delegated_spawn"
|
|
37
|
+
required: bool = True
|
|
38
|
+
|
|
39
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
40
|
+
if (call := evt.as_input(TaskCall)) is None or not call.prompt:
|
|
41
|
+
return None
|
|
42
|
+
model = call.model or "(none — inherits the session model, fable)"
|
|
43
|
+
return f"model: {model}\nagent_type: {call.agent_type or '(default)'}\nprompt:\n{call.prompt}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
WORKFLOW_SCRIPT_CAP = 14_000 # below the prose hooks' max_context=16_000, so truncation stays ours
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def prose_deliverable_sentences(text: str) -> list[str]:
|
|
50
|
+
"""Sentences where a writing verb governs a prose artifact, minus negated asks ("do NOT edit CHANGELOG.md").
|
|
51
|
+
|
|
52
|
+
The text is de-noised for the tagger first: path/URL tokens dropped, brackets and
|
|
53
|
+
word-edge quotes blanked (so `agent('write …')` doesn't glue into one token; intra-word
|
|
54
|
+
apostrophes survive for "n't" negations), readme/changelog extensions stripped, intra-word
|
|
55
|
+
hyphens split, writing verbs lowercased, and imperative writing verbs given a determiner —
|
|
56
|
+
"Update CHANGELOG.md" otherwise parses as a noun compound.
|
|
57
|
+
"""
|
|
58
|
+
verbs = r"write|draft|redraft|rewrite|revise|reword|polish|copyedit|compose|author|update|edit"
|
|
59
|
+
text = re.sub(r"\S+/\S+", " ", text)
|
|
60
|
+
text = re.sub(r"[(){}\[\]<>]|(?<![A-Za-z])['\"`]|['\"`](?![A-Za-z])", " ", text)
|
|
61
|
+
text = re.sub(r"(?i)\b(readme|changelog)\.(?:md|rst|txt)\b", r"\1", text)
|
|
62
|
+
text = re.sub(r"(?<=\w)-(?=\w)", " ", text)
|
|
63
|
+
text = re.sub(rf"(?i)\b({verbs})\b", lambda m: m.group(1).lower(), text)
|
|
64
|
+
text = re.sub(rf"\b({verbs})[ \t]+((?i:readme|changelog|docs))\b", r"\1 the \2", text)
|
|
65
|
+
artifact = Phrase(
|
|
66
|
+
"readme",
|
|
67
|
+
"readme.md",
|
|
68
|
+
"doc",
|
|
69
|
+
"docs",
|
|
70
|
+
"documentation",
|
|
71
|
+
"changelog",
|
|
72
|
+
"changelog.md",
|
|
73
|
+
"blog",
|
|
74
|
+
"blog post",
|
|
75
|
+
"release note",
|
|
76
|
+
"announcement",
|
|
77
|
+
"tutorial",
|
|
78
|
+
"guide",
|
|
79
|
+
"prose",
|
|
80
|
+
"marketing copy",
|
|
81
|
+
"article",
|
|
82
|
+
"newsletter",
|
|
83
|
+
"email",
|
|
84
|
+
"pr description",
|
|
85
|
+
"commit message",
|
|
86
|
+
)
|
|
87
|
+
writing = Phrase(
|
|
88
|
+
"write",
|
|
89
|
+
"draft",
|
|
90
|
+
"redraft",
|
|
91
|
+
"rewrite",
|
|
92
|
+
"revise",
|
|
93
|
+
"reword",
|
|
94
|
+
"polish",
|
|
95
|
+
"copyedit",
|
|
96
|
+
"compose",
|
|
97
|
+
"author",
|
|
98
|
+
"update",
|
|
99
|
+
"edit",
|
|
100
|
+
)
|
|
101
|
+
if not (matched := nlp_scan([Clause(noun=artifact, verb=writing)], text)):
|
|
102
|
+
return []
|
|
103
|
+
negated = set(nlp_scan([Clause(noun=artifact, verb=writing, negated=True)], text))
|
|
104
|
+
return [s for s in matched if s not in negated]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class ProseSpawn(DelegatedSpawn):
|
|
109
|
+
"""Gating context: the pending spawn, present only when its prompt asks to produce a prose artifact."""
|
|
110
|
+
|
|
111
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
112
|
+
# zero-arg super() breaks under @dataclass(slots=True) — the decorator rebuilds the class
|
|
113
|
+
if (base := DelegatedSpawn.content(self, evt)) is None or (call := evt.as_input(TaskCall)) is None:
|
|
114
|
+
return None
|
|
115
|
+
if not (sentences := prose_deliverable_sentences((call.prompt or "")[:WORKFLOW_SCRIPT_CAP])):
|
|
116
|
+
return None
|
|
117
|
+
matched = "\n".join(f" {s[:300]}" for s in sentences)
|
|
118
|
+
return f"{base}\n\nsentences the prose prefilter matched:\n{matched}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class WorkflowScriptSource:
|
|
123
|
+
"""Gating context: the pending Workflow call's script source, headed by its model pins and prose asks."""
|
|
124
|
+
|
|
125
|
+
tag: str = "workflow_script"
|
|
126
|
+
required: bool = True
|
|
127
|
+
|
|
128
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
129
|
+
if (source := workflow_script_source(evt)) is None:
|
|
130
|
+
return None
|
|
131
|
+
pin_lines = "\n".join(
|
|
132
|
+
f" {line.strip()[:200]}" for line in source.splitlines() if workflow_opt_values(line, "model")
|
|
133
|
+
)
|
|
134
|
+
if len(source) > WORKFLOW_SCRIPT_CAP:
|
|
135
|
+
head, tail = WORKFLOW_SCRIPT_CAP * 3 // 4, WORKFLOW_SCRIPT_CAP // 4
|
|
136
|
+
source = (
|
|
137
|
+
f"{source[:head]}\n"
|
|
138
|
+
f"… [script truncated: {len(source):,} chars total; every model pin is quoted above] …\n"
|
|
139
|
+
f"{source[-tail:]}"
|
|
140
|
+
)
|
|
141
|
+
if not (sentences := prose_deliverable_sentences(source)):
|
|
142
|
+
return None
|
|
143
|
+
matched = "\n".join(f" {s[:300]}" for s in sentences)
|
|
144
|
+
return (
|
|
145
|
+
"lines that pin a model in this script (a stage not quoted here inherits the "
|
|
146
|
+
f"session model, fable):\n{pin_lines or ' (none)'}\n\n"
|
|
147
|
+
f"sentences the prose prefilter matched:\n{matched}\n\n{source}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
hook(
|
|
152
|
+
Event.PreToolUse,
|
|
153
|
+
only_if=[Tool("Agent|Task"), ToolInput("model", r"(?i)\bhaiku\b")],
|
|
154
|
+
skip_if=[
|
|
155
|
+
ToolInput(
|
|
156
|
+
"prompt",
|
|
157
|
+
r"(?i)\b(classif|label|tag|categoriz|per.file|one (fact|thing)|mechanical|probe|ping"
|
|
158
|
+
r"|echo|smoke|count|capacit|extract)",
|
|
159
|
+
)
|
|
160
|
+
],
|
|
161
|
+
message=(
|
|
162
|
+
"This subagent is pinned to haiku. Route a subagent to haiku only for a single-fact "
|
|
163
|
+
"mechanical step — classifying, labeling, tagging, counting, or probing one thing per "
|
|
164
|
+
"item. Anything that carries judgment should run on sonnet; use model='sonnet', or drop "
|
|
165
|
+
"model entirely to inherit the session model. If this genuinely is a mechanical "
|
|
166
|
+
"single-fact step, say 'mechanical' in the prompt and retry — the haiku pin will be "
|
|
167
|
+
"allowed. See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
168
|
+
),
|
|
169
|
+
block=True,
|
|
170
|
+
tests={
|
|
171
|
+
Input(model="haiku", prompt="implement the retry backoff in the client"): Block(),
|
|
172
|
+
Input(model="haiku", prompt="classify each file's language"): Allow(),
|
|
173
|
+
Input(model="haiku", prompt="Probe subagent capacity: spawn and return the word ok"): Allow(),
|
|
174
|
+
Input(model="haiku", prompt="mechanical step: return the repo's default branch name"): Allow(),
|
|
175
|
+
Input(model="haiku", prompt="count the TODO markers in src/"): Allow(),
|
|
176
|
+
Input(prompt="implement the retry backoff in the client"): Allow(),
|
|
177
|
+
Input(model="sonnet", prompt="implement the retry backoff in the client"): Allow(),
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
llm_gate(
|
|
182
|
+
"""Decide whether this delegated subagent call pins a non-fable model on work whose
|
|
183
|
+
deliverable is prose.
|
|
184
|
+
|
|
185
|
+
<delegated_spawn> holds the pending Agent/Task call: its model pin, agent type, and
|
|
186
|
+
prompt, ending with the sentences a clause prefilter matched — each asks a writing
|
|
187
|
+
verb of a prose artifact, with negated asks ("do NOT edit the docs") already
|
|
188
|
+
screened out. Your job is precision: is a prose artifact what this subagent is
|
|
189
|
+
asked to PRODUCE?
|
|
190
|
+
|
|
191
|
+
The Models rubric: all writing a user reads — READMEs, docs, changelogs, release
|
|
192
|
+
notes, blog posts, PR descriptions, any user-facing text — routes to fable. Work
|
|
193
|
+
that merely mentions a prose file — as a constraint ("do NOT touch the docs"), as
|
|
194
|
+
reading material, as the subject of recon or review — is not prose work.
|
|
195
|
+
|
|
196
|
+
Set block=true only when the prompt clearly asks the subagent to write, draft,
|
|
197
|
+
revise, or polish a prose artifact. Recon, review, classification, and code work
|
|
198
|
+
that references docs stay allowed: block=false. When uncertain, block=false — a
|
|
199
|
+
wrong block stops legitimate work cold. Keep reasoning under 40 words.
|
|
200
|
+
|
|
201
|
+
<examples>
|
|
202
|
+
<example block="true">
|
|
203
|
+
model: sonnet — Write the README quickstart for this repo.
|
|
204
|
+
The deliverable is README prose on a non-fable pin.
|
|
205
|
+
</example>
|
|
206
|
+
<example block="true">
|
|
207
|
+
model: opus — Update CHANGELOG.md with an entry for the retry fix.
|
|
208
|
+
The subagent itself writes the changelog prose.
|
|
209
|
+
</example>
|
|
210
|
+
<example block="false">
|
|
211
|
+
model: opus — Fix the failing test in cli.py. Do NOT edit CHANGELOG.md — a sibling owns it.
|
|
212
|
+
CHANGELOG is a constraint, not the deliverable; this is code work.
|
|
213
|
+
</example>
|
|
214
|
+
<example block="false">
|
|
215
|
+
model: sonnet — Explore the cc-interact building blocks and report where the docs pipeline is written.
|
|
216
|
+
Read-only recon that mentions docs; nothing user-facing is produced.
|
|
217
|
+
</example>
|
|
218
|
+
<example block="false">
|
|
219
|
+
model: sonnet — Review the README draft for factual errors and list them.
|
|
220
|
+
Review findings go to the orchestrator; the subagent writes no user-facing prose.
|
|
221
|
+
</example>
|
|
222
|
+
</examples>""",
|
|
223
|
+
message=lambda r: (
|
|
224
|
+
"This subagent is pinned to a non-fable model but its deliverable is prose/writing work. "
|
|
225
|
+
f"{r.reasoning} All writing — docs, READMEs, release notes, any user-facing text — routes "
|
|
226
|
+
"to fable: drop model to inherit the session model, or pass model='fable'. "
|
|
227
|
+
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
228
|
+
),
|
|
229
|
+
contexts=[ProseSpawn()],
|
|
230
|
+
events=Event.PreToolUse,
|
|
231
|
+
only_if=[
|
|
232
|
+
Tool("Agent|Task"),
|
|
233
|
+
ToolInput("model", r"(?i)\b(haiku|sonnet|opus)\b"),
|
|
234
|
+
],
|
|
235
|
+
skip_if=[
|
|
236
|
+
ToolInput("prompt", r"(?i)\b(classif|label|tag|categoriz|count|extract|mechanical)"),
|
|
237
|
+
Agent("Explore|claude-code-guide"),
|
|
238
|
+
],
|
|
239
|
+
agent=False,
|
|
240
|
+
transcript=False,
|
|
241
|
+
max_context=16_000,
|
|
242
|
+
tests={
|
|
243
|
+
Input(model="sonnet", prompt="Write the README quickstart for this repo"): Block(),
|
|
244
|
+
Input(model="opus", prompt="draft the release notes for v2"): Block(),
|
|
245
|
+
Input(model="haiku", prompt="update the CHANGELOG entry for the fix"): Block(),
|
|
246
|
+
Input(model="fable", prompt="write the README quickstart"): Allow(),
|
|
247
|
+
Input(prompt="write the README quickstart"): Allow(),
|
|
248
|
+
Input(model="sonnet", prompt="review the README for factual errors"): Allow(),
|
|
249
|
+
Input(model="sonnet", prompt="update the retry backoff config"): Allow(),
|
|
250
|
+
Input(model="haiku", prompt="label each README section with its Diataxis mode"): Allow(),
|
|
251
|
+
Input(agent_type="Explore", model="sonnet", prompt="find where the README quickstart is written"): Allow(),
|
|
252
|
+
Input(agent_type="claude-code-guide", model="sonnet", prompt="explain how the docs get updated"): Allow(),
|
|
253
|
+
Input(model="opus", prompt="Update the retry backoff config per the spec in docs/plan.md"): Allow(),
|
|
254
|
+
Input(
|
|
255
|
+
model="opus",
|
|
256
|
+
prompt="Fix the failing test in cli.py. Do NOT edit the CHANGELOG — a sibling owns updating it",
|
|
257
|
+
): Allow(),
|
|
258
|
+
Input(
|
|
259
|
+
model="opus",
|
|
260
|
+
prompt="Fix the failing test in cli.py; do NOT edit CHANGELOG.md. Then draft the release notes.",
|
|
261
|
+
llm={"block": False},
|
|
262
|
+
): Allow(),
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
set_tool_input(
|
|
267
|
+
"model",
|
|
268
|
+
"sonnet",
|
|
269
|
+
tool="Agent|Task",
|
|
270
|
+
only_if=[Agent("Explore|claude-code-guide")],
|
|
271
|
+
note=(
|
|
272
|
+
"Upgraded this recon subagent from the silent haiku default to sonnet, per the Models "
|
|
273
|
+
"table in CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
274
|
+
),
|
|
275
|
+
tests={
|
|
276
|
+
Input(agent_type="Explore"): Rewrite(model="sonnet"),
|
|
277
|
+
Input(agent_type="Explore", model="haiku"): Allow(),
|
|
278
|
+
Input(agent_type="general-purpose"): Allow(),
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
llm_nudge(
|
|
283
|
+
"""Decide whether this delegated subagent should run on opus-4.8 instead of fable-5.
|
|
284
|
+
|
|
285
|
+
<delegated_spawn> holds the pending Agent/Task call: its model pin (or that it inherits
|
|
286
|
+
the session model, fable), agent type, and prompt.
|
|
287
|
+
|
|
288
|
+
The Models rubric: implementation delegates to opus-4.8 at xhigh — opus is ~2x cheaper
|
|
289
|
+
than fable and nearly as capable. Fable's lanes are orchestration, review, hard
|
|
290
|
+
planning/design/diagnosis, all prose/writing, and implementation that is very sensitive
|
|
291
|
+
or error-prone (auth, migrations, concurrency, data loss, crypto, subtle algorithms).
|
|
292
|
+
|
|
293
|
+
Set fire=true only when the prompt is clearly routine implementation — building, fixing,
|
|
294
|
+
wiring, or refactoring code — with no fable-lane signal. A prompt that reviews, plans,
|
|
295
|
+
designs, diagnoses a hard bug, writes prose, or touches a sensitive surface stays on
|
|
296
|
+
fable: fire=false. When uncertain, fire=false — the agent may have chosen fable
|
|
297
|
+
deliberately, and a false alarm teaches it to ignore this nudge. Keep reasoning under
|
|
298
|
+
40 words.
|
|
299
|
+
|
|
300
|
+
<examples>
|
|
301
|
+
<example fire="true">
|
|
302
|
+
Implement the pagination endpoint in api/users.py per the spec in the plan.
|
|
303
|
+
Routine implementation with no sensitivity signal — the opus xhigh lane.
|
|
304
|
+
</example>
|
|
305
|
+
<example fire="true">
|
|
306
|
+
Add a --json flag to the export command and thread it through the formatter.
|
|
307
|
+
Well-scoped feature wiring; the default implementation lane.
|
|
308
|
+
</example>
|
|
309
|
+
<example fire="false">
|
|
310
|
+
Review the diff for correctness and concurrency issues.
|
|
311
|
+
Review is fable's lane.
|
|
312
|
+
</example>
|
|
313
|
+
<example fire="false">
|
|
314
|
+
Design the migration strategy for the sharded session store.
|
|
315
|
+
Hard planning/design stays on fable.
|
|
316
|
+
</example>
|
|
317
|
+
<example fire="false">
|
|
318
|
+
Implement the token-refresh race fix in the auth middleware.
|
|
319
|
+
Auth plus concurrency: sensitive, error-prone implementation stays on fable.
|
|
320
|
+
</example>
|
|
321
|
+
</examples>""",
|
|
322
|
+
message=lambda r: (
|
|
323
|
+
f"This delegation would run on fable, but it reads as routine implementation. {r.reasoning} "
|
|
324
|
+
"Implementation defaults to model='opus' + effort='xhigh' (~2x cheaper, nearly as capable); "
|
|
325
|
+
"a well-scoped edit to existing code can also go to gpt-5.5 via the codex skill behind a "
|
|
326
|
+
"model='sonnet' low-effort wrapper. Keep fable if this genuinely is sensitive or error-prone. "
|
|
327
|
+
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
328
|
+
),
|
|
329
|
+
contexts=[DelegatedSpawn()],
|
|
330
|
+
events=Event.PreToolUse,
|
|
331
|
+
only_if=[Tool("Agent|Task")],
|
|
332
|
+
skip_if=[
|
|
333
|
+
ToolInput("model", r"(?i)\b(opus|sonnet|haiku)\b"),
|
|
334
|
+
Agent("Explore|claude-code-guide"),
|
|
335
|
+
],
|
|
336
|
+
agent=False,
|
|
337
|
+
transcript=False,
|
|
338
|
+
tests={
|
|
339
|
+
Input(prompt="implement the pagination endpoint in api/users.py"): Warn(pattern="opus"),
|
|
340
|
+
Input(model="fable", prompt="add a --json flag to the export command"): Warn(pattern="opus"),
|
|
341
|
+
Input(model="opus", prompt="implement the pagination endpoint in api/users.py"): Allow(),
|
|
342
|
+
Input(model="sonnet", prompt="scan the repo for TODO markers"): Allow(),
|
|
343
|
+
Input(agent_type="Explore", prompt="find where the config loader lives"): Allow(),
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
nudge(
|
|
348
|
+
"""
|
|
349
|
+
This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
|
|
350
|
+
map steps; a judgment-bearing stage should inherit the session model or route up. See
|
|
351
|
+
CLAUDE.md § Plan Execution & Orchestration (Models).
|
|
352
|
+
""",
|
|
353
|
+
only_if=[Tool("Workflow"), WorkflowScript(model="haiku")],
|
|
354
|
+
events=Event.PreToolUse,
|
|
355
|
+
max_fires=2,
|
|
356
|
+
tests={
|
|
357
|
+
Input(script="steps:\n - agent: reviewer\n model: 'haiku'\n"): Warn(),
|
|
358
|
+
Input(script="steps:\n - agent: reviewer\n model: 'sonnet'\n"): Allow(),
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
llm_nudge(
|
|
363
|
+
"""Decide whether this workflow script pins a non-fable model on a stage whose
|
|
364
|
+
deliverable is prose.
|
|
365
|
+
|
|
366
|
+
<workflow_script> holds the pending Workflow call's script source. Its header quotes
|
|
367
|
+
every line of the script that pins a model — a stage that is not quoted there carries
|
|
368
|
+
no pin and inherits the session model, fable — already correctly routed, whatever it
|
|
369
|
+
writes — followed by the sentences a clause prefilter matched: each asks a writing
|
|
370
|
+
verb of a prose artifact, with negated asks ("do NOT edit CHANGELOG.md") already
|
|
371
|
+
screened out. Your job is precision: does a PINNED stage have prose as its own
|
|
372
|
+
deliverable?
|
|
373
|
+
|
|
374
|
+
The Models rubric: all writing a user reads — READMEs, docs, changelogs, release
|
|
375
|
+
notes, blog posts, announcements, any user-facing text — routes to fable. A stage's
|
|
376
|
+
deliverable is prose when its agent() prompt asks it to write, draft, revise, or
|
|
377
|
+
polish such an artifact.
|
|
378
|
+
|
|
379
|
+
Set fire=true only when at least one agent() call both pins haiku/sonnet/opus and
|
|
380
|
+
has prose as its deliverable. A prose stage with no pin of its own is fine, even
|
|
381
|
+
when other stages pin opus. A prose keyword that appears as a constraint ("do NOT
|
|
382
|
+
edit CHANGELOG.md"), an ownership note, a file the stage merely reads, a
|
|
383
|
+
meta.description, or prose the orchestrator script assembles itself outside any
|
|
384
|
+
pinned agent() call is not that stage's deliverable: fire=false. When uncertain,
|
|
385
|
+
fire=false — a false alarm teaches the agent to ignore this nudge. Keep reasoning
|
|
386
|
+
under 40 words and name the offending stage.
|
|
387
|
+
|
|
388
|
+
<examples>
|
|
389
|
+
<example fire="true">
|
|
390
|
+
agent('Write the README quickstart section for the new CLI', {model: 'opus'})
|
|
391
|
+
The stage's deliverable is README prose, pinned to opus.
|
|
392
|
+
</example>
|
|
393
|
+
<example fire="true">
|
|
394
|
+
stages.push(agent(`Draft the docs-site page for ${feature}`, {model: 'sonnet'}))
|
|
395
|
+
A docs page is user-facing prose; the pin is non-fable.
|
|
396
|
+
</example>
|
|
397
|
+
<example fire="false">
|
|
398
|
+
agent('Fix the failing import in cli.py. Do NOT edit CHANGELOG.md — a sibling owns it', {model: 'opus'})
|
|
399
|
+
CHANGELOG appears only as a constraint; the deliverable is a code fix.
|
|
400
|
+
</example>
|
|
401
|
+
<example fire="false">
|
|
402
|
+
agent('Fix the CLI error handling', {label: 'fix:cli', model: 'opus'}) alongside
|
|
403
|
+
agent('Reword the troubleshooting guide and CHANGELOG bullet', {label: 'fix:docs'})
|
|
404
|
+
The prose stage carries no pin — it inherits fable; the opus pin is on code.
|
|
405
|
+
</example>
|
|
406
|
+
<example fire="false">
|
|
407
|
+
meta: {description: 'verify the doc claims against actual behavior'}, then agent('run the test matrix', {model: 'opus'})
|
|
408
|
+
"doc claims" lives in the description, not in any pinned stage's deliverable.
|
|
409
|
+
</example>
|
|
410
|
+
<example fire="false">
|
|
411
|
+
agent('fix the three failing tests', {model: 'opus'}), then the script itself assembles CHANGELOG.md from the results
|
|
412
|
+
The orchestrator writes the prose; the pinned stage only fixes tests.
|
|
413
|
+
</example>
|
|
414
|
+
<example fire="false">
|
|
415
|
+
agent('Read docs/architecture.md and list the sections that are stale', {model: 'sonnet'})
|
|
416
|
+
Reading and classifying docs is analysis, not a prose deliverable.
|
|
417
|
+
</example>
|
|
418
|
+
</examples>""",
|
|
419
|
+
message=lambda r: (
|
|
420
|
+
f"This workflow script pins a non-fable model on a stage whose deliverable is prose. {r.reasoning} "
|
|
421
|
+
"All writing — docs, READMEs, release notes, any user-facing text — routes to fable: inherit the "
|
|
422
|
+
"session model or pin model: 'fable' on that stage. "
|
|
423
|
+
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
424
|
+
),
|
|
425
|
+
contexts=[WorkflowScriptSource()],
|
|
426
|
+
events=Event.PreToolUse,
|
|
427
|
+
only_if=[
|
|
428
|
+
Tool("Workflow"),
|
|
429
|
+
WorkflowScript(model="haiku|sonnet|opus"),
|
|
430
|
+
],
|
|
431
|
+
max_fires=2,
|
|
432
|
+
max_context=16_000,
|
|
433
|
+
agent=False,
|
|
434
|
+
transcript=False,
|
|
435
|
+
tests={
|
|
436
|
+
Input(script="steps:\n - agent: write the README intro\n model: 'sonnet'\n"): Warn(pattern="fable"),
|
|
437
|
+
Input(script="steps:\n - agent: write the README intro\n model: 'fable'\n"): Allow(),
|
|
438
|
+
Input(script="steps:\n - agent: fix the retry backoff\n model: 'sonnet'\n"): Allow(),
|
|
439
|
+
Input(script="agent('Audit docs/architecture.md for stale claims', {model: 'opus'})"): Allow(),
|
|
440
|
+
Input(
|
|
441
|
+
script="agent('recon the module map', {model: 'sonnet'})\n// all prose stays with the main agent on fable\n"
|
|
442
|
+
): Allow(),
|
|
443
|
+
Input(
|
|
444
|
+
script="agent('Fix the import in cli.py. Do NOT edit CHANGELOG.md — a sibling owns it', {model: 'opus'})",
|
|
445
|
+
): Allow(),
|
|
446
|
+
},
|
|
447
|
+
)
|
|
@@ -94,7 +94,9 @@ registration gets:
|
|
|
94
94
|
- Inline `tests = {...}` from Step 1: the offending shape expecting `Block(...)` or
|
|
95
95
|
`Warn(...)` (match the chosen severity), the benign neighbor expecting `Allow()`.
|
|
96
96
|
LLM hooks (`llm_gate`, `llm_nudge`) ship without `tests=` — their inline tests would
|
|
97
|
-
only exercise a stubbed model.
|
|
97
|
+
only exercise a stubbed model. Exception: with a required `contexts=` provider, ship
|
|
98
|
+
`tests=`, and use `Input(llm={"fire": False})` (or `{"block": False}`) to wire-test the
|
|
99
|
+
judge-declines path; the default stub still always fires.
|
|
98
100
|
|
|
99
101
|
### 4. Verify
|
|
100
102
|
|
{capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md
RENAMED
|
@@ -13,8 +13,11 @@ hook's conditions and handler, and asserts the outcome. Exit code 1 on any failu
|
|
|
13
13
|
(first alternative of the pattern: `Tool("Edit|Write")` infers `Edit`), else `Bash`.
|
|
14
14
|
3. `skip_if`/`only_if` are evaluated; a non-matching input yields `Allow()`.
|
|
15
15
|
4. LLM calls are stubbed: `GateVerdict(block=True)`, `NudgeVerdict(fire=True)`,
|
|
16
|
-
`PromptCheckVerdict(action="block")`. The stub always fires, so inline tests on
|
|
17
|
-
assert
|
|
16
|
+
`PromptCheckVerdict(action="block")`. The default stub always fires, so inline tests on
|
|
17
|
+
LLM hooks assert only conditions and wiring, never judgment — ship LLM hooks without
|
|
18
|
+
`tests=` unless a required `contexts=` provider makes them meaningful.
|
|
19
|
+
`Input(llm={"fire": False})` / `Input(llm={"block": False})` overrides the stub verdict
|
|
20
|
+
per test, wire-testing the judge-declines path.
|
|
18
21
|
|
|
19
22
|
## Input fields
|
|
20
23
|
|
|
@@ -89,4 +92,4 @@ Statuses: `pass`, `fail` (wrong outcome), `error` (exception in hook or test), `
|
|
|
89
92
|
| `@on` handler on a piped command never fires | `evt.command_line.q.runs(...)` checks the pipeline's *last* command. Use `.any_command(lambda c: c.program == "...")`. |
|
|
90
93
|
| Edit/Write hook gets `None` content | The `Input` set `command=` but the hook's first event/tool is Edit — set `file=` and `content=` (and `tool=` if the hook matches several). |
|
|
91
94
|
| `Input(command=...)` ignored | The hook's first event is `Stop` — the runner builds a Stop event without tool input. Encode the command as a Bash `tool_use` in `transcript=` instead. |
|
|
92
|
-
| LLM hook test always blocks | The stub verdict always fires.
|
|
95
|
+
| LLM hook test always blocks | The default stub verdict always fires. Pass `Input(llm={"fire": False})` / `{"block": False}` to exercise the declines path, or remove `tests=` and test the static `only_if` narrowing on a deterministic sibling hook. |
|
|
@@ -19,7 +19,7 @@ skill, which owns hook drafting (Step 6 delegates to it).
|
|
|
19
19
|
## Hard Rules
|
|
20
20
|
|
|
21
21
|
- **Never write a hook the user has not approved in Step 4.** Survey and propose first; write only what was selected.
|
|
22
|
-
- **Every deterministic hook ships inline tests** — at least one firing `Input` and one `Allow()` — and `uvx capt-hook test` must be green before Step 8. LLM hooks (`llm_gate`, `llm_nudge`, `prompt_check`) and signal-scored `nudge`s ship without `tests=` — their inline tests would only exercise a stubbed model — with one exception: an LLM hook gated by a **required `contexts=` provider** should ship `tests=`, because the context extraction runs for real under the stub. The stub always confirms, so every firing case exercises the real gate and every `Allow()` case must yield no evidence (fail the gate)
|
|
22
|
+
- **Every deterministic hook ships inline tests** — at least one firing `Input` and one `Allow()` — and `uvx capt-hook test` must be green before Step 8. LLM hooks (`llm_gate`, `llm_nudge`, `prompt_check`) and signal-scored `nudge`s ship without `tests=` — their inline tests would only exercise a stubbed model — with one exception: an LLM hook gated by a **required `contexts=` provider** should ship `tests=`, because the context extraction runs for real under the stub. The default stub always confirms, so every firing case exercises the real gate and every `Allow()` case must yield no evidence (fail the gate) — or override the verdict per test with `Input(llm={"fire": False})` / `{"block": False}` to wire-test the judge-declines path. Never treat a stubbed verdict as proof the model judges correctly.
|
|
23
23
|
- **Propose `block` only for irreversible or destructive actions** (history rewrites, data deletion, deploys, secret leaks). Default everything else to warn. The user picks final severity in Step 4.
|
|
24
24
|
- **Never write style rules here.** A style guide found during the survey is delegated whole to the `translating-styleguides` skill (category E below).
|
|
25
25
|
|
|
@@ -4,7 +4,7 @@ import atexit
|
|
|
4
4
|
import re
|
|
5
5
|
import shutil
|
|
6
6
|
import tempfile
|
|
7
|
-
from collections.abc import Iterator
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
8
|
from contextlib import contextmanager
|
|
9
9
|
from itertools import count
|
|
10
10
|
from pathlib import Path
|
|
@@ -508,21 +508,29 @@ def assert_result(
|
|
|
508
508
|
assert val in actual, f"{prefix}Rewrite {key}={actual!r} doesn't contain '{val}'"
|
|
509
509
|
|
|
510
510
|
|
|
511
|
-
def
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
511
|
+
def make_stub_call_llm(overrides: dict[str, Any] | None = None) -> Callable[..., str | BaseModel]:
|
|
512
|
+
values = STUB_FIELD_VALUES | (overrides or {})
|
|
513
|
+
|
|
514
|
+
def stub_call_llm(
|
|
515
|
+
_template: Any,
|
|
516
|
+
*args: Any,
|
|
517
|
+
response_model: type[BaseModel] | None = None,
|
|
518
|
+
**kwargs: Any,
|
|
519
|
+
) -> str | BaseModel:
|
|
520
|
+
if response_model is None:
|
|
521
|
+
return "stubbed"
|
|
522
|
+
return response_model(
|
|
523
|
+
**{
|
|
524
|
+
name: values.get(name, "")
|
|
525
|
+
for name, info in response_model.model_fields.items()
|
|
526
|
+
if name in values or info.default is None
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
return stub_call_llm
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
stub_call_llm = make_stub_call_llm()
|
|
526
534
|
|
|
527
535
|
|
|
528
536
|
@contextmanager
|
|
@@ -563,7 +571,7 @@ def run_inline_tests() -> list[tuple[str, str, bool, str]]:
|
|
|
563
571
|
if spec_tools
|
|
564
572
|
else None,
|
|
565
573
|
)
|
|
566
|
-
evt.ctx.call_llm =
|
|
574
|
+
evt.ctx.call_llm = make_stub_call_llm(key.llm) # type: ignore[assignment]
|
|
567
575
|
hook_result = (
|
|
568
576
|
execute_hook(entry, evt)
|
|
569
577
|
if matches_conditions(entry.spec, evt) and not is_planning_agent_skip(entry.spec, evt)
|
|
@@ -110,6 +110,9 @@ class Input:
|
|
|
110
110
|
transcript: Session history for transcript conditions — a path, a
|
|
111
111
|
``TranscriptFixture``, or a raw list of transcript-line dicts.
|
|
112
112
|
tasks: The native task list read via ``evt.tasks``.
|
|
113
|
+
llm: Per-test LLM stub overrides merged over the default stub verdict
|
|
114
|
+
(``fire``/``block``/``action``/``reasoning``), e.g. ``llm={"fire": False}``
|
|
115
|
+
to exercise an LLM hook's judge-declines path.
|
|
113
116
|
"""
|
|
114
117
|
|
|
115
118
|
command: str | None = None
|
|
@@ -131,6 +134,7 @@ class Input:
|
|
|
131
134
|
limit: int | None = None
|
|
132
135
|
transcript: Path | TranscriptFixture | list[dict[str, Any]] | None = None
|
|
133
136
|
tasks: list[dict[str, Any]] | None = None
|
|
137
|
+
llm: dict[str, Any] | None = None
|
|
134
138
|
|
|
135
139
|
@field_validator("transcript", mode="before")
|
|
136
140
|
@classmethod
|
|
@@ -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 = "6.
|
|
4
|
+
version = "6.3.0"
|
|
5
5
|
description = "Declarative hook framework for Claude Code"
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "PolyForm-Noncommercial-1.0.0"
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
|
|
5
|
-
from captain_hook import (
|
|
6
|
-
Agent,
|
|
7
|
-
Allow,
|
|
8
|
-
BaseHookEvent,
|
|
9
|
-
Block,
|
|
10
|
-
Event,
|
|
11
|
-
Input,
|
|
12
|
-
Rewrite,
|
|
13
|
-
TaskCall,
|
|
14
|
-
Tool,
|
|
15
|
-
ToolInput,
|
|
16
|
-
Warn,
|
|
17
|
-
WorkflowScript,
|
|
18
|
-
hook,
|
|
19
|
-
llm_nudge,
|
|
20
|
-
nudge,
|
|
21
|
-
set_tool_input,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@dataclass(frozen=True, slots=True)
|
|
26
|
-
class DelegatedSpawn:
|
|
27
|
-
"""Gating context: the pending Agent/Task call's model pin, agent type, and prompt."""
|
|
28
|
-
|
|
29
|
-
tag: str = "delegated_spawn"
|
|
30
|
-
required: bool = True
|
|
31
|
-
|
|
32
|
-
def content(self, evt: BaseHookEvent) -> str | None:
|
|
33
|
-
if (call := evt.as_input(TaskCall)) is None or not call.prompt:
|
|
34
|
-
return None
|
|
35
|
-
model = call.model or "(none — inherits the session model, fable)"
|
|
36
|
-
return f"model: {model}\nagent_type: {call.agent_type or '(default)'}\nprompt:\n{call.prompt}"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
hook(
|
|
40
|
-
Event.PreToolUse,
|
|
41
|
-
only_if=[Tool("Agent|Task"), ToolInput("model", r"(?i)\bhaiku\b")],
|
|
42
|
-
skip_if=[
|
|
43
|
-
ToolInput(
|
|
44
|
-
"prompt",
|
|
45
|
-
r"(?i)\b(classif|label|tag|categoriz|per.file|one (fact|thing)|mechanical|probe|ping"
|
|
46
|
-
r"|echo|smoke|count|capacit|extract)",
|
|
47
|
-
)
|
|
48
|
-
],
|
|
49
|
-
message=(
|
|
50
|
-
"This subagent is pinned to haiku. Route a subagent to haiku only for a single-fact "
|
|
51
|
-
"mechanical step — classifying, labeling, tagging, counting, or probing one thing per "
|
|
52
|
-
"item. Anything that carries judgment should run on sonnet; use model='sonnet', or drop "
|
|
53
|
-
"model entirely to inherit the session model. If this genuinely is a mechanical "
|
|
54
|
-
"single-fact step, say 'mechanical' in the prompt and retry — the haiku pin will be "
|
|
55
|
-
"allowed. See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
56
|
-
),
|
|
57
|
-
block=True,
|
|
58
|
-
tests={
|
|
59
|
-
Input(model="haiku", prompt="implement the retry backoff in the client"): Block(),
|
|
60
|
-
Input(model="haiku", prompt="classify each file's language"): Allow(),
|
|
61
|
-
Input(model="haiku", prompt="Probe subagent capacity: spawn and return the word ok"): Allow(),
|
|
62
|
-
Input(model="haiku", prompt="mechanical step: return the repo's default branch name"): Allow(),
|
|
63
|
-
Input(model="haiku", prompt="count the TODO markers in src/"): Allow(),
|
|
64
|
-
Input(prompt="implement the retry backoff in the client"): Allow(),
|
|
65
|
-
Input(model="sonnet", prompt="implement the retry backoff in the client"): Allow(),
|
|
66
|
-
},
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
hook(
|
|
70
|
-
Event.PreToolUse,
|
|
71
|
-
only_if=[
|
|
72
|
-
Tool("Agent|Task"),
|
|
73
|
-
ToolInput("model", r"(?i)\b(haiku|sonnet|opus)\b"),
|
|
74
|
-
ToolInput(
|
|
75
|
-
"prompt",
|
|
76
|
-
r"(?i)\b(writ(e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)",
|
|
77
|
-
),
|
|
78
|
-
ToolInput(
|
|
79
|
-
"prompt",
|
|
80
|
-
r"(?i)\b(readme|docs?|documentation|blog|changelog|release notes|announcement"
|
|
81
|
-
r"|tutorial|guide|prose|copywrit|marketing copy|article|newsletter|email"
|
|
82
|
-
r"|pr description|commit message)\b",
|
|
83
|
-
),
|
|
84
|
-
],
|
|
85
|
-
skip_if=[ToolInput("prompt", r"(?i)\b(classif|label|tag|categoriz|count|extract|mechanical)")],
|
|
86
|
-
message=(
|
|
87
|
-
"This subagent is pinned to a non-fable model but its prompt is prose/writing work. "
|
|
88
|
-
"All writing — docs, READMEs, release notes, any user-facing text — routes to fable: "
|
|
89
|
-
"drop model to inherit the session model, or pass model='fable'. "
|
|
90
|
-
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
91
|
-
),
|
|
92
|
-
block=True,
|
|
93
|
-
tests={
|
|
94
|
-
Input(model="sonnet", prompt="Write the README quickstart for this repo"): Block(),
|
|
95
|
-
Input(model="opus", prompt="draft the release notes for v2"): Block(),
|
|
96
|
-
Input(model="haiku", prompt="update the CHANGELOG entry for the fix"): Block(),
|
|
97
|
-
Input(model="fable", prompt="write the README quickstart"): Allow(),
|
|
98
|
-
Input(prompt="write the README quickstart"): Allow(),
|
|
99
|
-
Input(model="sonnet", prompt="review the README for factual errors"): Allow(),
|
|
100
|
-
Input(model="sonnet", prompt="update the retry backoff config"): Allow(),
|
|
101
|
-
Input(model="haiku", prompt="label each README section with its Diataxis mode"): Allow(),
|
|
102
|
-
},
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
set_tool_input(
|
|
106
|
-
"model",
|
|
107
|
-
"sonnet",
|
|
108
|
-
tool="Agent|Task",
|
|
109
|
-
only_if=[Agent("Explore|claude-code-guide")],
|
|
110
|
-
note=(
|
|
111
|
-
"Upgraded this recon subagent from the silent haiku default to sonnet, per the Models "
|
|
112
|
-
"table in CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
113
|
-
),
|
|
114
|
-
tests={
|
|
115
|
-
Input(agent_type="Explore"): Rewrite(model="sonnet"),
|
|
116
|
-
Input(agent_type="Explore", model="haiku"): Allow(),
|
|
117
|
-
Input(agent_type="general-purpose"): Allow(),
|
|
118
|
-
},
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
llm_nudge(
|
|
122
|
-
"""Decide whether this delegated subagent should run on opus-4.8 instead of fable-5.
|
|
123
|
-
|
|
124
|
-
<delegated_spawn> holds the pending Agent/Task call: its model pin (or that it inherits
|
|
125
|
-
the session model, fable), agent type, and prompt.
|
|
126
|
-
|
|
127
|
-
The Models rubric: implementation delegates to opus-4.8 at xhigh — opus is ~2x cheaper
|
|
128
|
-
than fable and nearly as capable. Fable's lanes are orchestration, review, hard
|
|
129
|
-
planning/design/diagnosis, all prose/writing, and implementation that is very sensitive
|
|
130
|
-
or error-prone (auth, migrations, concurrency, data loss, crypto, subtle algorithms).
|
|
131
|
-
|
|
132
|
-
Set fire=true only when the prompt is clearly routine implementation — building, fixing,
|
|
133
|
-
wiring, or refactoring code — with no fable-lane signal. A prompt that reviews, plans,
|
|
134
|
-
designs, diagnoses a hard bug, writes prose, or touches a sensitive surface stays on
|
|
135
|
-
fable: fire=false. When uncertain, fire=false — the agent may have chosen fable
|
|
136
|
-
deliberately, and a false alarm teaches it to ignore this nudge. Keep reasoning under
|
|
137
|
-
40 words.
|
|
138
|
-
|
|
139
|
-
<examples>
|
|
140
|
-
<example fire="true">
|
|
141
|
-
Implement the pagination endpoint in api/users.py per the spec in the plan.
|
|
142
|
-
Routine implementation with no sensitivity signal — the opus xhigh lane.
|
|
143
|
-
</example>
|
|
144
|
-
<example fire="true">
|
|
145
|
-
Add a --json flag to the export command and thread it through the formatter.
|
|
146
|
-
Well-scoped feature wiring; the default implementation lane.
|
|
147
|
-
</example>
|
|
148
|
-
<example fire="false">
|
|
149
|
-
Review the diff for correctness and concurrency issues.
|
|
150
|
-
Review is fable's lane.
|
|
151
|
-
</example>
|
|
152
|
-
<example fire="false">
|
|
153
|
-
Design the migration strategy for the sharded session store.
|
|
154
|
-
Hard planning/design stays on fable.
|
|
155
|
-
</example>
|
|
156
|
-
<example fire="false">
|
|
157
|
-
Implement the token-refresh race fix in the auth middleware.
|
|
158
|
-
Auth plus concurrency: sensitive, error-prone implementation stays on fable.
|
|
159
|
-
</example>
|
|
160
|
-
</examples>""",
|
|
161
|
-
message=lambda r: (
|
|
162
|
-
f"This delegation would run on fable, but it reads as routine implementation. {r.reasoning} "
|
|
163
|
-
"Implementation defaults to model='opus' + effort='xhigh' (~2x cheaper, nearly as capable); "
|
|
164
|
-
"a well-scoped edit to existing code can also go to gpt-5.5 via the codex skill behind a "
|
|
165
|
-
"model='sonnet' low-effort wrapper. Keep fable if this genuinely is sensitive or error-prone. "
|
|
166
|
-
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
167
|
-
),
|
|
168
|
-
contexts=[DelegatedSpawn()],
|
|
169
|
-
events=Event.PreToolUse,
|
|
170
|
-
only_if=[Tool("Agent|Task")],
|
|
171
|
-
skip_if=[
|
|
172
|
-
ToolInput("model", r"(?i)\b(opus|sonnet|haiku)\b"),
|
|
173
|
-
Agent("Explore|claude-code-guide"),
|
|
174
|
-
],
|
|
175
|
-
agent=False,
|
|
176
|
-
transcript=False,
|
|
177
|
-
tests={
|
|
178
|
-
Input(prompt="implement the pagination endpoint in api/users.py"): Warn(pattern="opus"),
|
|
179
|
-
Input(model="fable", prompt="add a --json flag to the export command"): Warn(pattern="opus"),
|
|
180
|
-
Input(model="opus", prompt="implement the pagination endpoint in api/users.py"): Allow(),
|
|
181
|
-
Input(model="sonnet", prompt="scan the repo for TODO markers"): Allow(),
|
|
182
|
-
Input(agent_type="Explore", prompt="find where the config loader lives"): Allow(),
|
|
183
|
-
},
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
nudge(
|
|
187
|
-
"""
|
|
188
|
-
This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
|
|
189
|
-
map steps; a judgment-bearing stage should inherit the session model or route up. See
|
|
190
|
-
CLAUDE.md § Plan Execution & Orchestration (Models).
|
|
191
|
-
""",
|
|
192
|
-
only_if=[Tool("Workflow"), WorkflowScript(model="haiku")],
|
|
193
|
-
events=Event.PreToolUse,
|
|
194
|
-
max_fires=2,
|
|
195
|
-
tests={
|
|
196
|
-
Input(script="steps:\n - agent: reviewer\n model: 'haiku'\n"): Warn(),
|
|
197
|
-
Input(script="steps:\n - agent: reviewer\n model: 'sonnet'\n"): Allow(),
|
|
198
|
-
},
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
nudge(
|
|
202
|
-
"""
|
|
203
|
-
This workflow script pins a non-fable model on what looks like prose/writing stages. All
|
|
204
|
-
writing — docs, READMEs, release notes, any user-facing text — routes to fable: inherit the
|
|
205
|
-
session model or pin model: 'fable'. See CLAUDE.md § Plan Execution & Orchestration (Models).
|
|
206
|
-
""",
|
|
207
|
-
only_if=[
|
|
208
|
-
Tool("Workflow"),
|
|
209
|
-
WorkflowScript(
|
|
210
|
-
pattern=r"(?i)\b(readme|docs?|documentation|blog|changelog|release notes|prose)\b",
|
|
211
|
-
model="haiku|sonnet|opus",
|
|
212
|
-
),
|
|
213
|
-
],
|
|
214
|
-
events=Event.PreToolUse,
|
|
215
|
-
max_fires=2,
|
|
216
|
-
tests={
|
|
217
|
-
Input(script="steps:\n - agent: write the README intro\n model: 'sonnet'\n"): Warn(),
|
|
218
|
-
Input(script="steps:\n - agent: write the README intro\n model: 'fable'\n"): Allow(),
|
|
219
|
-
Input(script="steps:\n - agent: fix the retry backoff\n model: 'sonnet'\n"): Allow(),
|
|
220
|
-
},
|
|
221
|
-
)
|
|
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
|
|
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-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md
RENAMED
|
File without changes
|
|
File without changes
|
{capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md
RENAMED
|
File without changes
|
|
File without changes
|
{capt_hook-6.1.0 → capt_hook-6.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md
RENAMED
|
File without changes
|
{capt_hook-6.1.0 → capt_hook-6.3.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
|
|
File without changes
|
|
File without changes
|