capt-hook 4.1.0__tar.gz → 4.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-4.1.0 → capt_hook-4.3.0}/PKG-INFO +2 -2
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/__init__.py +4 -2
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/context.py +99 -33
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/dispatch.py +17 -6
- capt_hook-4.3.0/captain_hook/durable.py +148 -0
- capt_hook-4.3.0/captain_hook/fields.py +45 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/llm.py +2 -2
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/cli.py +47 -84
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/pipeline.py +2 -3
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/session.py +25 -1
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/state.py +7 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/types.py +1 -1
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/types.py +23 -26
- capt_hook-4.3.0/captain_hook/util/__init__.py +2 -0
- capt_hook-4.1.0/captain_hook/utils.py → capt_hook-4.3.0/captain_hook/util/fs.py +0 -6
- capt_hook-4.3.0/captain_hook/util/text.py +8 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/pyproject.toml +2 -2
- capt_hook-4.1.0/captain_hook/llm/__init__.py +0 -7
- capt_hook-4.1.0/captain_hook/tests/__init__.py +0 -13
- capt_hook-4.1.0/captain_hook/tests/helpers.py +0 -361
- capt_hook-4.1.0/captain_hook/tests/review_helpers.py +0 -141
- capt_hook-4.1.0/captain_hook/util/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/LICENSE +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/README.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/__main__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/app.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/cli.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/command.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/conditions.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/decisions.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/events.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/file.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/loader.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/log.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/steering/steering.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/prompt.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/py.typed +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/store.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/settings.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/tasks.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/util/http.py +0 -0
- {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/util/model_cache.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capt-hook
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.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
|
|
@@ -31,7 +31,7 @@ Requires-Dist: wn>=1.1.0
|
|
|
31
31
|
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
32
32
|
Requires-Dist: filelock>=3
|
|
33
33
|
Requires-Dist: loguru>=0.7.3
|
|
34
|
-
Requires-Dist: spawnllm>=0.5.0
|
|
34
|
+
Requires-Dist: spawnllm>=0.5.2,<0.6
|
|
35
35
|
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
36
36
|
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
37
37
|
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
@@ -19,10 +19,11 @@ from cc_transcript.tools import (
|
|
|
19
19
|
WriteCall,
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
from captain_hook import file, style,
|
|
22
|
+
from captain_hook import file, style, util
|
|
23
23
|
from captain_hook.app import hook, on, register
|
|
24
24
|
from captain_hook.command import Command, CommandLine, Redirect
|
|
25
25
|
from captain_hook.context import HookContext
|
|
26
|
+
from captain_hook.durable import DurableSlot, DurableState, DurableStore
|
|
26
27
|
from captain_hook.events import (
|
|
27
28
|
BaseHookEvent,
|
|
28
29
|
NotificationEvent,
|
|
@@ -37,6 +38,7 @@ from captain_hook.events import (
|
|
|
37
38
|
ToolHookEvent,
|
|
38
39
|
UserPromptSubmitEvent,
|
|
39
40
|
)
|
|
41
|
+
from captain_hook.fields import Deque
|
|
40
42
|
from captain_hook.file import File, categorize_files
|
|
41
43
|
from captain_hook.primitives import (
|
|
42
44
|
GateVerdict,
|
|
@@ -92,4 +94,4 @@ from captain_hook.types import (
|
|
|
92
94
|
UsedSkill,
|
|
93
95
|
Waiting,
|
|
94
96
|
)
|
|
95
|
-
from captain_hook.
|
|
97
|
+
from captain_hook.util import read_json, resolve_binary
|
|
@@ -5,7 +5,7 @@ import subprocess
|
|
|
5
5
|
from dataclasses import dataclass, replace
|
|
6
6
|
from functools import cached_property
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import TYPE_CHECKING, Any, Literal
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
9
9
|
|
|
10
10
|
from cc_transcript.activity import SessionActivity, meta_of
|
|
11
11
|
from cc_transcript.ids import SessionId
|
|
@@ -15,11 +15,10 @@ from cc_transcript.query import Session
|
|
|
15
15
|
from cc_transcript.render import Budget, render_turn
|
|
16
16
|
from cc_transcript.tools import parse_tool_call
|
|
17
17
|
from pydantic import BaseModel
|
|
18
|
-
from spawnllm import call_sync, extract_sync
|
|
18
|
+
from spawnllm import TModel, TSpecialty, call_sync, extract_sync
|
|
19
19
|
from spawnllm.proc import run_cli
|
|
20
20
|
|
|
21
21
|
from captain_hook.classifiers import detect
|
|
22
|
-
from captain_hook.llm import LlmBackends, TModel, TSpecialty
|
|
23
22
|
from captain_hook.prompt import Prompt
|
|
24
23
|
from captain_hook.session import SessionStore
|
|
25
24
|
|
|
@@ -35,10 +34,14 @@ if TYPE_CHECKING:
|
|
|
35
34
|
RECENT_WINDOW = 15
|
|
36
35
|
|
|
37
36
|
|
|
38
|
-
def transcript_window(transcript: bool | int |
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
def transcript_window(transcript: bool | int | Literal["recent", "full"]) -> int | None:
|
|
38
|
+
match transcript:
|
|
39
|
+
case "full":
|
|
40
|
+
return None
|
|
41
|
+
case True | "recent":
|
|
42
|
+
return RECENT_WINDOW
|
|
43
|
+
case int() as events:
|
|
44
|
+
return events
|
|
42
45
|
|
|
43
46
|
|
|
44
47
|
class LenientToolUseBlock(ToolUseBlock):
|
|
@@ -195,23 +198,37 @@ class HookContext:
|
|
|
195
198
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
196
199
|
return None
|
|
197
200
|
|
|
198
|
-
def diff(
|
|
199
|
-
|
|
201
|
+
def diff(
|
|
202
|
+
self,
|
|
203
|
+
source: str = "uncommitted",
|
|
204
|
+
*,
|
|
205
|
+
commit: str | None = None,
|
|
206
|
+
scope: str | None = None,
|
|
207
|
+
budget: int = 4000,
|
|
208
|
+
) -> str | None:
|
|
209
|
+
"""A compact diff via ``ccx diff`` when available, else plain ``git``.
|
|
200
210
|
|
|
201
|
-
Prefers cc-context's token-budgeted ``ccx diff`` and
|
|
202
|
-
|
|
211
|
+
Prefers cc-context's token-budgeted ``ccx diff`` and falls back to ``git`` when ``ccx`` is
|
|
212
|
+
absent, fails, or returns a hunkless symbol-outline (no ``@@`` / ``diff --git`` lines, as in
|
|
213
|
+
jj-colocated repos), so a hook gets a real diff in any repo. The git fallback is bounded to
|
|
214
|
+
roughly ``budget`` tokens with a trailing marker when truncated, so a large diff can't blow
|
|
215
|
+
the caller's context.
|
|
203
216
|
|
|
204
217
|
Args:
|
|
205
|
-
source: ``"uncommitted"`` (
|
|
218
|
+
source: ``"uncommitted"`` (the default), ``"staged"``, or any git ref. Ignored when
|
|
219
|
+
``commit`` is set.
|
|
220
|
+
commit: When set, the diff *introduced by* this commit (root-commit-safe), overriding
|
|
221
|
+
``source``; its git fallback is ``git show --stat -p``, so it carries the commit
|
|
222
|
+
header + diffstat ahead of the patch.
|
|
206
223
|
scope: Restrict the diff to this path.
|
|
207
|
-
budget:
|
|
208
|
-
|
|
209
|
-
Returns:
|
|
210
|
-
The rendered diff, or ``None`` when neither ``ccx`` nor ``git`` produces output.
|
|
224
|
+
budget: Token budget for both the ``ccx`` output and the bounded git fallback.
|
|
211
225
|
"""
|
|
212
|
-
|
|
213
|
-
if (out := self.
|
|
226
|
+
target = f"{commit}~1..{commit}" if commit is not None else source
|
|
227
|
+
if (out := self.ccx_diff(target, scope=scope, budget=budget)) is not None:
|
|
214
228
|
return out
|
|
229
|
+
git_scope = ("--", scope) if scope else ()
|
|
230
|
+
if commit is not None:
|
|
231
|
+
return self.bounded_git(budget, "show", "--stat", "-p", commit, *git_scope)
|
|
215
232
|
match source:
|
|
216
233
|
case "uncommitted":
|
|
217
234
|
args = ["diff"]
|
|
@@ -219,7 +236,17 @@ class HookContext:
|
|
|
219
236
|
args = ["diff", "--staged"]
|
|
220
237
|
case ref:
|
|
221
238
|
args = ["diff", ref]
|
|
222
|
-
return self.
|
|
239
|
+
return self.bounded_git(budget, *args, *git_scope)
|
|
240
|
+
|
|
241
|
+
def ccx_diff(self, target: str, *, scope: str | None, budget: int) -> str | None:
|
|
242
|
+
cmd = ["ccx", "diff", target, "--budget", str(budget), *(("--scope", scope) if scope else ())]
|
|
243
|
+
out = self.call_cli(cmd, throw=False)
|
|
244
|
+
return out if out is not None and ("@@" in out or "diff --git" in out) else None
|
|
245
|
+
|
|
246
|
+
def bounded_git(self, budget: int, *args: str) -> str | None:
|
|
247
|
+
if (out := self.git(*args)) is None or len(out) <= (limit := budget * 4):
|
|
248
|
+
return out
|
|
249
|
+
return out[:limit].rstrip() + f"\n... [diff truncated to ~{budget} tokens] ..."
|
|
223
250
|
|
|
224
251
|
@cached_property
|
|
225
252
|
def changed_paths(self) -> frozenset[Path] | None:
|
|
@@ -237,6 +264,36 @@ class HookContext:
|
|
|
237
264
|
def current_branch(self) -> str | None:
|
|
238
265
|
return out.strip() if (out := self.git("symbolic-ref", "--short", "HEAD")) else None
|
|
239
266
|
|
|
267
|
+
@overload
|
|
268
|
+
def call_llm[M: BaseModel](
|
|
269
|
+
self,
|
|
270
|
+
template: str | Prompt,
|
|
271
|
+
*args: Any,
|
|
272
|
+
specialty: TSpecialty = "general",
|
|
273
|
+
model: TModel = "small",
|
|
274
|
+
timeout: int = 180,
|
|
275
|
+
transcript: bool | int | Literal["recent", "full"] = False,
|
|
276
|
+
diff: bool | str = False,
|
|
277
|
+
agent: bool = False,
|
|
278
|
+
response_model: type[M],
|
|
279
|
+
**kwargs: Any,
|
|
280
|
+
) -> M: ...
|
|
281
|
+
|
|
282
|
+
@overload
|
|
283
|
+
def call_llm(
|
|
284
|
+
self,
|
|
285
|
+
template: str | Prompt,
|
|
286
|
+
*args: Any,
|
|
287
|
+
specialty: TSpecialty = "general",
|
|
288
|
+
model: TModel = "small",
|
|
289
|
+
timeout: int = 180,
|
|
290
|
+
transcript: bool | int | Literal["recent", "full"] = False,
|
|
291
|
+
diff: bool | str = False,
|
|
292
|
+
agent: bool = False,
|
|
293
|
+
response_model: None = None,
|
|
294
|
+
**kwargs: Any,
|
|
295
|
+
) -> str: ...
|
|
296
|
+
|
|
240
297
|
def call_llm(
|
|
241
298
|
self,
|
|
242
299
|
template: str | Prompt,
|
|
@@ -251,21 +308,30 @@ class HookContext:
|
|
|
251
308
|
**kwargs: Any,
|
|
252
309
|
) -> str | BaseModel:
|
|
253
310
|
diff_text = self.diff("uncommitted" if diff is True else diff) if diff else None
|
|
254
|
-
|
|
255
|
-
prompt = str(template.context("diff", diff_text))
|
|
256
|
-
if transcript:
|
|
257
|
-
prompt = f"{self.transcript_block(window=transcript_window(transcript))}\n\n<task>\n{prompt}\n</task>"
|
|
258
|
-
else:
|
|
259
|
-
block = self.transcript_block(window=transcript_window(transcript)) if transcript else ""
|
|
260
|
-
if transcript:
|
|
261
|
-
template = f"{{transcript}}\n\n<task>\n{template}\n</task>"
|
|
262
|
-
prompt = template.format(*args, **kwargs, transcript=block)
|
|
263
|
-
if diff_text is not None:
|
|
264
|
-
prompt = f"<diff>\n{diff_text}\n</diff>\n\n{prompt}"
|
|
265
|
-
backend = LlmBackends.for_specialty(specialty)
|
|
311
|
+
prompt = self.assemble_prompt(template, args, kwargs, transcript=transcript, diff_text=diff_text)
|
|
266
312
|
cwd = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR")
|
|
267
313
|
if response_model is not None:
|
|
268
314
|
return extract_sync(
|
|
269
|
-
prompt, response_model,
|
|
315
|
+
prompt, response_model, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout
|
|
270
316
|
)
|
|
271
|
-
return call_sync(prompt,
|
|
317
|
+
return call_sync(prompt, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout)
|
|
318
|
+
|
|
319
|
+
def assemble_prompt(
|
|
320
|
+
self,
|
|
321
|
+
template: str | Prompt,
|
|
322
|
+
args: tuple[Any, ...],
|
|
323
|
+
kwargs: dict[str, Any],
|
|
324
|
+
*,
|
|
325
|
+
transcript: bool | int | Literal["recent", "full"],
|
|
326
|
+
diff_text: str | None,
|
|
327
|
+
) -> str:
|
|
328
|
+
window = transcript_window(transcript) if transcript else None
|
|
329
|
+
match template:
|
|
330
|
+
case Prompt():
|
|
331
|
+
prompt = str(template.context("diff", diff_text))
|
|
332
|
+
return f"{self.transcript_block(window=window)}\n\n<task>\n{prompt}\n</task>" if transcript else prompt
|
|
333
|
+
case str():
|
|
334
|
+
block = self.transcript_block(window=window) if transcript else ""
|
|
335
|
+
wrapped = f"{{transcript}}\n\n<task>\n{template}\n</task>" if transcript else template
|
|
336
|
+
body = wrapped.format(*args, **kwargs, transcript=block)
|
|
337
|
+
return f"<diff>\n{diff_text}\n</diff>\n\n{body}" if diff_text is not None else body
|
|
@@ -23,6 +23,22 @@ def run_declarative(spec: HookSpec, evt: BaseHookEvent) -> HookResult | None:
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def record_fire(
|
|
27
|
+
entry: RegisteredHook,
|
|
28
|
+
evt: BaseHookEvent,
|
|
29
|
+
result: HookResult,
|
|
30
|
+
store: SessionStore,
|
|
31
|
+
hook_state: HookState,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""React to a hook firing: bump and persist the fire count, then record the decision."""
|
|
34
|
+
hook_state.fire_count += 1
|
|
35
|
+
store[HookState].set(hook_state)
|
|
36
|
+
try:
|
|
37
|
+
record_decision(entry, evt, result)
|
|
38
|
+
except Exception:
|
|
39
|
+
logger.bind(hook=entry.name).exception("decision write failed")
|
|
40
|
+
|
|
41
|
+
|
|
26
42
|
def execute_hook(
|
|
27
43
|
entry: RegisteredHook,
|
|
28
44
|
evt: BaseHookEvent,
|
|
@@ -46,12 +62,7 @@ def execute_hook(
|
|
|
46
62
|
return None
|
|
47
63
|
|
|
48
64
|
if result:
|
|
49
|
-
|
|
50
|
-
store[HookState].set(hook_state)
|
|
51
|
-
try:
|
|
52
|
-
record_decision(entry, evt, result)
|
|
53
|
-
except Exception:
|
|
54
|
-
logger.bind(hook=entry.name).exception("decision write failed")
|
|
65
|
+
record_fire(entry, evt, result, store, hook_state)
|
|
55
66
|
|
|
56
67
|
return result
|
|
57
68
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Durable, cross-session state: the durable twin of ``SessionStore``.
|
|
2
|
+
|
|
3
|
+
Where :class:`~captain_hook.SessionStore` is scoped to one session, a
|
|
4
|
+
:class:`DurableStore` persists across sessions, keyed by project (default) or globally.
|
|
5
|
+
Its slots add a ``filelock``-guarded :meth:`DurableSlot.mutate` so concurrent writers
|
|
6
|
+
across sessions never lose an update.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterator, Sequence
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from hashlib import sha256
|
|
14
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self
|
|
15
|
+
|
|
16
|
+
from filelock import FileLock
|
|
17
|
+
from loguru import logger
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from captain_hook.session import SessionSlot
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from captain_hook.events import BaseHookEvent
|
|
26
|
+
|
|
27
|
+
Scope = Literal["project", "global"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def durable_root() -> Path:
|
|
31
|
+
from captain_hook.settings import resolve_state_dir
|
|
32
|
+
|
|
33
|
+
return resolve_state_dir() / "hooks" / "durable"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def project_key(repo_root: Path) -> str:
|
|
37
|
+
return f"{repo_root.name}-{sha256(str(repo_root.resolve()).encode()).hexdigest()[:16]}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def durable_dir(scope: Scope, repo_root: Path | None) -> Path | None:
|
|
41
|
+
match scope:
|
|
42
|
+
case "global":
|
|
43
|
+
return durable_root() / "global"
|
|
44
|
+
case "project" if repo_root is not None:
|
|
45
|
+
return durable_root() / "projects" / project_key(repo_root)
|
|
46
|
+
case "project":
|
|
47
|
+
logger.warning("durable project state has no repo_root; not persisting")
|
|
48
|
+
return None
|
|
49
|
+
case _:
|
|
50
|
+
raise ValueError(f"scope must be 'project' or 'global', got {scope!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DurableSlot[M: BaseModel](SessionSlot[M]):
|
|
54
|
+
"""A :class:`SessionSlot` rooted in a durable directory, with a locked :meth:`mutate`."""
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def mutate(self) -> Iterator[M]:
|
|
58
|
+
"""Yield the loaded model under an exclusive file lock; persist it on clean exit.
|
|
59
|
+
|
|
60
|
+
The lock is held for the whole ``with`` block, so concurrent writers across sessions
|
|
61
|
+
serialize rather than clobber. The body must be short — no slow work under the lock.
|
|
62
|
+
A null slot (project scope with no ``repo_root``) yields an in-memory model and
|
|
63
|
+
persists nothing.
|
|
64
|
+
"""
|
|
65
|
+
if self._path is None:
|
|
66
|
+
yield self.get(self._model())
|
|
67
|
+
return
|
|
68
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
lock = self._path.with_name(self._path.name + ".lock")
|
|
70
|
+
with FileLock(str(lock)):
|
|
71
|
+
obj = self.get(self._model())
|
|
72
|
+
yield obj
|
|
73
|
+
self.set(obj)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DurableStore:
|
|
77
|
+
"""Class-keyed durable store providing typed :class:`DurableSlot` access via ``store[Model]``."""
|
|
78
|
+
|
|
79
|
+
TRACKED: ClassVar[list[type[BaseModel]]] = []
|
|
80
|
+
|
|
81
|
+
def __init__(self, directory: Path | None) -> None:
|
|
82
|
+
self._dir = directory
|
|
83
|
+
|
|
84
|
+
def __getitem__[M: BaseModel](self, model: type[M]) -> DurableSlot[M]:
|
|
85
|
+
return DurableSlot(self._dir, model)
|
|
86
|
+
|
|
87
|
+
def load[M: BaseModel](self, model: type[M]) -> M:
|
|
88
|
+
return self[model].get(model())
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def for_event(cls, evt: BaseHookEvent, *, scope: Scope = "project") -> DurableStore:
|
|
92
|
+
return cls(durable_dir(scope, evt.ctx.repo_root))
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def track(cls, model: type[BaseModel]) -> None:
|
|
96
|
+
if model not in cls.TRACKED:
|
|
97
|
+
cls.TRACKED.append(model)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def untrack(cls, model: type[BaseModel]) -> None:
|
|
101
|
+
if model in cls.TRACKED:
|
|
102
|
+
cls.TRACKED.remove(model)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def tracked_models(cls) -> Sequence[type[BaseModel]]:
|
|
106
|
+
return tuple(cls.TRACKED)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DurableState(BaseModel):
|
|
110
|
+
"""Base for a model persisted across sessions, scoped by the ``scope`` class keyword.
|
|
111
|
+
|
|
112
|
+
Declare the scope at subclass time — ``class Foo(DurableState, scope="global")``; it
|
|
113
|
+
defaults to ``"project"`` (keyed by the repo root). The subclass carries ``load``/``save``/
|
|
114
|
+
``reset`` plus a locked :meth:`mutate` context manager. Reach for it when state must
|
|
115
|
+
outlive a single session; for within-session sharing use
|
|
116
|
+
[`workflow_state`][captain_hook.workflow_state] instead.
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> from captain_hook import Deque
|
|
120
|
+
>>> class JsonShapes(DurableState, scope="global"):
|
|
121
|
+
... shapes: Deque[256]
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
__durable_scope__: ClassVar[Scope] = "project"
|
|
125
|
+
|
|
126
|
+
def __init_subclass__(cls, *, scope: Scope = "project", **kwargs: Any) -> None:
|
|
127
|
+
super().__init_subclass__(**kwargs)
|
|
128
|
+
if scope not in ("project", "global"):
|
|
129
|
+
raise ValueError(f"scope must be 'project' or 'global', got {scope!r}")
|
|
130
|
+
cls.__durable_scope__ = scope
|
|
131
|
+
DurableStore.track(cls)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def load(cls, evt: BaseHookEvent) -> Self:
|
|
135
|
+
return DurableStore.for_event(evt, scope=cls.__durable_scope__).load(cls)
|
|
136
|
+
|
|
137
|
+
def save(self, evt: BaseHookEvent) -> None:
|
|
138
|
+
DurableStore.for_event(evt, scope=type(self).__durable_scope__)[type(self)].set(self)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def reset(cls, evt: BaseHookEvent) -> None:
|
|
142
|
+
DurableStore.for_event(evt, scope=cls.__durable_scope__)[cls].delete()
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
@contextmanager
|
|
146
|
+
def mutate(cls, evt: BaseHookEvent) -> Iterator[Self]:
|
|
147
|
+
with DurableStore.for_event(evt, scope=cls.__durable_scope__)[cls].mutate() as obj:
|
|
148
|
+
yield obj
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Reusable Pydantic field types for hook state models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
8
|
+
|
|
9
|
+
from pydantic_core import core_schema
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pydantic import GetCoreSchemaHandler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Deque:
|
|
17
|
+
"""A bounded ``deque`` field type whose ``maxlen`` survives JSON round-trips.
|
|
18
|
+
|
|
19
|
+
Subscript with the cap: ``Deque[256]`` is a ``deque[str]`` capped at 256, and
|
|
20
|
+
``Deque[int, 256]`` sets the element type. A bare ``Deque`` field (no ``= Field(...)``)
|
|
21
|
+
defaults to an empty bounded deque, the cap is re-applied on every load, and the deque
|
|
22
|
+
auto-evicts its oldest item on append — so a growing collection stays bounded across
|
|
23
|
+
sessions. Unlike :class:`annotated_types.MaxLen`, which only rejects over-long input,
|
|
24
|
+
this caps the collection instead of raising.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> from captain_hook import DurableState, Deque
|
|
28
|
+
>>> class JsonShapes(DurableState, scope="global"):
|
|
29
|
+
... shapes: Deque[256]
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
maxlen: int
|
|
33
|
+
|
|
34
|
+
def __class_getitem__(cls, params: int | tuple[type, int]) -> Any:
|
|
35
|
+
match params:
|
|
36
|
+
case int(maxlen):
|
|
37
|
+
return Annotated[deque[str], cls(maxlen)]
|
|
38
|
+
case (item, int(maxlen)):
|
|
39
|
+
return Annotated[deque[item], cls(maxlen)]
|
|
40
|
+
case _:
|
|
41
|
+
raise TypeError(f"Deque[...] takes an int cap or (item, cap), got {params!r}")
|
|
42
|
+
|
|
43
|
+
def __get_pydantic_core_schema__(self, source: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
|
|
44
|
+
bounded = core_schema.no_info_after_validator_function(lambda d: deque(d, maxlen=self.maxlen), handler(source))
|
|
45
|
+
return core_schema.with_default_schema(bounded, default_factory=lambda: deque(maxlen=self.maxlen))
|
|
@@ -28,8 +28,9 @@ from captain_hook.types import (
|
|
|
28
28
|
FAILURE_ROOT = state.CACHE_ROOT / "failures"
|
|
29
29
|
|
|
30
30
|
if TYPE_CHECKING:
|
|
31
|
+
from spawnllm import TModel, TSpecialty
|
|
32
|
+
|
|
31
33
|
from captain_hook.events import BaseHookEvent
|
|
32
|
-
from captain_hook.llm import TModel, TSpecialty
|
|
33
34
|
from captain_hook.signals.nlp import NlpSignal
|
|
34
35
|
|
|
35
36
|
|
|
@@ -377,7 +378,6 @@ def prompt_check(
|
|
|
377
378
|
if not verdict:
|
|
378
379
|
return None
|
|
379
380
|
|
|
380
|
-
assert isinstance(verdict, response_model)
|
|
381
381
|
match verdict.action:
|
|
382
382
|
case "block":
|
|
383
383
|
return HookResult(action=Action.block, message=f"{prefix}: {verdict.reason}{suffix}")
|