capt-hook 4.2.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.2.0 → capt_hook-4.3.0}/PKG-INFO +2 -2
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/__init__.py +4 -2
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/context.py +55 -17
- {capt_hook-4.2.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.2.0 → capt_hook-4.3.0}/captain_hook/primitives/llm.py +2 -2
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/cli.py +47 -84
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/pipeline.py +2 -3
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/types.py +1 -1
- {capt_hook-4.2.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.2.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.2.0 → capt_hook-4.3.0}/pyproject.toml +2 -2
- capt_hook-4.2.0/captain_hook/llm/__init__.py +0 -7
- capt_hook-4.2.0/captain_hook/tests/__init__.py +0 -13
- capt_hook-4.2.0/captain_hook/tests/helpers.py +0 -361
- capt_hook-4.2.0/captain_hook/tests/review_helpers.py +0 -141
- capt_hook-4.2.0/captain_hook/util/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/LICENSE +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/README.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/__main__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/app.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/cli.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/command.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/conditions.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/decisions.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/events.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/file.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/loader.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/log.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/steering/steering.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/prompt.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/py.typed +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/store.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/session.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/settings.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/state.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/tasks.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/util/http.py +0 -0
- {capt_hook-4.2.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
|
|
|
@@ -265,6 +264,36 @@ class HookContext:
|
|
|
265
264
|
def current_branch(self) -> str | None:
|
|
266
265
|
return out.strip() if (out := self.git("symbolic-ref", "--short", "HEAD")) else None
|
|
267
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
|
+
|
|
268
297
|
def call_llm(
|
|
269
298
|
self,
|
|
270
299
|
template: str | Prompt,
|
|
@@ -279,21 +308,30 @@ class HookContext:
|
|
|
279
308
|
**kwargs: Any,
|
|
280
309
|
) -> str | BaseModel:
|
|
281
310
|
diff_text = self.diff("uncommitted" if diff is True else diff) if diff else None
|
|
282
|
-
|
|
283
|
-
prompt = str(template.context("diff", diff_text))
|
|
284
|
-
if transcript:
|
|
285
|
-
prompt = f"{self.transcript_block(window=transcript_window(transcript))}\n\n<task>\n{prompt}\n</task>"
|
|
286
|
-
else:
|
|
287
|
-
block = self.transcript_block(window=transcript_window(transcript)) if transcript else ""
|
|
288
|
-
if transcript:
|
|
289
|
-
template = f"{{transcript}}\n\n<task>\n{template}\n</task>"
|
|
290
|
-
prompt = template.format(*args, **kwargs, transcript=block)
|
|
291
|
-
if diff_text is not None:
|
|
292
|
-
prompt = f"<diff>\n{diff_text}\n</diff>\n\n{prompt}"
|
|
293
|
-
backend = LlmBackends.for_specialty(specialty)
|
|
311
|
+
prompt = self.assemble_prompt(template, args, kwargs, transcript=transcript, diff_text=diff_text)
|
|
294
312
|
cwd = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR")
|
|
295
313
|
if response_model is not None:
|
|
296
314
|
return extract_sync(
|
|
297
|
-
prompt, response_model,
|
|
315
|
+
prompt, response_model, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout
|
|
298
316
|
)
|
|
299
|
-
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}")
|
|
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING
|
|
|
19
19
|
import click
|
|
20
20
|
|
|
21
21
|
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
22
23
|
from typing import Any
|
|
23
24
|
|
|
24
25
|
from cc_transcript.corrections import Correction
|
|
@@ -27,7 +28,7 @@ if TYPE_CHECKING:
|
|
|
27
28
|
from captain_hook.review.judge import JudgeReport
|
|
28
29
|
from captain_hook.review.repo import RepoKey
|
|
29
30
|
from captain_hook.review.scan import ScanReport
|
|
30
|
-
from captain_hook.review.store import ThresholdStatus
|
|
31
|
+
from captain_hook.review.store import ReviewStore, ThresholdStatus
|
|
31
32
|
from captain_hook.review.sync import SyncReport
|
|
32
33
|
|
|
33
34
|
REVIEW_RUN_COMMAND = "capt-hook review run"
|
|
@@ -77,19 +78,24 @@ def ensure_review_wiring(settings_path: Path) -> bool:
|
|
|
77
78
|
return True
|
|
78
79
|
|
|
79
80
|
|
|
80
|
-
def
|
|
81
|
-
"""
|
|
82
|
-
|
|
83
|
-
The single persistence path shared by ``init`` and ``review enable``.
|
|
84
|
-
"""
|
|
81
|
+
def run_store[T](fn: Callable[[ReviewStore], Awaitable[T]]) -> T:
|
|
82
|
+
"""Open the review store, run ``fn`` against it, and bridge with ``asyncio.run`` at the command boundary."""
|
|
85
83
|
from captain_hook.review.settings import ReviewSettings
|
|
86
84
|
from captain_hook.review.store import ReviewStore
|
|
87
85
|
|
|
88
|
-
async def go() ->
|
|
86
|
+
async def go() -> T:
|
|
89
87
|
async with await ReviewStore.open(ReviewSettings().db_path) as store:
|
|
90
|
-
await store
|
|
88
|
+
return await fn(store)
|
|
89
|
+
|
|
90
|
+
return asyncio.run(go())
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
|
|
93
|
+
def watch_repo(repo: RepoKey) -> None:
|
|
94
|
+
"""Flip the global watching bit for ``repo``.
|
|
95
|
+
|
|
96
|
+
The single persistence path shared by ``init`` and ``review enable``.
|
|
97
|
+
"""
|
|
98
|
+
run_store(lambda store: store.enable(repo))
|
|
93
99
|
|
|
94
100
|
|
|
95
101
|
def candidate_line(row: dict[str, object]) -> str:
|
|
@@ -141,16 +147,8 @@ def enable(state: CliState) -> None:
|
|
|
141
147
|
@click.pass_obj
|
|
142
148
|
def disable(state: CliState) -> None:
|
|
143
149
|
"""Stop watching the current repo (its candidates stay recorded)."""
|
|
144
|
-
from captain_hook.review.settings import ReviewSettings
|
|
145
|
-
from captain_hook.review.store import ReviewStore
|
|
146
|
-
|
|
147
150
|
repo = current_repo(state.root)
|
|
148
|
-
|
|
149
|
-
async def go() -> None:
|
|
150
|
-
async with await ReviewStore.open(ReviewSettings().db_path) as store:
|
|
151
|
-
await store.disable(repo)
|
|
152
|
-
|
|
153
|
-
asyncio.run(go())
|
|
151
|
+
run_store(lambda store: store.disable(repo))
|
|
154
152
|
click.echo(f"not watching {repo}")
|
|
155
153
|
|
|
156
154
|
|
|
@@ -173,17 +171,11 @@ def scan(transcripts: tuple[Path, ...], dirs: tuple[Path, ...]) -> None:
|
|
|
173
171
|
"""Scan explicit transcripts for corrections, incrementally."""
|
|
174
172
|
from captain_hook.review.scan import scan as run_scan
|
|
175
173
|
from captain_hook.review.settings import ReviewSettings
|
|
176
|
-
from captain_hook.review.store import ReviewStore
|
|
177
174
|
|
|
178
175
|
if not transcripts and not dirs:
|
|
179
176
|
raise click.UsageError("pass at least one --transcript or --dir")
|
|
180
177
|
settings = ReviewSettings()
|
|
181
|
-
|
|
182
|
-
async def go() -> ScanReport:
|
|
183
|
-
async with await ReviewStore.open(settings.db_path) as store:
|
|
184
|
-
return await run_scan(store, settings=settings, transcripts=[*transcripts, *dirs])
|
|
185
|
-
|
|
186
|
-
report = asyncio.run(go())
|
|
178
|
+
report: ScanReport = run_store(lambda store: run_scan(store, settings=settings, transcripts=[*transcripts, *dirs]))
|
|
187
179
|
click.echo(f"scanned {report.scanned} transcripts, {report.inserted} new corrections")
|
|
188
180
|
|
|
189
181
|
|
|
@@ -193,15 +185,9 @@ def triage(limit: int | None) -> None:
|
|
|
193
185
|
"""Judge stored corrections lacking a verdict (manual/backfill)."""
|
|
194
186
|
from captain_hook.review.judge import judge_pass
|
|
195
187
|
from captain_hook.review.settings import ReviewSettings
|
|
196
|
-
from captain_hook.review.store import ReviewStore
|
|
197
188
|
|
|
198
189
|
settings = ReviewSettings()
|
|
199
|
-
|
|
200
|
-
async def go() -> JudgeReport:
|
|
201
|
-
async with await ReviewStore.open(settings.db_path) as store:
|
|
202
|
-
return await judge_pass(store, settings=settings, limit=limit)
|
|
203
|
-
|
|
204
|
-
report = asyncio.run(go())
|
|
190
|
+
report: JudgeReport = run_store(lambda store: judge_pass(store, settings=settings, limit=limit))
|
|
205
191
|
click.echo(f"judged {report.judged}, failed {report.failed}, pending {report.pending}")
|
|
206
192
|
|
|
207
193
|
|
|
@@ -221,16 +207,8 @@ def status(state: CliState, repo_: str | None, sync: bool) -> None:
|
|
|
221
207
|
@click.pass_obj
|
|
222
208
|
def list_candidates(state: CliState, repo_: str | None) -> None:
|
|
223
209
|
"""List the repo's PR candidates."""
|
|
224
|
-
from captain_hook.review.settings import ReviewSettings
|
|
225
|
-
from captain_hook.review.store import ReviewStore
|
|
226
|
-
|
|
227
210
|
repo = resolve_repo(repo_, state.root)
|
|
228
|
-
|
|
229
|
-
async def go() -> list[dict[str, object]]:
|
|
230
|
-
async with await ReviewStore.open(ReviewSettings().db_path) as store:
|
|
231
|
-
return await store.candidates(repo)
|
|
232
|
-
|
|
233
|
-
if not (rows := asyncio.run(go())):
|
|
211
|
+
if not (rows := run_store(lambda store: store.candidates(repo))):
|
|
234
212
|
click.echo(f"no candidates for {repo}")
|
|
235
213
|
for row in rows:
|
|
236
214
|
click.echo(candidate_line(row))
|
|
@@ -267,21 +245,19 @@ def show(candidate_id: int) -> None:
|
|
|
267
245
|
"""Show one candidate's row, its threshold status, and the shared ledger's faulted edits."""
|
|
268
246
|
from captain_hook.review.judge import REVIEW_PROMPT_VERSION
|
|
269
247
|
from captain_hook.review.settings import ReviewSettings
|
|
270
|
-
from captain_hook.review.store import ReviewStore
|
|
271
248
|
|
|
272
249
|
settings = ReviewSettings()
|
|
273
250
|
|
|
274
|
-
async def
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
)
|
|
251
|
+
async def body(store: ReviewStore) -> tuple[dict[str, object], ThresholdStatus, bool, tuple[Correction, ...]]:
|
|
252
|
+
return (
|
|
253
|
+
await store.candidate(candidate_id),
|
|
254
|
+
await store.threshold_status(candidate_id, settings=settings, prompt_version=REVIEW_PROMPT_VERSION),
|
|
255
|
+
await store.eligible(candidate_id, settings=settings, prompt_version=REVIEW_PROMPT_VERSION),
|
|
256
|
+
await store.correction_evidence(candidate_id),
|
|
257
|
+
)
|
|
282
258
|
|
|
283
259
|
try:
|
|
284
|
-
row, status, ok, evidence =
|
|
260
|
+
row, status, ok, evidence = run_store(body)
|
|
285
261
|
except LookupError as exc:
|
|
286
262
|
raise click.ClickException(str(exc)) from exc
|
|
287
263
|
for key, value in row.items():
|
|
@@ -304,29 +280,25 @@ def threshold_check(state: CliState, candidate_id: int | None, repo_: str | None
|
|
|
304
280
|
"""Report which candidates cross their PR thresholds."""
|
|
305
281
|
from captain_hook.review.judge import REVIEW_PROMPT_VERSION
|
|
306
282
|
from captain_hook.review.settings import ReviewSettings
|
|
307
|
-
from captain_hook.review.store import ReviewStore
|
|
308
283
|
|
|
309
284
|
settings = ReviewSettings()
|
|
310
285
|
|
|
311
|
-
async def
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
status := await store.threshold_status(cid, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)
|
|
325
|
-
)
|
|
326
|
-
]
|
|
286
|
+
async def body(store: ReviewStore) -> list[str]:
|
|
287
|
+
ids = (
|
|
288
|
+
[candidate_id]
|
|
289
|
+
if candidate_id is not None
|
|
290
|
+
else [int(str(row["id"])) for row in await store.candidates(resolve_repo(repo_, state.root))]
|
|
291
|
+
)
|
|
292
|
+
return [
|
|
293
|
+
f"#{cid} eligible={await store.eligible(cid, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)}"
|
|
294
|
+
f" sessions={status.sessions}/{settings.min_sessions} days={status.days}/{settings.min_days}"
|
|
295
|
+
f" open_prs={status.open_prs}/{settings.max_open_prs} watching={status.watching}"
|
|
296
|
+
for cid in ids
|
|
297
|
+
if (status := await store.threshold_status(cid, settings=settings, prompt_version=REVIEW_PROMPT_VERSION))
|
|
298
|
+
]
|
|
327
299
|
|
|
328
300
|
try:
|
|
329
|
-
lines =
|
|
301
|
+
lines = run_store(body)
|
|
330
302
|
except LookupError as exc:
|
|
331
303
|
raise click.ClickException(str(exc)) from exc
|
|
332
304
|
if not lines:
|
|
@@ -341,20 +313,17 @@ def threshold_check(state: CliState, candidate_id: int | None, repo_: str | None
|
|
|
341
313
|
@click.option("--pr-url", default=None, help="PR URL stamped with the move (also stamps pr_opened_at)")
|
|
342
314
|
def update(candidate_id: int, status: str, pr_url: str | None) -> None:
|
|
343
315
|
"""Move a candidate to a new status."""
|
|
344
|
-
from captain_hook.review.
|
|
345
|
-
from captain_hook.review.store import CandidateStatus, InvalidTransition, ReviewStore
|
|
316
|
+
from captain_hook.review.store import CandidateStatus, InvalidTransition
|
|
346
317
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
318
|
+
try:
|
|
319
|
+
run_store(
|
|
320
|
+
lambda store: store.transition(
|
|
350
321
|
candidate_id,
|
|
351
322
|
CandidateStatus(status),
|
|
352
323
|
pr_url=pr_url,
|
|
353
324
|
pr_opened_at=datetime.now(UTC) if pr_url else None,
|
|
354
325
|
)
|
|
355
|
-
|
|
356
|
-
try:
|
|
357
|
-
asyncio.run(go())
|
|
326
|
+
)
|
|
358
327
|
except (InvalidTransition, LookupError) as exc:
|
|
359
328
|
raise click.ClickException(str(exc)) from exc
|
|
360
329
|
click.echo(f"#{candidate_id} -> {status}")
|
|
@@ -366,17 +335,11 @@ def update(candidate_id: int, status: str, pr_url: str | None) -> None:
|
|
|
366
335
|
def sync_prs(state: CliState, repo_: str | None) -> None:
|
|
367
336
|
"""Fold open PR states back into candidate statuses."""
|
|
368
337
|
from captain_hook.review.settings import ReviewSettings
|
|
369
|
-
from captain_hook.review.store import ReviewStore
|
|
370
338
|
from captain_hook.review.sync import sync_open_prs
|
|
371
339
|
|
|
372
340
|
settings = ReviewSettings()
|
|
373
341
|
repo = resolve_repo(repo_, state.root)
|
|
374
|
-
|
|
375
|
-
async def go() -> SyncReport:
|
|
376
|
-
async with await ReviewStore.open(settings.db_path) as store:
|
|
377
|
-
return await sync_open_prs(store, repo, settings=settings)
|
|
378
|
-
|
|
379
|
-
report = asyncio.run(go())
|
|
342
|
+
report: SyncReport = run_store(lambda store: sync_open_prs(store, repo, settings=settings))
|
|
380
343
|
click.echo(
|
|
381
344
|
f"accepted {report.accepted}, rejected {report.rejected}, "
|
|
382
345
|
f"stale {report.stale}, unreachable {report.unreachable}"
|
|
@@ -147,12 +147,11 @@ def brain_prompt(transcript: Path) -> str:
|
|
|
147
147
|
|
|
148
148
|
|
|
149
149
|
def brain_argv(*, max_turns: int, max_budget_usd: float) -> list[str]:
|
|
150
|
-
from spawnllm import ClaudeConfig, RunSpec
|
|
150
|
+
from spawnllm import ClaudeCliBackend, ClaudeConfig, RunSpec
|
|
151
151
|
|
|
152
152
|
from captain_hook.cli import plugin_dir
|
|
153
|
-
from captain_hook.llm import ClaudeBackend
|
|
154
153
|
|
|
155
|
-
backend =
|
|
154
|
+
backend = ClaudeCliBackend()
|
|
156
155
|
spec = RunSpec(
|
|
157
156
|
prompt="",
|
|
158
157
|
model=backend.models[BRAIN_TIER],
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from functools import cached_property
|
|
8
8
|
from typing import TYPE_CHECKING, ClassVar
|
|
9
9
|
|
|
10
|
-
from captain_hook.
|
|
10
|
+
from captain_hook.util import kebab
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
13
|
from captain_hook.style.matchers import Matcher
|