capt-hook 6.3.0__tar.gz → 6.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {capt_hook-6.3.0 → capt_hook-6.4.0}/PKG-INFO +1 -1
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/cli.py +3 -1
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/context.py +4 -3
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/durable.py +1 -8
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/loader.py +5 -6
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/capt-hook.toml +1 -1
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/models.py +303 -16
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/manager.py +2 -2
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/llm.py +4 -4
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/prompt.py +5 -6
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/session.py +0 -6
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/settings.py +14 -9
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/state.py +0 -2
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/testing/helpers.py +60 -44
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/testing/session_cache.py +3 -1
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/model_cache.py +2 -3
- {capt_hook-6.3.0 → capt_hook-6.4.0}/pyproject.toml +1 -1
- {capt_hook-6.3.0 → capt_hook-6.4.0}/LICENSE +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/README.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/__main__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/app.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/command.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/conditions.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/contexts.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/decisions.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/events.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/fields.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/file.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/log.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/_lib.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/tombstones.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/steering/steering.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/py.typed +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/store.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/types.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/tasks.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/types.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/http.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/text.py +0 -0
|
@@ -501,7 +501,9 @@ def run_tests(json_output: bool = False) -> None:
|
|
|
501
501
|
@click.pass_context
|
|
502
502
|
def cli(ctx: click.Context, hooks: str | None, root_path: str | None) -> None:
|
|
503
503
|
"""Captain Hook — declarative hook framework for Claude Code lifecycle events."""
|
|
504
|
-
|
|
504
|
+
from captain_hook.settings import resolve_project_dir
|
|
505
|
+
|
|
506
|
+
root = Path(root_path) if root_path else Path(p) if (p := resolve_project_dir()) else Path.cwd()
|
|
505
507
|
ctx.obj = CliState(root=root, hooks=hooks or str(root / ".claude" / "hooks"))
|
|
506
508
|
|
|
507
509
|
|
|
@@ -21,6 +21,7 @@ from spawnllm.proc import run_cli
|
|
|
21
21
|
from captain_hook.classifiers import detect
|
|
22
22
|
from captain_hook.prompt import Prompt
|
|
23
23
|
from captain_hook.session import SessionStore
|
|
24
|
+
from captain_hook.settings import resolve_project_dir
|
|
24
25
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from collections.abc import Sequence
|
|
@@ -78,7 +79,7 @@ def lift_session(events: Sequence[TranscriptEvent], *, path: Path | None = None)
|
|
|
78
79
|
from captain_hook.app import _state
|
|
79
80
|
|
|
80
81
|
classifier = _state.classifier or detect(
|
|
81
|
-
cwd=
|
|
82
|
+
cwd=resolve_project_dir(),
|
|
82
83
|
transcript_path=str(path) if path else None,
|
|
83
84
|
events=events,
|
|
84
85
|
)
|
|
@@ -185,7 +186,7 @@ class HookContext:
|
|
|
185
186
|
input=input,
|
|
186
187
|
timeout=timeout,
|
|
187
188
|
env=os.environ | (env or {}),
|
|
188
|
-
cwd=
|
|
189
|
+
cwd=resolve_project_dir(),
|
|
189
190
|
)
|
|
190
191
|
except (OSError, subprocess.SubprocessError):
|
|
191
192
|
if throw:
|
|
@@ -309,7 +310,7 @@ class HookContext:
|
|
|
309
310
|
) -> str | BaseModel:
|
|
310
311
|
diff_text = self.diff("uncommitted" if diff is True else diff) if diff else None
|
|
311
312
|
prompt = self.assemble_prompt(template, args, kwargs, transcript=transcript, diff_text=diff_text)
|
|
312
|
-
cwd =
|
|
313
|
+
cwd = resolve_project_dir()
|
|
313
314
|
if response_model is not None:
|
|
314
315
|
return extract_sync(
|
|
315
316
|
prompt, response_model, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout
|
|
@@ -26,13 +26,11 @@ if TYPE_CHECKING:
|
|
|
26
26
|
|
|
27
27
|
Scope = Literal["project", "global"]
|
|
28
28
|
|
|
29
|
-
DURABLE_ROOT_OVERRIDE: list[Path] = []
|
|
30
|
-
|
|
31
29
|
|
|
32
30
|
def durable_root() -> Path:
|
|
33
31
|
from captain_hook.settings import resolve_state_dir
|
|
34
32
|
|
|
35
|
-
return
|
|
33
|
+
return resolve_state_dir() / "hooks" / "durable"
|
|
36
34
|
|
|
37
35
|
|
|
38
36
|
def project_key(repo_root: Path) -> str:
|
|
@@ -98,11 +96,6 @@ class DurableStore:
|
|
|
98
96
|
if model not in cls.TRACKED:
|
|
99
97
|
cls.TRACKED.append(model)
|
|
100
98
|
|
|
101
|
-
@classmethod
|
|
102
|
-
def untrack(cls, model: type[BaseModel]) -> None:
|
|
103
|
-
if model in cls.TRACKED:
|
|
104
|
-
cls.TRACKED.remove(model)
|
|
105
|
-
|
|
106
99
|
@classmethod
|
|
107
100
|
def tracked_models(cls) -> Sequence[type[BaseModel]]:
|
|
108
101
|
return tuple(cls.TRACKED)
|
|
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING
|
|
|
13
13
|
|
|
14
14
|
from loguru import logger
|
|
15
15
|
|
|
16
|
-
from captain_hook.app import
|
|
16
|
+
from captain_hook.app import _state, on
|
|
17
17
|
from captain_hook.settings import build_settings
|
|
18
18
|
from captain_hook.types import Event
|
|
19
19
|
|
|
@@ -47,8 +47,7 @@ def import_or_reload(fqn: str, fresh_this_pass: set[str]) -> ModuleType:
|
|
|
47
47
|
return mod
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def discover_hooks(hooks_dir: str | Path
|
|
51
|
-
target = state or _state
|
|
50
|
+
def discover_hooks(hooks_dir: str | Path) -> None:
|
|
52
51
|
hooks_path = Path(hooks_dir).resolve()
|
|
53
52
|
if str(hooks_path.parent) not in sys.path:
|
|
54
53
|
sys.path.insert(0, str(hooks_path.parent))
|
|
@@ -60,9 +59,9 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
|
|
|
60
59
|
|
|
61
60
|
if CONF_MODULE in top_level:
|
|
62
61
|
conf_module = import_or_reload(f"{pkg}.{CONF_MODULE}", fresh_this_pass)
|
|
63
|
-
|
|
62
|
+
_state.settings = build_settings(conf_module)
|
|
64
63
|
if classifier := getattr(conf_module, "classifier", None):
|
|
65
|
-
|
|
64
|
+
_state.classifier = classifier
|
|
66
65
|
|
|
67
66
|
all_modules = {
|
|
68
67
|
info.name
|
|
@@ -80,7 +79,7 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
|
|
|
80
79
|
import_or_reload(fqn, fresh_this_pass)
|
|
81
80
|
except Exception as exc:
|
|
82
81
|
logger.bind(module=fqn).opt(exception=True).warning("skipped unloadable hook module")
|
|
83
|
-
|
|
82
|
+
_state.load_errors.append((fqn, exc))
|
|
84
83
|
|
|
85
84
|
|
|
86
85
|
def ensure_pack_package(fqn: str, search_paths: list[str]) -> ModuleType:
|
|
@@ -10,6 +10,7 @@ from captain_hook import (
|
|
|
10
10
|
Block,
|
|
11
11
|
Clause,
|
|
12
12
|
Event,
|
|
13
|
+
FilePath,
|
|
13
14
|
Input,
|
|
14
15
|
Phrase,
|
|
15
16
|
Rewrite,
|
|
@@ -43,6 +44,19 @@ class DelegatedSpawn:
|
|
|
43
44
|
return f"model: {model}\nagent_type: {call.agent_type or '(default)'}\nprompt:\n{call.prompt}"
|
|
44
45
|
|
|
45
46
|
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class InlineEdit:
|
|
49
|
+
"""Gating context: the file the main agent is about to edit inline on the main loop."""
|
|
50
|
+
|
|
51
|
+
tag: str = "edit_target"
|
|
52
|
+
required: bool = True
|
|
53
|
+
|
|
54
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
55
|
+
if not evt.file or evt.content is None:
|
|
56
|
+
return None
|
|
57
|
+
return f"file: {evt.file.path}\nincoming text: {len(evt.content):,} chars"
|
|
58
|
+
|
|
59
|
+
|
|
46
60
|
WORKFLOW_SCRIPT_CAP = 14_000 # below the prose hooks' max_context=16_000, so truncation stays ours
|
|
47
61
|
|
|
48
62
|
|
|
@@ -120,12 +134,13 @@ class ProseSpawn(DelegatedSpawn):
|
|
|
120
134
|
|
|
121
135
|
@dataclass(frozen=True, slots=True)
|
|
122
136
|
class WorkflowScriptSource:
|
|
123
|
-
"""Gating context: the pending Workflow call's script source, headed by its model pins
|
|
137
|
+
"""Gating context: the pending Workflow call's script source, headed by its model pins."""
|
|
124
138
|
|
|
125
139
|
tag: str = "workflow_script"
|
|
126
140
|
required: bool = True
|
|
127
141
|
|
|
128
|
-
|
|
142
|
+
@staticmethod
|
|
143
|
+
def pins_and_source(evt: BaseHookEvent) -> tuple[str, str] | None:
|
|
129
144
|
if (source := workflow_script_source(evt)) is None:
|
|
130
145
|
return None
|
|
131
146
|
pin_lines = "\n".join(
|
|
@@ -138,14 +153,31 @@ class WorkflowScriptSource:
|
|
|
138
153
|
f"… [script truncated: {len(source):,} chars total; every model pin is quoted above] …\n"
|
|
139
154
|
f"{source[-tail:]}"
|
|
140
155
|
)
|
|
156
|
+
header = (
|
|
157
|
+
"lines that pin a model in this script (a stage not quoted here inherits the "
|
|
158
|
+
f"session model, fable):\n{pin_lines or ' (none)'}"
|
|
159
|
+
)
|
|
160
|
+
return header, source
|
|
161
|
+
|
|
162
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
163
|
+
if (parts := self.pins_and_source(evt)) is None:
|
|
164
|
+
return None
|
|
165
|
+
header, source = parts
|
|
166
|
+
return f"{header}\n\n{source}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, slots=True)
|
|
170
|
+
class ProseWorkflowScript(WorkflowScriptSource):
|
|
171
|
+
"""Gating context: the script source, present only when its prose asks survive the prefilter."""
|
|
172
|
+
|
|
173
|
+
def content(self, evt: BaseHookEvent) -> str | None:
|
|
174
|
+
if (parts := self.pins_and_source(evt)) is None:
|
|
175
|
+
return None
|
|
176
|
+
header, source = parts
|
|
141
177
|
if not (sentences := prose_deliverable_sentences(source)):
|
|
142
178
|
return None
|
|
143
179
|
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
|
-
)
|
|
180
|
+
return f"{header}\n\nsentences the prose prefilter matched:\n{matched}\n\n{source}"
|
|
149
181
|
|
|
150
182
|
|
|
151
183
|
hook(
|
|
@@ -286,16 +318,17 @@ llm_nudge(
|
|
|
286
318
|
the session model, fable), agent type, and prompt.
|
|
287
319
|
|
|
288
320
|
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,
|
|
290
|
-
planning
|
|
291
|
-
|
|
321
|
+
than fable and nearly as capable. Fable's lanes are orchestration, design/architecture
|
|
322
|
+
review, hard planning, all prose/writing, and implementation that is very sensitive or
|
|
323
|
+
error-prone (auth, migrations, concurrency, data loss, crypto, subtle algorithms).
|
|
324
|
+
Code/diff review and bug diagnosis have their own gpt-5.5 lane with a separate nudge.
|
|
292
325
|
|
|
293
326
|
Set fire=true only when the prompt is clearly routine implementation — building, fixing,
|
|
294
327
|
wiring, or refactoring code — with no fable-lane signal. A prompt that reviews, plans,
|
|
295
|
-
designs, diagnoses a
|
|
296
|
-
|
|
297
|
-
deliberately, and a false alarm teaches it to ignore this nudge. Keep
|
|
298
|
-
40 words.
|
|
328
|
+
designs, diagnoses a bug, writes prose, or touches a sensitive surface is not an
|
|
329
|
+
implementation prompt: fire=false. When uncertain, fire=false — the agent may have
|
|
330
|
+
chosen fable deliberately, and a false alarm teaches it to ignore this nudge. Keep
|
|
331
|
+
reasoning under 40 words.
|
|
299
332
|
|
|
300
333
|
<examples>
|
|
301
334
|
<example fire="true">
|
|
@@ -308,7 +341,7 @@ Well-scoped feature wiring; the default implementation lane.
|
|
|
308
341
|
</example>
|
|
309
342
|
<example fire="false">
|
|
310
343
|
Review the diff for correctness and concurrency issues.
|
|
311
|
-
|
|
344
|
+
Not implementation — review routes via its own nudge (gpt-5.5's lane), not to opus.
|
|
312
345
|
</example>
|
|
313
346
|
<example fire="false">
|
|
314
347
|
Design the migration strategy for the sharded session store.
|
|
@@ -344,6 +377,184 @@ Auth plus concurrency: sensitive, error-prone implementation stays on fable.
|
|
|
344
377
|
},
|
|
345
378
|
)
|
|
346
379
|
|
|
380
|
+
llm_nudge(
|
|
381
|
+
"""Decide whether the main agent should delegate this inline edit instead of making
|
|
382
|
+
it itself.
|
|
383
|
+
|
|
384
|
+
The main loop runs on fable-5; this pending edit is fable implementing directly.
|
|
385
|
+
<edit_target> names the file; <before_edit>/<after_edit> hold the text being replaced
|
|
386
|
+
and written.
|
|
387
|
+
|
|
388
|
+
The Models rubric: implementation belongs to a delegated opus-4.8 subagent at xhigh —
|
|
389
|
+
~2x cheaper than fable and nearly as capable — or, for a well-scoped edit to existing
|
|
390
|
+
code, to gpt-5.5 via the codex skill. Fable edits inline when the change is small or
|
|
391
|
+
judgment-bound: a fix-up finishing work it just reasoned through, a subtle algorithm,
|
|
392
|
+
or a sensitive surface (auth, migrations, concurrency, data loss, crypto).
|
|
393
|
+
|
|
394
|
+
Set fire=true only when this edit is clearly substantial routine implementation —
|
|
395
|
+
building out a feature, wiring components, refactoring — that a subagent could own end
|
|
396
|
+
to end. A small fix-up, a sensitive surface, or a change entangled with judgment the
|
|
397
|
+
main agent just exercised stays inline: fire=false. When uncertain, fire=false — the
|
|
398
|
+
agent may be editing inline deliberately, and a false alarm teaches it to ignore this
|
|
399
|
+
nudge. Keep reasoning under 40 words.
|
|
400
|
+
|
|
401
|
+
<examples>
|
|
402
|
+
<example fire="true">
|
|
403
|
+
after_edit: a new 180-line pagination module written to src/api/pagination.py.
|
|
404
|
+
Substantial net-new feature code — a delegated opus xhigh subagent's lane.
|
|
405
|
+
</example>
|
|
406
|
+
<example fire="true">
|
|
407
|
+
after_edit: rewiring three call sites and adding a formatter class in export.py.
|
|
408
|
+
Routine multi-part refactor a subagent could own end to end.
|
|
409
|
+
</example>
|
|
410
|
+
<example fire="false">
|
|
411
|
+
after_edit: a two-line fix to the retry counter the agent just diagnosed.
|
|
412
|
+
Small fix-up entangled with judgment already exercised — inline is right.
|
|
413
|
+
</example>
|
|
414
|
+
<example fire="false">
|
|
415
|
+
after_edit: reworking the token-refresh lock in auth/middleware.py.
|
|
416
|
+
Auth plus concurrency is a sensitive surface — fable's inline lane.
|
|
417
|
+
</example>
|
|
418
|
+
</examples>""",
|
|
419
|
+
message=lambda r: (
|
|
420
|
+
f"This inline edit reads as routine implementation on fable. {r.reasoning} "
|
|
421
|
+
"Implementation delegates: spawn a model='opus', effort='xhigh' subagent to own the change, "
|
|
422
|
+
"or route a well-scoped edit to gpt-5.5 via the codex skill. Keep editing inline only when "
|
|
423
|
+
"the change is small, sensitive, or bound to judgment you just exercised. "
|
|
424
|
+
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
425
|
+
),
|
|
426
|
+
contexts=[InlineEdit()],
|
|
427
|
+
events=Event.PreToolUse,
|
|
428
|
+
only_if=[
|
|
429
|
+
Tool("Edit|Write|MultiEdit"),
|
|
430
|
+
FilePath(
|
|
431
|
+
"**/*.py",
|
|
432
|
+
"**/*.go",
|
|
433
|
+
"**/*.swift",
|
|
434
|
+
"**/*.rs",
|
|
435
|
+
"**/*.ts",
|
|
436
|
+
"**/*.tsx",
|
|
437
|
+
"**/*.js",
|
|
438
|
+
"**/*.jsx",
|
|
439
|
+
"**/*.rb",
|
|
440
|
+
"**/*.java",
|
|
441
|
+
"**/*.kt",
|
|
442
|
+
"**/*.c",
|
|
443
|
+
"**/*.cc",
|
|
444
|
+
"**/*.cpp",
|
|
445
|
+
"**/*.h",
|
|
446
|
+
"**/*.zig",
|
|
447
|
+
),
|
|
448
|
+
],
|
|
449
|
+
skip_if=[
|
|
450
|
+
FilePath("**/test_*.py", "**/*_test.py", "**/*_test.go", "**/tests/**", "**/*.test.*", "**/*.spec.*"),
|
|
451
|
+
],
|
|
452
|
+
when=lambda evt: not evt.is_subagent and len(evt.content or "") >= 400,
|
|
453
|
+
max_fires=1,
|
|
454
|
+
agent=False,
|
|
455
|
+
transcript=False,
|
|
456
|
+
tests={
|
|
457
|
+
Input(
|
|
458
|
+
file="src/api/users.py",
|
|
459
|
+
content="def list_users(page: int):\n return paginate(page)\n" * 12,
|
|
460
|
+
): Warn(pattern="opus"),
|
|
461
|
+
Input(
|
|
462
|
+
file="README.md",
|
|
463
|
+
content="Pagination lands in the users API.\n" * 20,
|
|
464
|
+
): Allow(),
|
|
465
|
+
Input(file="src/api/users.py", old="page = 1", content="page = 2"): Allow(),
|
|
466
|
+
Input(
|
|
467
|
+
file="tests/test_users.py",
|
|
468
|
+
content="def test_list_users(page: int):\n assert paginate(page)\n" * 12,
|
|
469
|
+
): Allow(),
|
|
470
|
+
Input(
|
|
471
|
+
file="src/auth/middleware.py",
|
|
472
|
+
content="def refresh_token(lock: Lock):\n with lock:\n rotate()\n" * 12,
|
|
473
|
+
llm={"fire": False},
|
|
474
|
+
): Allow(),
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
llm_nudge(
|
|
479
|
+
"""Decide whether this delegated subagent runs code review or bug diagnosis that
|
|
480
|
+
should route to gpt-5.5 instead of fable.
|
|
481
|
+
|
|
482
|
+
<delegated_spawn> holds the pending Agent/Task call: its model pin (or that it inherits
|
|
483
|
+
the session model, fable), agent type, and prompt.
|
|
484
|
+
|
|
485
|
+
The Models rubric: code/diff review — sweeping a diff or codebase for bugs,
|
|
486
|
+
correctness, or cleanups; finder and refuter passes over findings — and bug diagnosis
|
|
487
|
+
route to gpt-5.5 via the codex skill; fable is the escalation target when gpt-5.5's
|
|
488
|
+
output misses. Fable keeps design/architecture review, "is this the right approach"
|
|
489
|
+
judgment, prose review, and the synthesis/accept-reject pass over review findings.
|
|
490
|
+
|
|
491
|
+
Set fire=true only when the prompt clearly reviews code or diffs for defects, or
|
|
492
|
+
diagnoses a bug, and the spawn would run on fable. Design review, approach judgment,
|
|
493
|
+
synthesis over findings, and prose review are fable's lanes: fire=false. When
|
|
494
|
+
uncertain, fire=false — the agent may have chosen fable deliberately, and a false
|
|
495
|
+
alarm teaches it to ignore this nudge. Keep reasoning under 40 words.
|
|
496
|
+
|
|
497
|
+
<examples>
|
|
498
|
+
<example fire="true">
|
|
499
|
+
Review the diff for correctness and concurrency issues; report findings as JSON.
|
|
500
|
+
Diff review for defects — gpt-5.5's lane via codex.
|
|
501
|
+
</example>
|
|
502
|
+
<example fire="true">
|
|
503
|
+
Adversarially refute this finding: the retry loop double-increments the counter.
|
|
504
|
+
A refuter pass over a code finding is review work.
|
|
505
|
+
</example>
|
|
506
|
+
<example fire="true">
|
|
507
|
+
Diagnose why the exporter hangs when two workers flush concurrently.
|
|
508
|
+
Bug diagnosis starts on gpt-5.5; fable is the escalation target.
|
|
509
|
+
</example>
|
|
510
|
+
<example fire="false">
|
|
511
|
+
Judge these three sharding designs and recommend one.
|
|
512
|
+
Design/architecture judgment is fable's lane.
|
|
513
|
+
</example>
|
|
514
|
+
<example fire="false">
|
|
515
|
+
Synthesize the confirmed findings and decide which to fix before release.
|
|
516
|
+
The accept-reject pass over findings stays on fable.
|
|
517
|
+
</example>
|
|
518
|
+
<example fire="false">
|
|
519
|
+
Review the README draft for factual errors.
|
|
520
|
+
Prose review stays on fable.
|
|
521
|
+
</example>
|
|
522
|
+
</examples>""",
|
|
523
|
+
message=lambda r: (
|
|
524
|
+
f"This review/diagnosis delegation would run on fable. {r.reasoning} "
|
|
525
|
+
"Code/diff review and bug diagnosis route to gpt-5.5: run the codex skill (from a "
|
|
526
|
+
"workflow or subagent, spawn a model='sonnet', effort='low' wrapper that writes a "
|
|
527
|
+
"self-contained codex prompt), and escalate to fable only when gpt-5.5's output misses. "
|
|
528
|
+
"Design review and findings synthesis stay on fable. "
|
|
529
|
+
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
530
|
+
),
|
|
531
|
+
contexts=[DelegatedSpawn()],
|
|
532
|
+
events=Event.PreToolUse,
|
|
533
|
+
only_if=[
|
|
534
|
+
Tool("Agent|Task"),
|
|
535
|
+
ToolInput("prompt", r"(?i)\b(review|refut|adversari|audit|correctness|diagnos|root.?caus)"),
|
|
536
|
+
],
|
|
537
|
+
skip_if=[
|
|
538
|
+
ToolInput("model", r"(?i)\b(opus|sonnet|haiku)\b"),
|
|
539
|
+
Agent("Explore|claude-code-guide"),
|
|
540
|
+
],
|
|
541
|
+
agent=False,
|
|
542
|
+
transcript=False,
|
|
543
|
+
tests={
|
|
544
|
+
Input(prompt="Review the diff for correctness and concurrency issues"): Warn(pattern="gpt-5.5"),
|
|
545
|
+
Input(model="fable", prompt="Adversarially refute this finding: the retry loop is wrong"): Warn(
|
|
546
|
+
pattern="codex"
|
|
547
|
+
),
|
|
548
|
+
Input(model="sonnet", prompt="Review the diff for correctness via the codex skill"): Allow(),
|
|
549
|
+
Input(prompt="fix the failing import in cli.py"): Allow(),
|
|
550
|
+
Input(agent_type="Explore", prompt="find where the review pipeline lives"): Allow(),
|
|
551
|
+
Input(
|
|
552
|
+
prompt="Synthesize the confirmed review findings and decide which to fix",
|
|
553
|
+
llm={"fire": False},
|
|
554
|
+
): Allow(),
|
|
555
|
+
},
|
|
556
|
+
)
|
|
557
|
+
|
|
347
558
|
nudge(
|
|
348
559
|
"""
|
|
349
560
|
This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
|
|
@@ -422,7 +633,7 @@ Reading and classifying docs is analysis, not a prose deliverable.
|
|
|
422
633
|
"session model or pin model: 'fable' on that stage. "
|
|
423
634
|
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
424
635
|
),
|
|
425
|
-
contexts=[
|
|
636
|
+
contexts=[ProseWorkflowScript()],
|
|
426
637
|
events=Event.PreToolUse,
|
|
427
638
|
only_if=[
|
|
428
639
|
Tool("Workflow"),
|
|
@@ -445,3 +656,79 @@ Reading and classifying docs is analysis, not a prose deliverable.
|
|
|
445
656
|
): Allow(),
|
|
446
657
|
},
|
|
447
658
|
)
|
|
659
|
+
|
|
660
|
+
llm_nudge(
|
|
661
|
+
"""Decide whether this workflow script runs code review or bug diagnosis stages on
|
|
662
|
+
fable that should route to gpt-5.5.
|
|
663
|
+
|
|
664
|
+
<workflow_script> holds the pending Workflow call's script source, headed by every
|
|
665
|
+
line that pins a model — a stage not quoted there carries no pin and inherits the
|
|
666
|
+
session model, fable.
|
|
667
|
+
|
|
668
|
+
The Models rubric: code/diff review stages — finder sweeps over a diff or codebase,
|
|
669
|
+
adversarial refuters over findings — and bug diagnosis route to gpt-5.5 via the codex
|
|
670
|
+
skill. A stage does that correctly when it pins model 'sonnet' at low effort and its
|
|
671
|
+
prompt writes a self-contained codex prompt and runs the codex skill. Fable keeps the
|
|
672
|
+
synthesis/accept-reject stage over findings and design/architecture judgment.
|
|
673
|
+
|
|
674
|
+
Set fire=true only when at least one review or diagnosis stage would run on fable —
|
|
675
|
+
unpinned, or pinned 'fable'. Stages already wrapped for codex, synthesis stages, and
|
|
676
|
+
design judgment are routed right: fire=false. When uncertain, fire=false — a false
|
|
677
|
+
alarm teaches the agent to ignore this nudge. Keep reasoning under 40 words and name
|
|
678
|
+
the offending stage.
|
|
679
|
+
|
|
680
|
+
<examples>
|
|
681
|
+
<example fire="true">
|
|
682
|
+
agent(`Sweep the diff for go-correctness issues; return findings as JSON`)
|
|
683
|
+
An unpinned finder inherits fable; finder sweeps are the codex-wrapper lane.
|
|
684
|
+
</example>
|
|
685
|
+
<example fire="true">
|
|
686
|
+
findings.map(f => agent(`Adversarially refute: ${f.title}`, {effort: 'max'}))
|
|
687
|
+
Refuters over code findings inherit fable — route them through codex wrappers.
|
|
688
|
+
</example>
|
|
689
|
+
<example fire="false">
|
|
690
|
+
agent(`Write a self-contained codex prompt reviewing this diff for correctness,
|
|
691
|
+
then run the codex skill`, {model: 'sonnet', effort: 'low'})
|
|
692
|
+
Already the codex wrapper — correctly routed.
|
|
693
|
+
</example>
|
|
694
|
+
<example fire="false">
|
|
695
|
+
agent(`Synthesize the confirmed findings and decide which to fix`)
|
|
696
|
+
Synthesis/accept-reject stays on fable.
|
|
697
|
+
</example>
|
|
698
|
+
</examples>""",
|
|
699
|
+
message=lambda r: (
|
|
700
|
+
f"This workflow runs review/diagnosis stages on fable. {r.reasoning} "
|
|
701
|
+
"Route finder, refuter, and diagnosis stages to gpt-5.5: make each a model: 'sonnet', "
|
|
702
|
+
"effort: 'low' stage that writes a self-contained codex prompt and runs the codex skill; "
|
|
703
|
+
"keep the synthesis/accept-reject stage on fable (inherit the session model). "
|
|
704
|
+
"See CLAUDE.md § Plan Execution & Orchestration (Models)."
|
|
705
|
+
),
|
|
706
|
+
contexts=[WorkflowScriptSource()],
|
|
707
|
+
events=Event.PreToolUse,
|
|
708
|
+
only_if=[
|
|
709
|
+
Tool("Workflow"),
|
|
710
|
+
WorkflowScript(pattern=r"(?i)\b(review|refut|adversari|audit|correctness|diagnos)"),
|
|
711
|
+
],
|
|
712
|
+
max_fires=2,
|
|
713
|
+
max_context=16_000,
|
|
714
|
+
agent=False,
|
|
715
|
+
transcript=False,
|
|
716
|
+
tests={
|
|
717
|
+
Input(script="const findings = await agent(`Sweep the diff for correctness issues; return JSON`)"): Warn(
|
|
718
|
+
pattern="codex"
|
|
719
|
+
),
|
|
720
|
+
Input(script="agent(`Adversarially refute: ${f.title}`, {model: 'fable', effort: 'max'})"): Warn(
|
|
721
|
+
pattern="gpt-5.5"
|
|
722
|
+
),
|
|
723
|
+
Input(
|
|
724
|
+
script="agent('Write a self-contained codex prompt reviewing this diff, "
|
|
725
|
+
"then run the codex skill', {model: 'sonnet', effort: 'low'})",
|
|
726
|
+
llm={"fire": False},
|
|
727
|
+
): Allow(),
|
|
728
|
+
Input(script="agent('fix the failing import in cli.py')"): Allow(),
|
|
729
|
+
Input(
|
|
730
|
+
script="agent(`Synthesize the confirmed review findings and decide which to fix`)",
|
|
731
|
+
llm={"fire": False},
|
|
732
|
+
): Allow(),
|
|
733
|
+
},
|
|
734
|
+
)
|
|
@@ -32,7 +32,7 @@ from typing import Any
|
|
|
32
32
|
|
|
33
33
|
from filelock import FileLock
|
|
34
34
|
|
|
35
|
-
from captain_hook import
|
|
35
|
+
from captain_hook.settings import resolve_cache_dir
|
|
36
36
|
from captain_hook.util import http
|
|
37
37
|
|
|
38
38
|
PACK_MANIFEST = "capt-hook.toml"
|
|
@@ -209,7 +209,7 @@ def delete_entry(path: Path, name: str) -> None:
|
|
|
209
209
|
|
|
210
210
|
|
|
211
211
|
def packs_cache_root() -> Path:
|
|
212
|
-
return
|
|
212
|
+
return resolve_cache_dir() / "packs"
|
|
213
213
|
|
|
214
214
|
|
|
215
215
|
def meta_path(name: str) -> Path:
|
|
@@ -9,11 +9,11 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
|
9
9
|
from loguru import logger
|
|
10
10
|
from pydantic import BaseModel
|
|
11
11
|
|
|
12
|
-
from captain_hook import state
|
|
13
12
|
from captain_hook.app import on
|
|
14
13
|
from captain_hook.contexts import apply_contexts, with_defaults
|
|
15
14
|
from captain_hook.primitives.nudge import DEFAULT_FIRES
|
|
16
15
|
from captain_hook.prompt import Prompt
|
|
16
|
+
from captain_hook.settings import resolve_cache_dir
|
|
17
17
|
from captain_hook.signals import extract_signal_context, resolve_signals, transcript_texts
|
|
18
18
|
from captain_hook.state import PrimitiveState, fired_this_turn, hook_name, record_fire
|
|
19
19
|
from captain_hook.types import (
|
|
@@ -27,8 +27,6 @@ from captain_hook.types import (
|
|
|
27
27
|
Waiting,
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
-
FAILURE_ROOT = state.CACHE_ROOT / "failures"
|
|
31
|
-
|
|
32
30
|
if TYPE_CHECKING:
|
|
33
31
|
from spawnllm import TModel, TSpecialty
|
|
34
32
|
|
|
@@ -362,7 +360,9 @@ def record_prompt_check_failure(
|
|
|
362
360
|
case _:
|
|
363
361
|
argv, exit_code, stdout, stderr = None, None, "", ""
|
|
364
362
|
|
|
365
|
-
failure_path =
|
|
363
|
+
failure_path = (
|
|
364
|
+
resolve_cache_dir() / "failures" / (p.stem if (p := evt.ctx.t.path) else "unknown") / f"{timestamp}.json"
|
|
365
|
+
)
|
|
366
366
|
failure_path.parent.mkdir(parents=True, exist_ok=True)
|
|
367
367
|
failure_path.write_text(
|
|
368
368
|
json.dumps(
|
|
@@ -5,17 +5,16 @@ import textwrap
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
from captain_hook.state import FRAMEWORK_DIR
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
def dedent_text(text: str) -> str:
|
|
10
12
|
return textwrap.dedent(text).strip()
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _caller_dir() -> Path:
|
|
15
|
+
def caller_dir() -> Path:
|
|
17
16
|
frame = inspect.currentframe()
|
|
18
|
-
while frame and Path(frame.f_code.co_filename).resolve().is_relative_to(
|
|
17
|
+
while frame and Path(frame.f_code.co_filename).resolve().is_relative_to(FRAMEWORK_DIR):
|
|
19
18
|
frame = frame.f_back
|
|
20
19
|
return Path(frame.f_code.co_filename).resolve().parent if frame else Path.cwd()
|
|
21
20
|
|
|
@@ -83,7 +82,7 @@ class Prompt:
|
|
|
83
82
|
FileNotFoundError: If no matching file exists in any searched directory.
|
|
84
83
|
KeyError: If the file references a placeholder not supplied in ``**vars``.
|
|
85
84
|
"""
|
|
86
|
-
dirs = [Path(base) if base else
|
|
85
|
+
dirs = [Path(base) if base else caller_dir() / "prompts", Path(FRAMEWORK_DIR) / "prompts"]
|
|
87
86
|
for path in (d / f"{name}.md" for d in dirs):
|
|
88
87
|
if path.is_file():
|
|
89
88
|
return cls.from_template(path.read_text(), **vars)
|
|
@@ -149,12 +149,6 @@ class SessionStore:
|
|
|
149
149
|
if model not in cls.TRACKED:
|
|
150
150
|
cls.TRACKED.append(model)
|
|
151
151
|
|
|
152
|
-
@classmethod
|
|
153
|
-
def untrack(cls, model: type[BaseModel]) -> None:
|
|
154
|
-
"""Reverse ``track`` — primarily for test isolation."""
|
|
155
|
-
if model in cls.TRACKED:
|
|
156
|
-
cls.TRACKED.remove(model)
|
|
157
|
-
|
|
158
152
|
@classmethod
|
|
159
153
|
def tracked_models(cls) -> Sequence[type[BaseModel]]:
|
|
160
154
|
"""Return the registered tracked-state models as an immutable tuple."""
|
|
@@ -32,18 +32,23 @@ DEFAULT_WAITING_TOOLS = [
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def resolve_state_dir() -> Path:
|
|
35
|
-
return Path(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)
|
|
35
|
+
return Path(os.environ.get("CAPTAIN_HOOK_STATE_DIR") or Path.home() / ".claude" / "state")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_project_dir() -> str | None:
|
|
39
|
+
return os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_cache_home() -> Path:
|
|
43
|
+
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_cache_dir() -> Path:
|
|
47
|
+
return resolve_cache_home() / "captain-hook"
|
|
40
48
|
|
|
41
49
|
|
|
42
50
|
def resolve_log_dir() -> Path:
|
|
43
|
-
return Path(
|
|
44
|
-
os.environ.get("CAPTAIN_HOOK_LOG_DIR")
|
|
45
|
-
or Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "captain-hook" / "logs"
|
|
46
|
-
)
|
|
51
|
+
return Path(os.environ.get("CAPTAIN_HOOK_LOG_DIR") or resolve_cache_dir() / "logs")
|
|
47
52
|
|
|
48
53
|
|
|
49
54
|
class HooksSettings(BaseSettings):
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import inspect
|
|
6
|
-
import os
|
|
7
6
|
import re
|
|
8
7
|
from collections.abc import Callable, Iterable
|
|
9
8
|
from dataclasses import dataclass
|
|
@@ -24,7 +23,6 @@ if TYPE_CHECKING:
|
|
|
24
23
|
|
|
25
24
|
FRAMEWORK_DIR = str(Path(__file__).resolve().parent)
|
|
26
25
|
PACKS_DIR = str(Path(FRAMEWORK_DIR) / "packs")
|
|
27
|
-
CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "captain-hook"
|
|
28
26
|
SPACY_MODEL = "en_core_web_sm"
|
|
29
27
|
|
|
30
28
|
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import atexit
|
|
4
|
+
import functools
|
|
5
|
+
import os
|
|
4
6
|
import re
|
|
5
7
|
import shutil
|
|
6
8
|
import tempfile
|
|
7
|
-
from collections.abc import
|
|
9
|
+
from collections.abc import Iterator
|
|
8
10
|
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass, field
|
|
9
12
|
from itertools import count
|
|
10
13
|
from pathlib import Path
|
|
11
|
-
from typing import Any, cast
|
|
14
|
+
from typing import Any, cast, overload
|
|
12
15
|
|
|
13
16
|
from cc_transcript.parser import parse_event
|
|
14
17
|
from cc_transcript.query import Session
|
|
@@ -27,6 +30,7 @@ from captain_hook.events import (
|
|
|
27
30
|
ToolHookEvent,
|
|
28
31
|
UserPromptSubmitEvent,
|
|
29
32
|
)
|
|
33
|
+
from captain_hook.prompt import Prompt
|
|
30
34
|
from captain_hook.session import SessionStore
|
|
31
35
|
from captain_hook.testing.session_cache import SessionCache
|
|
32
36
|
from captain_hook.testing.types import Allow, Block, FileFixture, Input, Rewrite, TranscriptFixture, Warn
|
|
@@ -40,16 +44,52 @@ STUB_FIELD_VALUES: dict[str, Any] = {
|
|
|
40
44
|
"reason": "inline test stub",
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
FIXTURE_FILE_DIR: list[Path] = []
|
|
44
47
|
FIXTURE_FILE_COUNTER = count()
|
|
45
48
|
|
|
46
49
|
|
|
50
|
+
@dataclass
|
|
51
|
+
class StubbedContext(HookContext):
|
|
52
|
+
"""HookContext for inline tests: ``call_llm`` fabricates a deterministic verdict from
|
|
53
|
+
``STUB_FIELD_VALUES`` merged with the per-test ``Input.llm`` overrides."""
|
|
54
|
+
|
|
55
|
+
llm: dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
@overload
|
|
58
|
+
def call_llm[M: BaseModel](
|
|
59
|
+
self, template: str | Prompt, *args: Any, response_model: type[M], **kwargs: Any
|
|
60
|
+
) -> M: ...
|
|
61
|
+
@overload
|
|
62
|
+
def call_llm(self, template: str | Prompt, *args: Any, response_model: None = None, **kwargs: Any) -> str: ...
|
|
63
|
+
def call_llm(
|
|
64
|
+
self, template: str | Prompt, *args: Any, response_model: type[BaseModel] | None = None, **kwargs: Any
|
|
65
|
+
) -> str | BaseModel:
|
|
66
|
+
if response_model is None:
|
|
67
|
+
return "stubbed"
|
|
68
|
+
values = STUB_FIELD_VALUES | self.llm
|
|
69
|
+
return response_model(
|
|
70
|
+
**{
|
|
71
|
+
name: values.get(name, "")
|
|
72
|
+
for name, info in response_model.model_fields.items()
|
|
73
|
+
if name in values or info.default is None
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def wrapping(cls, ctx: HookContext, llm: dict[str, Any] | None = None) -> StubbedContext:
|
|
79
|
+
return cls(
|
|
80
|
+
session=ctx.session,
|
|
81
|
+
transcript=ctx.transcript,
|
|
82
|
+
settings=ctx.settings,
|
|
83
|
+
project_root=ctx.project_root,
|
|
84
|
+
llm=llm or {},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@functools.cache
|
|
47
89
|
def fixture_file_dir() -> Path:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
atexit.register(shutil.rmtree, root, ignore_errors=True)
|
|
52
|
-
return FIXTURE_FILE_DIR[0]
|
|
90
|
+
root = Path(tempfile.mkdtemp(prefix="capt-hook-fixture-"))
|
|
91
|
+
atexit.register(shutil.rmtree, root, ignore_errors=True)
|
|
92
|
+
return root
|
|
53
93
|
|
|
54
94
|
|
|
55
95
|
def materialize_file(fixture: FileFixture) -> str:
|
|
@@ -403,13 +443,13 @@ def input_to_event(
|
|
|
403
443
|
# injected list in inline tests — no native task store / session_id required.
|
|
404
444
|
evt.__dict__["tasks"] = Tasks(tuple(Task.from_raw(t) for t in inp.tasks))
|
|
405
445
|
|
|
446
|
+
evt.ctx = StubbedContext.wrapping(evt.ctx, inp.llm)
|
|
406
447
|
return evt
|
|
407
448
|
|
|
408
449
|
|
|
409
450
|
def replay_session(entry: Any, jsonl: Path) -> Iterator[HookResult | None]:
|
|
410
451
|
transcript = load_transcript(jsonl)
|
|
411
|
-
ctx =
|
|
412
|
-
ctx.call_llm = stub_call_llm # type: ignore[method-assign]
|
|
452
|
+
ctx = StubbedContext(session=SessionStore(None), transcript=transcript, settings=None)
|
|
413
453
|
transcript_path = str(jsonl)
|
|
414
454
|
|
|
415
455
|
for ev_type in entry.spec.events:
|
|
@@ -508,42 +548,19 @@ def assert_result(
|
|
|
508
548
|
assert val in actual, f"{prefix}Rewrite {key}={actual!r} doesn't contain '{val}'"
|
|
509
549
|
|
|
510
550
|
|
|
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()
|
|
534
|
-
|
|
535
|
-
|
|
536
551
|
@contextmanager
|
|
537
|
-
def
|
|
538
|
-
"""Point
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
DURABLE_ROOT_OVERRIDE.append(root)
|
|
552
|
+
def isolated_state_root() -> Iterator[Path]:
|
|
553
|
+
"""Point the whole capt-hook state root at a throwaway dir so inline tests never touch real state."""
|
|
554
|
+
root = Path(tempfile.mkdtemp(prefix="capt-hook-state-"))
|
|
555
|
+
saved = os.environ.get("CAPTAIN_HOOK_STATE_DIR")
|
|
556
|
+
os.environ["CAPTAIN_HOOK_STATE_DIR"] = str(root)
|
|
543
557
|
try:
|
|
544
558
|
yield root
|
|
545
559
|
finally:
|
|
546
|
-
|
|
560
|
+
if saved is None:
|
|
561
|
+
del os.environ["CAPTAIN_HOOK_STATE_DIR"]
|
|
562
|
+
else:
|
|
563
|
+
os.environ["CAPTAIN_HOOK_STATE_DIR"] = saved
|
|
547
564
|
shutil.rmtree(root, ignore_errors=True)
|
|
548
565
|
|
|
549
566
|
|
|
@@ -552,7 +569,7 @@ def run_inline_tests() -> list[tuple[str, str, bool, str]]:
|
|
|
552
569
|
|
|
553
570
|
results: list[tuple[str, str, bool, str]] = []
|
|
554
571
|
|
|
555
|
-
with
|
|
572
|
+
with isolated_state_root():
|
|
556
573
|
for entry in _state.hooks:
|
|
557
574
|
if not entry.spec.tests:
|
|
558
575
|
continue
|
|
@@ -571,7 +588,6 @@ def run_inline_tests() -> list[tuple[str, str, bool, str]]:
|
|
|
571
588
|
if spec_tools
|
|
572
589
|
else None,
|
|
573
590
|
)
|
|
574
|
-
evt.ctx.call_llm = make_stub_call_llm(key.llm) # type: ignore[assignment]
|
|
575
591
|
hook_result = (
|
|
576
592
|
execute_hook(entry, evt)
|
|
577
593
|
if matches_conditions(entry.spec, evt) and not is_planning_agent_skip(entry.spec, evt)
|
|
@@ -6,6 +6,8 @@ import shutil
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import ClassVar
|
|
8
8
|
|
|
9
|
+
from captain_hook.settings import resolve_project_dir
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
class SessionCache:
|
|
11
13
|
"""Resolves UUID-keyed inline-test fixtures to a cached transcript jsonl.
|
|
@@ -24,7 +26,7 @@ class SessionCache:
|
|
|
24
26
|
|
|
25
27
|
@classmethod
|
|
26
28
|
def for_root(cls, root: Path | str | None = None) -> SessionCache:
|
|
27
|
-
return cls(Path(root or
|
|
29
|
+
return cls(Path(root or resolve_project_dir() or os.getcwd()))
|
|
28
30
|
|
|
29
31
|
def path(self, uuid: str) -> Path:
|
|
30
32
|
return self.dir / f"{uuid}.jsonl"
|
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
4
|
import hashlib
|
|
5
|
-
import os
|
|
6
5
|
import re
|
|
7
6
|
import shutil
|
|
8
7
|
import zipfile
|
|
@@ -12,6 +11,7 @@ from typing import Any
|
|
|
12
11
|
|
|
13
12
|
from filelock import FileLock
|
|
14
13
|
|
|
14
|
+
from captain_hook.settings import resolve_cache_home
|
|
15
15
|
from captain_hook.util import http
|
|
16
16
|
|
|
17
17
|
MODEL_NAME = "en_core_web_sm"
|
|
@@ -19,9 +19,8 @@ WN_LEXICON = "oewn:2025"
|
|
|
19
19
|
WHEEL_CHECKSUM = re.compile(r"Checksum \.whl:\*\*\s*`([0-9a-f]{64})`")
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
@functools.cache
|
|
23
22
|
def cache_root() -> Path:
|
|
24
|
-
return
|
|
23
|
+
return resolve_cache_home() / "spacy" / "models"
|
|
25
24
|
|
|
26
25
|
|
|
27
26
|
@functools.cache
|
|
@@ -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.4.0"
|
|
5
5
|
description = "Declarative hook framework for Claude Code"
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "PolyForm-Noncommercial-1.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md
RENAMED
|
File without changes
|
|
File without changes
|
{capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md
RENAMED
|
File without changes
|
{capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md
RENAMED
|
File without changes
|
{capt_hook-6.3.0 → capt_hook-6.4.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
|