capt-hook 5.1.0__tar.gz → 6.0.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-5.1.0 → capt_hook-6.0.0}/PKG-INFO +2 -2
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/__init__.py +4 -1
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/ast_grep.py +67 -16
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/cli.py +25 -5
- capt_hook-6.0.0/captain_hook/contexts.py +151 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/durable.py +3 -1
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/events.py +35 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/loader.py +20 -1
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/capt-hook.toml +2 -1
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/models.py +89 -1
- capt_hook-6.0.0/captain_hook/packs/general/tombstones.py +189 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/manager.py +9 -1
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/steering/capt-hook.toml +2 -1
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/llm.py +42 -1
- capt_hook-6.0.0/captain_hook/signals/nlp.py +203 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +16 -4
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +1 -1
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/state.py +9 -7
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/testing/helpers.py +72 -35
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/testing/types.py +2 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/types.py +3 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/model_cache.py +24 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/pyproject.toml +2 -2
- capt_hook-5.1.0/captain_hook/signals/nlp.py +0 -107
- {capt_hook-5.1.0 → capt_hook-6.0.0}/LICENSE +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/README.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/__main__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/app.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/command.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/conditions.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/context.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/decisions.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/fields.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/file.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/log.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/steering/steering.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/prompt.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/py.typed +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/store.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/session.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/settings.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/types.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/tasks.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/http.py +0 -0
- {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/text.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capt-hook
|
|
3
|
-
Version:
|
|
3
|
+
Version: 6.0.0
|
|
4
4
|
Summary: Declarative hook framework for Claude Code
|
|
5
5
|
Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
|
|
6
6
|
Author: Yasyf Mohamedali
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
16
16
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
17
|
Classifier: Topic :: Software Development :: Testing
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: cc-transcript>=7,<8
|
|
19
|
+
Requires-Dist: cc-transcript>=7.1,<8
|
|
20
20
|
Requires-Dist: pydantic>=2.0
|
|
21
21
|
Requires-Dist: pydantic-settings>=2.0
|
|
22
22
|
Requires-Dist: tree-sitter>=0.24
|
|
@@ -21,8 +21,10 @@ from cc_transcript.tools import (
|
|
|
21
21
|
|
|
22
22
|
from captain_hook import file, style, util
|
|
23
23
|
from captain_hook.app import hook, on
|
|
24
|
+
from captain_hook.ast_grep import COMMENT_TYPES
|
|
24
25
|
from captain_hook.command import CommandLine, ParsedCommand, Redirect
|
|
25
26
|
from captain_hook.context import HookContext
|
|
27
|
+
from captain_hook.contexts import AfterEdit, BeforeEdit, Introduced, PromptContext, apply_contexts
|
|
26
28
|
from captain_hook.durable import DurableSlot, DurableState, DurableStore
|
|
27
29
|
from captain_hook.events import (
|
|
28
30
|
BaseHookEvent,
|
|
@@ -32,6 +34,7 @@ from captain_hook.events import (
|
|
|
32
34
|
PreCompactEvent,
|
|
33
35
|
PreToolUseEvent,
|
|
34
36
|
SessionEndEvent,
|
|
37
|
+
SessionStartEvent,
|
|
35
38
|
StopEvent,
|
|
36
39
|
SubagentStartEvent,
|
|
37
40
|
SubagentStopEvent,
|
|
@@ -66,7 +69,7 @@ from captain_hook.primitives.workflow import Artifact, Step, Workflow, text_matc
|
|
|
66
69
|
from captain_hook.prompt import Prompt
|
|
67
70
|
from captain_hook.session import SessionSlot, SessionStore, session_state
|
|
68
71
|
from captain_hook.settings import HooksSettings, build_settings
|
|
69
|
-
from captain_hook.signals.nlp import Clause, NlpSignal, Phrase
|
|
72
|
+
from captain_hook.signals.nlp import Clause, NlpSignal, Phrase, has_nominal_subject, is_past_predicate
|
|
70
73
|
from captain_hook.state import HookState, PrimitiveState, WorkflowState, workflow_state
|
|
71
74
|
from captain_hook.tasks import Task, Tasks
|
|
72
75
|
from captain_hook.testing import Allow, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
|
|
@@ -14,7 +14,7 @@ long names (``"python"``).
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import re
|
|
17
|
-
from collections.abc import Iterator
|
|
17
|
+
from collections.abc import Iterable, Iterator, Set
|
|
18
18
|
from dataclasses import dataclass
|
|
19
19
|
from typing import TYPE_CHECKING
|
|
20
20
|
|
|
@@ -29,6 +29,13 @@ EXT_TO_LANG: dict[str, str] = {glob.removeprefix("*."): lang for lang, globs in
|
|
|
29
29
|
|
|
30
30
|
TEMPLATE_VAR = re.compile(r"\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)")
|
|
31
31
|
|
|
32
|
+
COMMENT_TYPES: frozenset[str] = frozenset({"comment", "line_comment", "block_comment"})
|
|
33
|
+
"""Tree-sitter node kinds that denote a comment, across every supported grammar.
|
|
34
|
+
|
|
35
|
+
The union covers every [`LANG_GLOBS`][captain_hook.types.LANG_GLOBS] grammar; a future grammar
|
|
36
|
+
whose top-level comment kind is named differently would silently miss.
|
|
37
|
+
"""
|
|
38
|
+
|
|
32
39
|
|
|
33
40
|
@dataclass(frozen=True, slots=True)
|
|
34
41
|
class Match:
|
|
@@ -39,6 +46,31 @@ class Match:
|
|
|
39
46
|
text: str
|
|
40
47
|
|
|
41
48
|
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class SyntaxNode:
|
|
51
|
+
"""One node of a parsed syntax tree — the framework's face over the ast-grep binding node."""
|
|
52
|
+
|
|
53
|
+
raw: SgNode
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def kind(self) -> str:
|
|
57
|
+
return self.raw.kind()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def text(self) -> str:
|
|
61
|
+
return self.raw.text()
|
|
62
|
+
|
|
63
|
+
def descendants(self) -> Iterator[SyntaxNode]:
|
|
64
|
+
"""Every node below this one, in document order."""
|
|
65
|
+
for raw in self.raw.children():
|
|
66
|
+
yield (child := SyntaxNode(raw))
|
|
67
|
+
yield from child.descendants()
|
|
68
|
+
|
|
69
|
+
def to_match(self) -> Match:
|
|
70
|
+
r = self.raw.range()
|
|
71
|
+
return Match(line=r.start.line + 1, end_line=r.end.line + 1, text=self.raw.text())
|
|
72
|
+
|
|
73
|
+
|
|
42
74
|
def lang_for_path(path: Path) -> str | None:
|
|
43
75
|
"""Infer an ast-grep language id from a file extension, or ``None`` when unsupported."""
|
|
44
76
|
return EXT_TO_LANG.get(path.suffix.removeprefix("."))
|
|
@@ -49,15 +81,10 @@ def has_metavar(text: str) -> bool:
|
|
|
49
81
|
return TEMPLATE_VAR.search(text) is not None
|
|
50
82
|
|
|
51
83
|
|
|
52
|
-
def parse(source: str, lang: str) ->
|
|
84
|
+
def parse(source: str, lang: str) -> SyntaxNode:
|
|
53
85
|
from ast_grep_py import SgRoot
|
|
54
86
|
|
|
55
|
-
return SgRoot(source, lang).root()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def to_match(node: SgNode) -> Match:
|
|
59
|
-
r = node.range()
|
|
60
|
-
return Match(line=r.start.line + 1, end_line=r.end.line + 1, text=node.text())
|
|
87
|
+
return SyntaxNode(SgRoot(source, lang).root())
|
|
61
88
|
|
|
62
89
|
|
|
63
90
|
def match_key(m: Match) -> str:
|
|
@@ -66,22 +93,46 @@ def match_key(m: Match) -> str:
|
|
|
66
93
|
|
|
67
94
|
def matches(source: str, lang: str, pattern: str) -> bool:
|
|
68
95
|
"""Whether ``pattern`` matches anywhere in ``source`` — the cheap boolean for conditions."""
|
|
69
|
-
return parse(source, lang).find(pattern=pattern) is not None
|
|
96
|
+
return parse(source, lang).raw.find(pattern=pattern) is not None
|
|
70
97
|
|
|
71
98
|
|
|
72
99
|
def find_all(source: str, lang: str, pattern: str) -> Iterator[Match]:
|
|
73
100
|
"""Every structural match of ``pattern`` in ``source``, as 1-based-line :class:`Match` objects."""
|
|
74
|
-
return (
|
|
101
|
+
return (SyntaxNode(node).to_match() for node in parse(source, lang).raw.find_all(pattern=pattern))
|
|
75
102
|
|
|
76
103
|
|
|
77
|
-
def
|
|
78
|
-
"""
|
|
104
|
+
def find_kinds(source: str, lang: str, kinds: Set[str]) -> Iterator[Match]:
|
|
105
|
+
"""Every node whose tree-sitter kind is in ``kinds``, in document order.
|
|
106
|
+
|
|
107
|
+
Kinds are read off parsed nodes rather than passed to the ast-grep kind
|
|
108
|
+
matcher, which raises on kind names a grammar doesn't define.
|
|
109
|
+
"""
|
|
110
|
+
return (n.to_match() for n in parse(source, lang).descendants() if n.kind in kinds)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def comments(source: str, lang: str) -> Iterator[Match]:
|
|
114
|
+
"""Every comment in ``source``, in document order — regardless of language."""
|
|
115
|
+
return find_kinds(source, lang, COMMENT_TYPES)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def introduced(old: Iterable[Match], new: Iterable[Match]) -> Iterator[Match]:
|
|
119
|
+
"""Matches in ``new`` whose construct was absent from ``old``.
|
|
79
120
|
|
|
80
121
|
Identity is the match's whitespace-normalized text, not its range (which shifts as
|
|
81
122
|
surrounding code moves) — so a pre-existing construct is never reported as newly added.
|
|
82
123
|
"""
|
|
83
|
-
before = {match_key(m) for m in
|
|
84
|
-
return (m for m in
|
|
124
|
+
before = {match_key(m) for m in old}
|
|
125
|
+
return (m for m in new if match_key(m) not in before)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def find_introduced(old: str, new: str, lang: str, pattern: str) -> Iterator[Match]:
|
|
129
|
+
"""Matches of ``pattern`` present in ``new`` but absent from ``old`` — the diff helper."""
|
|
130
|
+
return introduced(find_all(old, lang, pattern), find_all(new, lang, pattern))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def introduced_comments(old: str, new: str, lang: str) -> Iterator[Match]:
|
|
134
|
+
"""Comments present in ``new`` whose text was absent from ``old``."""
|
|
135
|
+
return introduced(comments(old, lang), comments(new, lang))
|
|
85
136
|
|
|
86
137
|
|
|
87
138
|
def rewrite(source: str, lang: str, pattern: str, replace: str) -> str:
|
|
@@ -90,7 +141,7 @@ def rewrite(source: str, lang: str, pattern: str, replace: str) -> str:
|
|
|
90
141
|
``replace`` uses ast-grep's ``$VAR`` / ``$$$VAR`` fix syntax, each metavariable filled from the
|
|
91
142
|
match it names. Returns ``source`` unchanged when nothing matches.
|
|
92
143
|
"""
|
|
93
|
-
root = parse(source, lang)
|
|
144
|
+
root = parse(source, lang).raw
|
|
94
145
|
if not (edits := [node.replace(fill_template(node, replace)) for node in root.find_all(pattern=pattern)]):
|
|
95
146
|
return source
|
|
96
147
|
return root.commit_edits(edits)
|
|
@@ -103,7 +154,7 @@ def capture(source: str, lang: str, pattern: str) -> dict[str, str] | None:
|
|
|
103
154
|
original-source span covering its matches, so whitespace is preserved (mirroring :func:`fill_template`).
|
|
104
155
|
A pattern with no metavars that still matches yields an empty dict — present but empty.
|
|
105
156
|
"""
|
|
106
|
-
if (node := parse(source, lang).find(pattern=pattern)) is None:
|
|
157
|
+
if (node := parse(source, lang).raw.find(pattern=pattern)) is None:
|
|
107
158
|
return None
|
|
108
159
|
|
|
109
160
|
def value(m: re.Match[str]) -> str:
|
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
10
|
from collections import defaultdict
|
|
11
|
+
from collections.abc import Sequence
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Any
|
|
@@ -18,7 +19,7 @@ from cc_transcript.ids import SessionId
|
|
|
18
19
|
from captain_hook.app import _state, load_gitignore, reset
|
|
19
20
|
from captain_hook.context import HookContext, load_transcript
|
|
20
21
|
from captain_hook.dispatch import dispatch
|
|
21
|
-
from captain_hook.loader import CONF_MODULE, discover_hooks, discover_pack, is_skip_marked
|
|
22
|
+
from captain_hook.loader import CONF_MODULE, discover_hooks, discover_pack, is_skip_marked, register_nlp_provisioning
|
|
22
23
|
from captain_hook.log import setup_logging
|
|
23
24
|
from captain_hook.packs import manager
|
|
24
25
|
from captain_hook.review.cli import review
|
|
@@ -35,19 +36,22 @@ class CliState:
|
|
|
35
36
|
root: Path
|
|
36
37
|
hooks: str
|
|
37
38
|
|
|
38
|
-
def discover(self) ->
|
|
39
|
+
def discover(self) -> list[manager.ResolvedPack]:
|
|
39
40
|
reset()
|
|
40
41
|
load_gitignore(self.root)
|
|
41
42
|
discover_hooks(self.hooks)
|
|
42
43
|
resolved, missing = manager.resolve_enabled_packs(self.root)
|
|
43
44
|
for pack_ in resolved:
|
|
44
45
|
discover_pack(pack_.entry.name, pack_.path)
|
|
46
|
+
if any(pack_.manifest.nlp for pack_ in resolved):
|
|
47
|
+
register_nlp_provisioning()
|
|
45
48
|
if missing:
|
|
46
49
|
print(
|
|
47
50
|
f"capt-hook: packs unavailable (offline and not cached): {', '.join(missing)} "
|
|
48
51
|
"— run `capt-hook pack update` when online",
|
|
49
52
|
file=sys.stderr,
|
|
50
53
|
)
|
|
54
|
+
return resolved
|
|
51
55
|
|
|
52
56
|
|
|
53
57
|
def example_hook_source() -> str:
|
|
@@ -222,12 +226,26 @@ def print_hook_summary(label: str, summary: dict[str, str], deferred_to: str) ->
|
|
|
222
226
|
click.echo(f" deferred to {deferred_to}: {', '.join(deferred)}")
|
|
223
227
|
|
|
224
228
|
|
|
229
|
+
def provision_nlp(resolved: Sequence[manager.ResolvedPack]) -> None:
|
|
230
|
+
from captain_hook.util import http
|
|
231
|
+
from captain_hook.util.model_cache import ensure_nlp_resources
|
|
232
|
+
|
|
233
|
+
if not any(pack_.manifest.nlp for pack_ in resolved):
|
|
234
|
+
return
|
|
235
|
+
click.echo("Provisioning NLP resources (spaCy en_core_web_sm ~13MB + oewn:2025 lexicon ~231MB, cached)...")
|
|
236
|
+
try:
|
|
237
|
+
ensure_nlp_resources()
|
|
238
|
+
except (http.GitHubFetchError, OSError) as e:
|
|
239
|
+
click.echo(f" deferred (offline?): {e} — the SessionStart hook will retry at session start")
|
|
240
|
+
|
|
241
|
+
|
|
225
242
|
def regenerate_settings(state: CliState) -> None:
|
|
226
|
-
state.discover()
|
|
243
|
+
resolved = state.discover()
|
|
227
244
|
settings_path = state.root / ".claude" / "settings.json"
|
|
228
245
|
merged, summary = merge_settings(".claude/hooks", settings_path)
|
|
229
246
|
write_settings(settings_path, merged)
|
|
230
247
|
print_hook_summary(str(settings_path.relative_to(state.root)), summary, sibling_settings(settings_path).name)
|
|
248
|
+
provision_nlp(resolved)
|
|
231
249
|
|
|
232
250
|
|
|
233
251
|
def settings_drift(root: Path) -> set[str]:
|
|
@@ -320,9 +338,10 @@ def init_project(root: Path, *, review: bool = True) -> None:
|
|
|
320
338
|
example.write_text(example_hook_source())
|
|
321
339
|
|
|
322
340
|
settings_path = root / ".claude" / "settings.json"
|
|
323
|
-
CliState(root=root, hooks=str(hooks_dir)).discover()
|
|
341
|
+
resolved = CliState(root=root, hooks=str(hooks_dir)).discover()
|
|
324
342
|
merged, summary = merge_settings(".claude/hooks", settings_path)
|
|
325
343
|
write_settings(settings_path, merged)
|
|
344
|
+
provision_nlp(resolved)
|
|
326
345
|
|
|
327
346
|
register_marketplace(root)
|
|
328
347
|
|
|
@@ -507,7 +526,7 @@ def run(state: CliState, event: str, async_: bool) -> None:
|
|
|
507
526
|
@click.pass_obj
|
|
508
527
|
def register_hooks_cmd(state: CliState, hooks_dir: str, dry_run: bool, from_source: str) -> None:
|
|
509
528
|
"""Register captain-hook's event hooks into .claude/settings.json."""
|
|
510
|
-
state.discover()
|
|
529
|
+
resolved = state.discover()
|
|
511
530
|
settings_path = state.root / ".claude" / "settings.json"
|
|
512
531
|
merged, summary = merge_settings(hooks_dir, settings_path, from_source=from_source)
|
|
513
532
|
if dry_run:
|
|
@@ -515,6 +534,7 @@ def register_hooks_cmd(state: CliState, hooks_dir: str, dry_run: bool, from_sour
|
|
|
515
534
|
return
|
|
516
535
|
write_settings(settings_path, merged)
|
|
517
536
|
print_hook_summary(str(settings_path), summary, sibling_settings(settings_path).name)
|
|
537
|
+
provision_nlp(resolved)
|
|
518
538
|
|
|
519
539
|
|
|
520
540
|
@cli.command()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Declarative prompt contexts: named XML evidence blocks attached to LLM primitives.
|
|
2
|
+
|
|
3
|
+
A :class:`PromptContext` resolves one ``<tag>…</tag>`` block from the current event at
|
|
4
|
+
evaluation time. Pass instances via ``contexts=[...]`` on ``llm_nudge``/``llm_gate``;
|
|
5
|
+
a ``required`` context whose content is empty skips the LLM call entirely. The ambient
|
|
6
|
+
defaults :class:`BeforeEdit` and :class:`AfterEdit` attach to every primitive, carrying
|
|
7
|
+
the pending edit's before/after text on edit-shaped events and nothing elsewhere.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import partial
|
|
15
|
+
from typing import TYPE_CHECKING, Protocol
|
|
16
|
+
|
|
17
|
+
from captain_hook.ast_grep import find_all, find_kinds, introduced, lang_for_path
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Sequence, Set
|
|
21
|
+
|
|
22
|
+
from captain_hook.events import BaseHookEvent
|
|
23
|
+
from captain_hook.prompt import Prompt
|
|
24
|
+
|
|
25
|
+
SNAKE_CASE = re.compile(r"(?<!^)(?=[A-Z])")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PromptContext(Protocol):
|
|
29
|
+
"""One declarative XML block attached to an LLM primitive's prompt.
|
|
30
|
+
|
|
31
|
+
``content(evt)`` runs at evaluation time; ``None`` or empty content omits the
|
|
32
|
+
block. When a ``required`` context yields empty content, the primitive skips
|
|
33
|
+
the LLM call entirely — nothing fires and no fire is consumed.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
tag: str
|
|
37
|
+
required: bool
|
|
38
|
+
|
|
39
|
+
def content(self, evt: BaseHookEvent) -> str | None: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class BeforeEdit:
|
|
44
|
+
"""The pending edit's pre-image, as a ``<before_edit>`` block.
|
|
45
|
+
|
|
46
|
+
Reads :attr:`~captain_hook.events.ToolHookEvent.replaced` — an Edit's old text, a
|
|
47
|
+
MultiEdit's joined olds, or a Write's current on-disk content — so it carries
|
|
48
|
+
content only at ``PreToolUse`` on edit-shaped events. Attached to every LLM
|
|
49
|
+
primitive as a default context; ``required=False`` because it is ambient
|
|
50
|
+
enrichment, empty (and omitted) on non-edit events. Pass ``BeforeEdit(required=True)``
|
|
51
|
+
explicitly to gate a hook on a non-empty pre-image.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
tag: str = "before_edit"
|
|
55
|
+
required: bool = False
|
|
56
|
+
|
|
57
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
58
|
+
return evt.replaced
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class AfterEdit:
|
|
63
|
+
"""The pending edit's new text, as an ``<after_edit>`` block.
|
|
64
|
+
|
|
65
|
+
Reads :attr:`~captain_hook.events.BaseHookEvent.content` — the text an Edit,
|
|
66
|
+
Write, MultiEdit, or NotebookEdit is about to land. Attached to every LLM
|
|
67
|
+
primitive as a default context; ``required=False`` because it is ambient
|
|
68
|
+
enrichment, empty (and omitted) on non-edit events.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
tag: str = "after_edit"
|
|
72
|
+
required: bool = False
|
|
73
|
+
|
|
74
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
75
|
+
return evt.content
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class Introduced:
|
|
80
|
+
"""Constructs the pending edit newly introduces, as an auto-tagged block.
|
|
81
|
+
|
|
82
|
+
Exactly one of ``kind``/``pattern`` selects the constructs: ``kind`` names
|
|
83
|
+
tree-sitter node kinds (a bare string or any set — normalized to ``frozenset``;
|
|
84
|
+
see :data:`~captain_hook.ast_grep.COMMENT_TYPES`), ``pattern`` is an ast-grep
|
|
85
|
+
pattern. Extraction diffs the event's before/after text, so only constructs
|
|
86
|
+
absent before the edit appear; files without a supported language yield nothing.
|
|
87
|
+
|
|
88
|
+
``required`` defaults to ``True``: a context you attach explicitly IS the
|
|
89
|
+
evidence — no evidence, no LLM call. ``tag`` auto-derives from the class name in
|
|
90
|
+
snake_case (``Introduced()`` renders ``<introduced>``; a subclass named
|
|
91
|
+
``TombstoneComments`` renders ``<tombstone_comments>``). Subclass and override
|
|
92
|
+
:meth:`keep` to filter which introduced constructs count.
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
>>> llm_nudge("...", contexts=[Introduced(pattern="print($$$)")],
|
|
96
|
+
... events=Event.PreToolUse, only_if=[Tool("Edit", "Write", "MultiEdit")])
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
kind: str | Set[str] | None = None
|
|
100
|
+
pattern: str | None = None
|
|
101
|
+
required: bool = True
|
|
102
|
+
tag: str | None = None
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
if (self.kind is None) == (self.pattern is None):
|
|
106
|
+
raise ValueError(f"{type(self).__name__} requires exactly one of kind or pattern")
|
|
107
|
+
if self.kind is not None and not isinstance(self.kind, frozenset):
|
|
108
|
+
object.__setattr__(self, "kind", frozenset([self.kind] if isinstance(self.kind, str) else self.kind))
|
|
109
|
+
if self.tag is None:
|
|
110
|
+
object.__setattr__(self, "tag", SNAKE_CASE.sub("_", type(self).__name__).lower())
|
|
111
|
+
|
|
112
|
+
def keep(self, text: str) -> bool:
|
|
113
|
+
"""Whether an introduced construct's text belongs in the block — override to filter."""
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
117
|
+
if (
|
|
118
|
+
not (file := evt.file)
|
|
119
|
+
or not (lang := lang_for_path(file.path))
|
|
120
|
+
or (old := evt.replaced) is None
|
|
121
|
+
or (new := evt.content) is None
|
|
122
|
+
):
|
|
123
|
+
return None
|
|
124
|
+
extract = (
|
|
125
|
+
partial(find_kinds, lang=lang, kinds=self.kind)
|
|
126
|
+
if self.pattern is None
|
|
127
|
+
else partial(find_all, lang=lang, pattern=self.pattern)
|
|
128
|
+
)
|
|
129
|
+
return "\n".join(m.text for m in introduced(extract(old), extract(new)) if self.keep(m.text))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def with_defaults(contexts: Sequence[PromptContext]) -> tuple[PromptContext, ...]:
|
|
133
|
+
defaults: tuple[PromptContext, ...] = (BeforeEdit(), AfterEdit())
|
|
134
|
+
return (*contexts, *(d for d in defaults if not any(isinstance(c, type(d)) for c in contexts)))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def apply_contexts(
|
|
138
|
+
prompt: Prompt, evt: BaseHookEvent, contexts: Sequence[PromptContext], *, max_len: int = 2000
|
|
139
|
+
) -> Prompt | None:
|
|
140
|
+
"""Append each context's block to ``prompt`` in order, each capped at ``max_len`` characters.
|
|
141
|
+
|
|
142
|
+
A context yielding ``None`` or whitespace-only content has its block omitted;
|
|
143
|
+
when that context is ``required``, returns ``None`` instead — the caller must
|
|
144
|
+
skip the LLM call entirely.
|
|
145
|
+
"""
|
|
146
|
+
for c in contexts:
|
|
147
|
+
text = c.content(evt)
|
|
148
|
+
if c.required and not (text and text.strip()):
|
|
149
|
+
return None
|
|
150
|
+
prompt = prompt.context(c.tag, text and text[:max_len])
|
|
151
|
+
return prompt
|
|
@@ -26,11 +26,13 @@ if TYPE_CHECKING:
|
|
|
26
26
|
|
|
27
27
|
Scope = Literal["project", "global"]
|
|
28
28
|
|
|
29
|
+
DURABLE_ROOT_OVERRIDE: list[Path] = []
|
|
30
|
+
|
|
29
31
|
|
|
30
32
|
def durable_root() -> Path:
|
|
31
33
|
from captain_hook.settings import resolve_state_dir
|
|
32
34
|
|
|
33
|
-
return resolve_state_dir() / "hooks" / "durable"
|
|
35
|
+
return DURABLE_ROOT_OVERRIDE[-1] if DURABLE_ROOT_OVERRIDE else resolve_state_dir() / "hooks" / "durable"
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def project_key(repo_root: Path) -> str:
|
|
@@ -129,6 +129,10 @@ class BaseHookEvent:
|
|
|
129
129
|
def old(self) -> str | None:
|
|
130
130
|
return None
|
|
131
131
|
|
|
132
|
+
@property
|
|
133
|
+
def replaced(self) -> str | None:
|
|
134
|
+
return None
|
|
135
|
+
|
|
132
136
|
@property
|
|
133
137
|
def agent_type(self) -> str | None:
|
|
134
138
|
return None
|
|
@@ -227,6 +231,23 @@ class ToolHookEvent(BaseHookEvent):
|
|
|
227
231
|
case _:
|
|
228
232
|
return None
|
|
229
233
|
|
|
234
|
+
@property
|
|
235
|
+
def replaced(self) -> str | None:
|
|
236
|
+
"""The pre-image of the text this call overwrites.
|
|
237
|
+
|
|
238
|
+
An Edit's ``old``, a MultiEdit's newline-joined olds, or — for a Write — the
|
|
239
|
+
file's current on-disk content (``""`` for a new file). ``None`` for other
|
|
240
|
+
tools. The Write arm reads the disk, so it is only meaningful at
|
|
241
|
+
``PreToolUse``: once the Write lands, disk already holds the new text.
|
|
242
|
+
"""
|
|
243
|
+
match self.input:
|
|
244
|
+
case EditCall() | MultiEditCall():
|
|
245
|
+
return self.old
|
|
246
|
+
case WriteCall(file_path=path):
|
|
247
|
+
return p.read_text() if (p := Path(path)).exists() else ""
|
|
248
|
+
case _:
|
|
249
|
+
return None
|
|
250
|
+
|
|
230
251
|
@property
|
|
231
252
|
def agent_type(self) -> str | None:
|
|
232
253
|
return call.agent_type if isinstance(call := self.input, TaskCall) else None
|
|
@@ -383,6 +404,20 @@ class NotificationEvent(BaseHookEvent):
|
|
|
383
404
|
return self._raw.get("notification_type")
|
|
384
405
|
|
|
385
406
|
|
|
407
|
+
@dataclass
|
|
408
|
+
class SessionStartEvent(BaseHookEvent):
|
|
409
|
+
"""Fires when a session starts, providing what triggered it. Warns inject context; it cannot block.
|
|
410
|
+
|
|
411
|
+
``source`` is one of ``"startup"``, ``"resume"``, ``"clear"``, or ``"compact"``.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
event_name: ClassVar[Event] = Event.SessionStart
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def source(self) -> str:
|
|
418
|
+
return self._raw["source"]
|
|
419
|
+
|
|
420
|
+
|
|
386
421
|
@dataclass
|
|
387
422
|
class SessionEndEvent(BaseHookEvent):
|
|
388
423
|
"""Fires when the session ends, providing the termination reason. Output is ignored, so it cannot block."""
|
|
@@ -9,11 +9,16 @@ import re
|
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from types import ModuleType
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
12
13
|
|
|
13
14
|
from loguru import logger
|
|
14
15
|
|
|
15
|
-
from captain_hook.app import State, _state
|
|
16
|
+
from captain_hook.app import State, _state, on
|
|
16
17
|
from captain_hook.settings import build_settings
|
|
18
|
+
from captain_hook.types import Event
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from captain_hook.events import BaseHookEvent
|
|
17
22
|
|
|
18
23
|
CONF_MODULE = "conf"
|
|
19
24
|
PACK_PACKAGE_PREFIX = "captain_hook._packs"
|
|
@@ -105,6 +110,20 @@ def import_pack_module(fqn: str, path: Path) -> ModuleType:
|
|
|
105
110
|
return module
|
|
106
111
|
|
|
107
112
|
|
|
113
|
+
def register_nlp_provisioning() -> None:
|
|
114
|
+
"""Register the shared SessionStart hook that eagerly provisions NLP resources.
|
|
115
|
+
|
|
116
|
+
Called once per discovery pass when any resolved pack manifest declares ``nlp``,
|
|
117
|
+
so both event dispatch and settings generation see the async hook.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
@on(Event.SessionStart, async_=True)
|
|
121
|
+
def provision_nlp_resources(evt: BaseHookEvent) -> None:
|
|
122
|
+
from captain_hook.util.model_cache import ensure_nlp_resources
|
|
123
|
+
|
|
124
|
+
ensure_nlp_resources()
|
|
125
|
+
|
|
126
|
+
|
|
108
127
|
def discover_pack(name: str, pack_dir: Path) -> None:
|
|
109
128
|
pkg = f"{PACK_PACKAGE_PREFIX}.{re.sub(r'\W', '_', name)}"
|
|
110
129
|
ensure_pack_package(PACK_PACKAGE_PREFIX, [])
|
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
3
5
|
from captain_hook import (
|
|
4
6
|
Agent,
|
|
5
7
|
Allow,
|
|
8
|
+
BaseHookEvent,
|
|
6
9
|
Block,
|
|
7
10
|
Event,
|
|
8
11
|
Input,
|
|
9
12
|
Rewrite,
|
|
13
|
+
TaskCall,
|
|
10
14
|
Tool,
|
|
11
15
|
ToolInput,
|
|
12
16
|
Warn,
|
|
13
17
|
WorkflowScript,
|
|
14
18
|
hook,
|
|
19
|
+
llm_nudge,
|
|
15
20
|
nudge,
|
|
16
21
|
set_tool_input,
|
|
17
22
|
)
|
|
18
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
|
+
|
|
19
39
|
hook(
|
|
20
40
|
Event.PreToolUse,
|
|
21
41
|
only_if=[Tool("Agent|Task"), ToolInput("model", r"(?i)\bhaiku\b")],
|
|
@@ -51,7 +71,10 @@ hook(
|
|
|
51
71
|
only_if=[
|
|
52
72
|
Tool("Agent|Task"),
|
|
53
73
|
ToolInput("model", r"(?i)\b(haiku|sonnet|opus)\b"),
|
|
54
|
-
ToolInput(
|
|
74
|
+
ToolInput(
|
|
75
|
+
"prompt",
|
|
76
|
+
r"(?i)\b(writ(e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)",
|
|
77
|
+
),
|
|
55
78
|
ToolInput(
|
|
56
79
|
"prompt",
|
|
57
80
|
r"(?i)\b(readme|docs?|documentation|blog|changelog|release notes|announcement"
|
|
@@ -95,6 +118,71 @@ set_tool_input(
|
|
|
95
118
|
},
|
|
96
119
|
)
|
|
97
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
|
+
|
|
98
186
|
nudge(
|
|
99
187
|
"""
|
|
100
188
|
This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
|