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.
Files changed (108) hide show
  1. {capt_hook-4.2.0 → capt_hook-4.3.0}/PKG-INFO +2 -2
  2. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/__init__.py +4 -2
  3. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/context.py +55 -17
  4. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/dispatch.py +17 -6
  5. capt_hook-4.3.0/captain_hook/durable.py +148 -0
  6. capt_hook-4.3.0/captain_hook/fields.py +45 -0
  7. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/llm.py +2 -2
  8. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/cli.py +47 -84
  9. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/pipeline.py +2 -3
  10. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/types.py +1 -1
  11. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/types.py +23 -26
  12. capt_hook-4.3.0/captain_hook/util/__init__.py +2 -0
  13. capt_hook-4.2.0/captain_hook/utils.py → capt_hook-4.3.0/captain_hook/util/fs.py +0 -6
  14. capt_hook-4.3.0/captain_hook/util/text.py +8 -0
  15. {capt_hook-4.2.0 → capt_hook-4.3.0}/pyproject.toml +2 -2
  16. capt_hook-4.2.0/captain_hook/llm/__init__.py +0 -7
  17. capt_hook-4.2.0/captain_hook/tests/__init__.py +0 -13
  18. capt_hook-4.2.0/captain_hook/tests/helpers.py +0 -361
  19. capt_hook-4.2.0/captain_hook/tests/review_helpers.py +0 -141
  20. capt_hook-4.2.0/captain_hook/util/__init__.py +0 -0
  21. {capt_hook-4.2.0 → capt_hook-4.3.0}/LICENSE +0 -0
  22. {capt_hook-4.2.0 → capt_hook-4.3.0}/README.md +0 -0
  23. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  24. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/__main__.py +0 -0
  25. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/app.py +0 -0
  26. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/ast_grep.py +0 -0
  27. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/classifiers/__init__.py +0 -0
  28. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/classifiers/conductor.py +0 -0
  29. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/classifiers/droid.py +0 -0
  30. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/classifiers/native.py +0 -0
  31. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/cli.py +0 -0
  32. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/command.py +0 -0
  33. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/conditions.py +0 -0
  34. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/decisions.py +0 -0
  35. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/events.py +0 -0
  36. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/file.py +0 -0
  37. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/loader.py +0 -0
  38. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/log.py +0 -0
  39. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/__init__.py +0 -0
  40. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/__init__.py +0 -0
  41. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  42. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/commands.py +0 -0
  43. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/docs.py +0 -0
  44. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/plans.py +0 -0
  45. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/prompts.py +0 -0
  46. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/review.py +0 -0
  47. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/general/tasks.py +0 -0
  48. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/go/__init__.py +0 -0
  49. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  50. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/go/testing.py +0 -0
  51. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/go/toolchain.py +0 -0
  52. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/manager.py +0 -0
  53. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/__init__.py +0 -0
  54. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  55. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/style.py +0 -0
  56. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/testing.py +0 -0
  57. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/python/toolchain.py +0 -0
  58. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/steering/__init__.py +0 -0
  59. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  60. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/packs/steering/steering.py +0 -0
  61. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/__init__.py +0 -0
  62. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/commands.py +0 -0
  63. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/lint.py +0 -0
  64. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/nudge.py +0 -0
  65. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/rewrite.py +0 -0
  66. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/primitives/workflow.py +0 -0
  67. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/prompt.py +0 -0
  68. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/py.typed +0 -0
  69. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/__init__.py +0 -0
  70. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/dashboard.py +0 -0
  71. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/fix.py +0 -0
  72. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/formats.py +0 -0
  73. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/judge.py +0 -0
  74. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/repo.py +0 -0
  75. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/scan.py +0 -0
  76. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/settings.py +0 -0
  77. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/store.py +0 -0
  78. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/review/sync.py +0 -0
  79. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/session.py +0 -0
  80. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/settings.py +0 -0
  81. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/signals/__init__.py +0 -0
  82. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/signals/nlp.py +0 -0
  83. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  84. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  85. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  86. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  87. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  88. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  89. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  90. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  91. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  92. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  93. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  94. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  95. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  96. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/state.py +0 -0
  97. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/__init__.py +0 -0
  98. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/ast_grep.py +0 -0
  99. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/matchers.py +0 -0
  100. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/style/scope.py +0 -0
  101. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/tasks.py +0 -0
  102. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  103. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/testing/__init__.py +0 -0
  104. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/testing/helpers.py +0 -0
  105. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/testing/session_cache.py +0 -0
  106. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/testing/types.py +0 -0
  107. {capt_hook-4.2.0 → capt_hook-4.3.0}/captain_hook/util/http.py +0 -0
  108. {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.2.0
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, utils
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.utils import read_json, resolve_binary
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
- if isinstance(template, Prompt):
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, backend=backend, model=model, agent=agent, cwd=cwd, timeout=timeout
315
+ prompt, response_model, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout
298
316
  )
299
- return call_sync(prompt, backend=backend, model=model, agent=agent, cwd=cwd, timeout=timeout)
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
- hook_state.fire_count += 1
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 watch_repo(repo: RepoKey) -> None:
81
- """Flip the global watching bit for ``repo``.
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() -> None:
86
+ async def go() -> T:
89
87
  async with await ReviewStore.open(ReviewSettings().db_path) as store:
90
- await store.enable(repo)
88
+ return await fn(store)
89
+
90
+ return asyncio.run(go())
91
91
 
92
- asyncio.run(go())
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 go() -> tuple[dict[str, object], ThresholdStatus, bool, tuple[Correction, ...]]:
275
- async with await ReviewStore.open(settings.db_path) as store:
276
- return (
277
- await store.candidate(candidate_id),
278
- await store.threshold_status(candidate_id, settings=settings, prompt_version=REVIEW_PROMPT_VERSION),
279
- await store.eligible(candidate_id, settings=settings, prompt_version=REVIEW_PROMPT_VERSION),
280
- await store.correction_evidence(candidate_id),
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 = asyncio.run(go())
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 go() -> list[str]:
312
- async with await ReviewStore.open(settings.db_path) as store:
313
- ids = (
314
- [candidate_id]
315
- if candidate_id is not None
316
- else [int(str(row["id"])) for row in await store.candidates(resolve_repo(repo_, state.root))]
317
- )
318
- return [
319
- f"#{cid} eligible={await store.eligible(cid, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)}"
320
- f" sessions={status.sessions}/{settings.min_sessions} days={status.days}/{settings.min_days}"
321
- f" open_prs={status.open_prs}/{settings.max_open_prs} watching={status.watching}"
322
- for cid in ids
323
- if (
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 = asyncio.run(go())
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.settings import ReviewSettings
345
- from captain_hook.review.store import CandidateStatus, InvalidTransition, ReviewStore
316
+ from captain_hook.review.store import CandidateStatus, InvalidTransition
346
317
 
347
- async def go() -> None:
348
- async with await ReviewStore.open(ReviewSettings().db_path) as store:
349
- await store.transition(
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 = ClaudeBackend()
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.utils import kebab
10
+ from captain_hook.util import kebab
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from captain_hook.style.matchers import Matcher