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.
Files changed (108) hide show
  1. {capt_hook-4.1.0 → capt_hook-4.3.0}/PKG-INFO +2 -2
  2. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/__init__.py +4 -2
  3. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/context.py +99 -33
  4. {capt_hook-4.1.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.1.0 → capt_hook-4.3.0}/captain_hook/primitives/llm.py +2 -2
  8. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/cli.py +47 -84
  9. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/pipeline.py +2 -3
  10. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/session.py +25 -1
  11. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/state.py +7 -0
  12. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/types.py +1 -1
  13. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/types.py +23 -26
  14. capt_hook-4.3.0/captain_hook/util/__init__.py +2 -0
  15. capt_hook-4.1.0/captain_hook/utils.py → capt_hook-4.3.0/captain_hook/util/fs.py +0 -6
  16. capt_hook-4.3.0/captain_hook/util/text.py +8 -0
  17. {capt_hook-4.1.0 → capt_hook-4.3.0}/pyproject.toml +2 -2
  18. capt_hook-4.1.0/captain_hook/llm/__init__.py +0 -7
  19. capt_hook-4.1.0/captain_hook/tests/__init__.py +0 -13
  20. capt_hook-4.1.0/captain_hook/tests/helpers.py +0 -361
  21. capt_hook-4.1.0/captain_hook/tests/review_helpers.py +0 -141
  22. capt_hook-4.1.0/captain_hook/util/__init__.py +0 -0
  23. {capt_hook-4.1.0 → capt_hook-4.3.0}/LICENSE +0 -0
  24. {capt_hook-4.1.0 → capt_hook-4.3.0}/README.md +0 -0
  25. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  26. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/__main__.py +0 -0
  27. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/app.py +0 -0
  28. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/ast_grep.py +0 -0
  29. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/classifiers/__init__.py +0 -0
  30. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/classifiers/conductor.py +0 -0
  31. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/classifiers/droid.py +0 -0
  32. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/classifiers/native.py +0 -0
  33. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/cli.py +0 -0
  34. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/command.py +0 -0
  35. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/conditions.py +0 -0
  36. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/decisions.py +0 -0
  37. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/events.py +0 -0
  38. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/file.py +0 -0
  39. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/loader.py +0 -0
  40. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/log.py +0 -0
  41. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/__init__.py +0 -0
  42. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/__init__.py +0 -0
  43. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  44. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/commands.py +0 -0
  45. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/docs.py +0 -0
  46. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/plans.py +0 -0
  47. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/prompts.py +0 -0
  48. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/review.py +0 -0
  49. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/general/tasks.py +0 -0
  50. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/go/__init__.py +0 -0
  51. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  52. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/go/testing.py +0 -0
  53. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/go/toolchain.py +0 -0
  54. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/manager.py +0 -0
  55. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/__init__.py +0 -0
  56. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  57. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/style.py +0 -0
  58. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/testing.py +0 -0
  59. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/python/toolchain.py +0 -0
  60. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/steering/__init__.py +0 -0
  61. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  62. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/packs/steering/steering.py +0 -0
  63. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/__init__.py +0 -0
  64. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/commands.py +0 -0
  65. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/lint.py +0 -0
  66. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/nudge.py +0 -0
  67. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/rewrite.py +0 -0
  68. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/primitives/workflow.py +0 -0
  69. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/prompt.py +0 -0
  70. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/py.typed +0 -0
  71. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/__init__.py +0 -0
  72. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/dashboard.py +0 -0
  73. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/fix.py +0 -0
  74. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/formats.py +0 -0
  75. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/judge.py +0 -0
  76. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/repo.py +0 -0
  77. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/scan.py +0 -0
  78. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/settings.py +0 -0
  79. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/store.py +0 -0
  80. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/review/sync.py +0 -0
  81. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/settings.py +0 -0
  82. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/signals/__init__.py +0 -0
  83. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/signals/nlp.py +0 -0
  84. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  85. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  86. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  87. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  88. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  89. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  90. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  91. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  92. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  93. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  94. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  95. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  96. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  97. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/__init__.py +0 -0
  98. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/ast_grep.py +0 -0
  99. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/matchers.py +0 -0
  100. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/style/scope.py +0 -0
  101. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/tasks.py +0 -0
  102. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  103. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/testing/__init__.py +0 -0
  104. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/testing/helpers.py +0 -0
  105. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/testing/session_cache.py +0 -0
  106. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/testing/types.py +0 -0
  107. {capt_hook-4.1.0 → capt_hook-4.3.0}/captain_hook/util/http.py +0 -0
  108. {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.1.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
 
@@ -35,10 +34,14 @@ if TYPE_CHECKING:
35
34
  RECENT_WINDOW = 15
36
35
 
37
36
 
38
- def transcript_window(transcript: bool | int | str) -> int | None:
39
- if transcript == "full":
40
- return None
41
- return transcript if isinstance(transcript, int) and transcript is not True else RECENT_WINDOW
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(self, source: str = "uncommitted", *, scope: str | None = None, budget: int = 4000) -> str | None:
199
- """A compact working-tree diff via ``ccx diff`` when available, else plain ``git diff``.
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 transparently falls back to
202
- ``git diff`` when ``ccx`` is absent or fails, so a hook gets the same diff in any repo.
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"`` (working tree, the default), ``"staged"``, or any git ref.
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: Approximate token budget for the ``ccx`` output (the ``git`` fallback is unbounded).
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
- ccx = ["ccx", "diff", source, "--budget", str(budget), *(("--scope", scope) if scope else ())]
213
- if (out := self.call_cli(ccx, throw=False)) is not None:
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.git(*args, *(("--", scope) if scope else ()))
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
- if isinstance(template, Prompt):
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, backend=backend, model=model, agent=agent, cwd=cwd, timeout=timeout
315
+ prompt, response_model, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout
270
316
  )
271
- 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}")