capt-hook 4.0.0__tar.gz → 4.2.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 (104) hide show
  1. {capt_hook-4.0.0 → capt_hook-4.2.0}/PKG-INFO +1 -1
  2. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/context.py +89 -14
  3. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/review.py +10 -6
  4. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/llm.py +20 -8
  5. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/session.py +25 -1
  6. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/state.py +7 -0
  7. {capt_hook-4.0.0 → capt_hook-4.2.0}/pyproject.toml +1 -1
  8. {capt_hook-4.0.0 → capt_hook-4.2.0}/LICENSE +0 -0
  9. {capt_hook-4.0.0 → capt_hook-4.2.0}/README.md +0 -0
  10. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  11. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/__init__.py +0 -0
  12. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/__main__.py +0 -0
  13. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/app.py +0 -0
  14. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/ast_grep.py +0 -0
  15. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/classifiers/__init__.py +0 -0
  16. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/classifiers/conductor.py +0 -0
  17. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/classifiers/droid.py +0 -0
  18. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/classifiers/native.py +0 -0
  19. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/cli.py +0 -0
  20. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/command.py +0 -0
  21. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/conditions.py +0 -0
  22. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/decisions.py +0 -0
  23. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/dispatch.py +0 -0
  24. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/events.py +0 -0
  25. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/file.py +0 -0
  26. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/llm/__init__.py +0 -0
  27. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/loader.py +0 -0
  28. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/log.py +0 -0
  29. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/__init__.py +0 -0
  30. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/__init__.py +0 -0
  31. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  32. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/commands.py +0 -0
  33. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/docs.py +0 -0
  34. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/plans.py +0 -0
  35. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/prompts.py +0 -0
  36. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/tasks.py +0 -0
  37. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/go/__init__.py +0 -0
  38. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  39. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/go/testing.py +0 -0
  40. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/go/toolchain.py +0 -0
  41. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/manager.py +0 -0
  42. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/__init__.py +0 -0
  43. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  44. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/style.py +0 -0
  45. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/testing.py +0 -0
  46. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/toolchain.py +0 -0
  47. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/steering/__init__.py +0 -0
  48. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  49. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/steering/steering.py +0 -0
  50. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/__init__.py +0 -0
  51. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/commands.py +0 -0
  52. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/lint.py +0 -0
  53. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/nudge.py +0 -0
  54. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/rewrite.py +0 -0
  55. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/workflow.py +0 -0
  56. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/prompt.py +0 -0
  57. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/py.typed +0 -0
  58. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/__init__.py +0 -0
  59. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/cli.py +0 -0
  60. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/dashboard.py +0 -0
  61. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/fix.py +0 -0
  62. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/formats.py +0 -0
  63. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/judge.py +0 -0
  64. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/pipeline.py +0 -0
  65. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/repo.py +0 -0
  66. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/scan.py +0 -0
  67. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/settings.py +0 -0
  68. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/store.py +0 -0
  69. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/sync.py +0 -0
  70. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/settings.py +0 -0
  71. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/signals/__init__.py +0 -0
  72. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/signals/nlp.py +0 -0
  73. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  74. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  75. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  76. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  77. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  78. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  79. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  80. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  81. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  82. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  83. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  84. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  85. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  86. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/__init__.py +0 -0
  87. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/ast_grep.py +0 -0
  88. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/matchers.py +0 -0
  89. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/scope.py +0 -0
  90. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/types.py +0 -0
  91. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/tasks.py +0 -0
  92. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  93. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/testing/__init__.py +0 -0
  94. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/testing/helpers.py +0 -0
  95. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/testing/session_cache.py +0 -0
  96. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/testing/types.py +0 -0
  97. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/tests/__init__.py +0 -0
  98. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/tests/helpers.py +0 -0
  99. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/tests/review_helpers.py +0 -0
  100. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/types.py +0 -0
  101. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/util/__init__.py +0 -0
  102. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/util/http.py +0 -0
  103. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/util/model_cache.py +0 -0
  104. {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 4.0.0
3
+ Version: 4.2.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
@@ -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
8
+ from typing import TYPE_CHECKING, Any, Literal
9
9
 
10
10
  from cc_transcript.activity import SessionActivity, meta_of
11
11
  from cc_transcript.ids import SessionId
@@ -32,6 +32,19 @@ if TYPE_CHECKING:
32
32
  from captain_hook.settings import HooksSettings
33
33
 
34
34
 
35
+ RECENT_WINDOW = 15
36
+
37
+
38
+ def transcript_window(transcript: bool | int | Literal["recent", "full"]) -> int | None:
39
+ match transcript:
40
+ case "full":
41
+ return None
42
+ case True | "recent":
43
+ return RECENT_WINDOW
44
+ case int() as events:
45
+ return events
46
+
47
+
35
48
  class LenientToolUseBlock(ToolUseBlock):
36
49
  """A ``ToolUseBlock`` whose typed parse degrades to ``OtherCall`` instead of raising.
37
50
 
@@ -134,19 +147,26 @@ class HookContext:
134
147
  """The session window before the current turn's last exchange (cached)."""
135
148
  return self.transcript.prior()
136
149
 
137
- def transcript_text(self) -> str:
138
- """The transcript rendered turn by turn under the default budget."""
139
- return "\n\n".join(
140
- rendered for turn in self.transcript.turns if (rendered := render_turn(turn, budget=Budget()))
141
- )
150
+ def transcript_text(self, *, window: int | None = None) -> str:
151
+ """The transcript rendered turn by turn under the default budget.
152
+
153
+ Args:
154
+ window: Render only the most recent ``window`` events; ``None`` renders the whole session.
155
+ """
156
+ src = self.transcript if window is None else self.transcript.recent(window)
157
+ return "\n\n".join(rendered for turn in src.turns if (rendered := render_turn(turn, budget=Budget())))
142
158
 
143
- def transcript_block(self) -> str:
159
+ def transcript_block(self, *, window: int | None = RECENT_WINDOW) -> str:
144
160
  """The rendered transcript wrapped in a ``<transcript>`` tag carrying its source path.
145
161
 
146
- The render clips long turns and tool calls under :class:`Budget`, so an agent-mode
147
- LLM needs the path to read the untruncated content (e.g. a full ``ExitPlanMode`` plan).
162
+ Defaults to a recent-event window rather than the whole session. The render clips long
163
+ turns and tool calls under :class:`Budget`, so an agent-mode LLM uses the path to read
164
+ the untruncated content (e.g. a full ``ExitPlanMode`` plan) or earlier history.
165
+
166
+ Args:
167
+ window: Render only the most recent ``window`` events; ``None`` renders the whole session.
148
168
  """
149
- rendered = self.transcript_text()
169
+ rendered = self.transcript_text(window=window)
150
170
  if (path := self.transcript.path) is not None:
151
171
  return f'<transcript path="{path}">\n{rendered}\n</transcript>'
152
172
  return f"<transcript>\n{rendered}\n</transcript>"
@@ -179,6 +199,56 @@ class HookContext:
179
199
  except (subprocess.CalledProcessError, FileNotFoundError):
180
200
  return None
181
201
 
202
+ def diff(
203
+ self,
204
+ source: str = "uncommitted",
205
+ *,
206
+ commit: str | None = None,
207
+ scope: str | None = None,
208
+ budget: int = 4000,
209
+ ) -> str | None:
210
+ """A compact diff via ``ccx diff`` when available, else plain ``git``.
211
+
212
+ Prefers cc-context's token-budgeted ``ccx diff`` and falls back to ``git`` when ``ccx`` is
213
+ absent, fails, or returns a hunkless symbol-outline (no ``@@`` / ``diff --git`` lines, as in
214
+ jj-colocated repos), so a hook gets a real diff in any repo. The git fallback is bounded to
215
+ roughly ``budget`` tokens with a trailing marker when truncated, so a large diff can't blow
216
+ the caller's context.
217
+
218
+ Args:
219
+ source: ``"uncommitted"`` (the default), ``"staged"``, or any git ref. Ignored when
220
+ ``commit`` is set.
221
+ commit: When set, the diff *introduced by* this commit (root-commit-safe), overriding
222
+ ``source``; its git fallback is ``git show --stat -p``, so it carries the commit
223
+ header + diffstat ahead of the patch.
224
+ scope: Restrict the diff to this path.
225
+ budget: Token budget for both the ``ccx`` output and the bounded git fallback.
226
+ """
227
+ target = f"{commit}~1..{commit}" if commit is not None else source
228
+ if (out := self.ccx_diff(target, scope=scope, budget=budget)) is not None:
229
+ return out
230
+ git_scope = ("--", scope) if scope else ()
231
+ if commit is not None:
232
+ return self.bounded_git(budget, "show", "--stat", "-p", commit, *git_scope)
233
+ match source:
234
+ case "uncommitted":
235
+ args = ["diff"]
236
+ case "staged":
237
+ args = ["diff", "--staged"]
238
+ case ref:
239
+ args = ["diff", ref]
240
+ return self.bounded_git(budget, *args, *git_scope)
241
+
242
+ def ccx_diff(self, target: str, *, scope: str | None, budget: int) -> str | None:
243
+ cmd = ["ccx", "diff", target, "--budget", str(budget), *(("--scope", scope) if scope else ())]
244
+ out = self.call_cli(cmd, throw=False)
245
+ return out if out is not None and ("@@" in out or "diff --git" in out) else None
246
+
247
+ def bounded_git(self, budget: int, *args: str) -> str | None:
248
+ if (out := self.git(*args)) is None or len(out) <= (limit := budget * 4):
249
+ return out
250
+ return out[:limit].rstrip() + f"\n... [diff truncated to ~{budget} tokens] ..."
251
+
182
252
  @cached_property
183
253
  def changed_paths(self) -> frozenset[Path] | None:
184
254
  if (out := self.git("diff", "--name-only", "HEAD", "--no-renames")) is None or (root := self.repo_root) is None:
@@ -202,19 +272,24 @@ class HookContext:
202
272
  specialty: TSpecialty = "general",
203
273
  model: TModel = "small",
204
274
  timeout: int = 180,
205
- transcript: bool = False,
275
+ transcript: bool | int | Literal["recent", "full"] = False,
276
+ diff: bool | str = False,
206
277
  agent: bool = False,
207
278
  response_model: type[BaseModel] | None = None,
208
279
  **kwargs: Any,
209
280
  ) -> str | BaseModel:
281
+ diff_text = self.diff("uncommitted" if diff is True else diff) if diff else None
210
282
  if isinstance(template, Prompt):
211
- prompt = str(template)
283
+ prompt = str(template.context("diff", diff_text))
212
284
  if transcript:
213
- prompt = f"{self.transcript_block()}\n\n<task>\n{prompt}\n</task>"
285
+ prompt = f"{self.transcript_block(window=transcript_window(transcript))}\n\n<task>\n{prompt}\n</task>"
214
286
  else:
287
+ block = self.transcript_block(window=transcript_window(transcript)) if transcript else ""
215
288
  if transcript:
216
289
  template = f"{{transcript}}\n\n<task>\n{template}\n</task>"
217
- prompt = template.format(*args, **kwargs, transcript=self.transcript_block())
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}"
218
293
  backend = LlmBackends.for_specialty(specialty)
219
294
  cwd = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR")
220
295
  if response_model is not None:
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from captain_hook import Allow, BaseHookEvent, Block, CustomCondition, Event, Input, Waiting, gate
3
+ from captain_hook import Allow, BaseHookEvent, Block, CustomCondition, Event, Input, llm_gate
4
4
 
5
5
  # Prose and config file extensions that shouldn't, on their own, demand a code-review pass.
6
6
  # Tailor this (and the excluded dirs below) to scope what counts as "source" for your repo.
@@ -29,12 +29,16 @@ class EditedSource(CustomCondition):
29
29
  )
30
30
 
31
31
 
32
- gate(
33
- "You changed source files but haven't done a review pass. Before stopping, review your "
34
- "changes for correctness and against STYLEGUIDE.md, and fix any issues in the code you "
35
- "wrote. See: STYLEGUIDE.md.",
32
+ llm_gate(
33
+ "You are reviewing a code change before the agent stops. The compact diff of the "
34
+ "uncommitted changes is in <diff>. Review it for (1) correctness bugs and (2) clear "
35
+ "violations of the project's STYLEGUIDE.md (read STYLEGUIDE.md from the working dir). "
36
+ "Set block=true ONLY for a concrete, real issue in the changed code, with the specific "
37
+ "problem and the fix in `reasoning`. Otherwise block=false. Do not block on style nits "
38
+ "absent from STYLEGUIDE, on unchanged pre-existing code, or on speculative concerns.",
39
+ message=lambda r: f"Review flagged an issue to fix before stopping: {r.reasoning}",
40
+ diff=True,
36
41
  only_if=[EditedSource()],
37
- skip_if=[Waiting()],
38
42
  events=Event.Stop,
39
43
  tests={
40
44
  Input(
@@ -65,7 +65,8 @@ def llm_evaluate[M: BaseModel](
65
65
  specialty: TSpecialty = "review",
66
66
  model: TModel = "small",
67
67
  agent: bool = False,
68
- transcript: bool = False,
68
+ transcript: bool | int | Literal["recent", "full"] = False,
69
+ diff: bool | str = False,
69
70
  ) -> M | None:
70
71
  if fired_this_turn(evt):
71
72
  return None
@@ -99,6 +100,7 @@ def llm_evaluate[M: BaseModel](
99
100
  model=model,
100
101
  agent=agent,
101
102
  transcript=transcript,
103
+ diff=diff,
102
104
  response_model=response_model,
103
105
  )
104
106
  except Exception:
@@ -137,7 +139,8 @@ def llm_primitive[M: BaseModel](
137
139
  specialty: TSpecialty = "review",
138
140
  model: TModel = "small",
139
141
  agent: bool = False,
140
- transcript: bool = False,
142
+ transcript: bool | int | Literal["recent", "full"] = False,
143
+ diff: bool | str = False,
141
144
  ) -> None:
142
145
  sig = resolve_signals(signals)
143
146
 
@@ -154,6 +157,7 @@ def llm_primitive[M: BaseModel](
154
157
  model=model,
155
158
  agent=agent,
156
159
  transcript=transcript,
160
+ diff=diff,
157
161
  )
158
162
  ):
159
163
  return None
@@ -197,13 +201,15 @@ def llm_gate(
197
201
  specialty: TSpecialty = "review",
198
202
  model: TModel = "small",
199
203
  agent: bool = True,
200
- transcript: bool = True,
204
+ transcript: bool | int | Literal["recent", "full"] = True,
205
+ diff: bool | str = False,
201
206
  ) -> None:
202
207
  """Register an LLM-powered blocking gate.
203
208
 
204
209
  Defaults are tuned for the common case: ``agent=True`` and ``transcript=True``
205
- so the gate has tool access and full transcript context. Pass
206
- ``agent=False, transcript=False`` for cheap, stateless yes/no checks.
210
+ so the gate has tool access and a recent transcript window (the path lets the agent
211
+ read full history). Pass ``diff=True`` to attach a compact working-tree diff as a
212
+ ``<diff>`` block, or ``agent=False, transcript=False`` for cheap, stateless yes/no checks.
207
213
 
208
214
  Example:
209
215
  >>> llm_gate("Is the agent making excuses?",
@@ -231,6 +237,7 @@ def llm_gate(
231
237
  model=model,
232
238
  agent=agent,
233
239
  transcript=transcript,
240
+ diff=diff,
234
241
  )
235
242
 
236
243
 
@@ -252,13 +259,15 @@ def llm_nudge(
252
259
  specialty: TSpecialty = "review",
253
260
  model: TModel = "small",
254
261
  agent: bool = True,
255
- transcript: bool = True,
262
+ transcript: bool | int | Literal["recent", "full"] = True,
263
+ diff: bool | str = False,
256
264
  ) -> None:
257
265
  """Register an LLM-powered advisory nudge.
258
266
 
259
267
  Defaults are tuned for the common case: ``agent=True`` and ``transcript=True``
260
- so the nudge has tool access and full transcript context. Pass
261
- ``agent=False, transcript=False`` for cheap, stateless yes/no checks.
268
+ so the nudge has tool access and a recent transcript window (the path lets the agent
269
+ read full history). Pass ``diff=True`` to attach a compact working-tree diff as a
270
+ ``<diff>`` block, or ``agent=False, transcript=False`` for cheap, stateless yes/no checks.
262
271
 
263
272
  Example:
264
273
  >>> llm_nudge("Is the agent speculating instead of observing?",
@@ -287,6 +296,7 @@ def llm_nudge(
287
296
  model=model,
288
297
  agent=agent,
289
298
  transcript=transcript,
299
+ diff=diff,
290
300
  )
291
301
 
292
302
 
@@ -343,6 +353,7 @@ def prompt_check(
343
353
  suffix: str = "",
344
354
  timeout: int = 45,
345
355
  include_reasoning: bool = True,
356
+ diff: bool | str = False,
346
357
  response_model: type[PromptCheckVerdict] = PromptCheckVerdict,
347
358
  ) -> HookResult | None:
348
359
  """Run an LLM check with a formatted prompt and return block/warn/None."""
@@ -356,6 +367,7 @@ def prompt_check(
356
367
  verdict = evt.ctx.call_llm(
357
368
  built,
358
369
  timeout=timeout,
370
+ diff=diff,
359
371
  response_model=response_model,
360
372
  )
361
373
  except Exception as exc:
@@ -5,7 +5,7 @@ import re
5
5
  import shutil
6
6
  import tempfile
7
7
  import time
8
- from collections.abc import Sequence
8
+ from collections.abc import Iterable, Sequence
9
9
  from pathlib import Path
10
10
  from typing import ClassVar, Generic, TypeVar, overload
11
11
 
@@ -119,6 +119,30 @@ class SessionStore:
119
119
  """
120
120
  return self[model].get(model())
121
121
 
122
+ def once(self, key: str, *, scope: str | None = None) -> bool:
123
+ """Return ``True`` the first time ``(scope, key)`` is seen this session, ``False`` thereafter.
124
+
125
+ Keyed, scoped dedup for hook authors — the single-key case of :meth:`unseen`.
126
+ """
127
+ return bool(self.unseen([key], scope=scope))
128
+
129
+ def unseen(self, keys: Iterable[str], *, scope: str | None = None) -> list[str]:
130
+ """Return the first-sight ``keys`` under ``scope``, recording the whole fresh subset in one write.
131
+
132
+ De-duplicates within the batch (order-preserving) and marks every returned key before any
133
+ downstream filtering, so a batch is never partially recorded; no write when nothing is fresh.
134
+ ``scope`` namespaces independent call sites on the shared session store.
135
+ """
136
+ from captain_hook.state import SeenKeys
137
+
138
+ blob = self.load(SeenKeys)
139
+ seen = blob.seen.setdefault(scope or "", [])
140
+ if not (fresh := [key for key in dict.fromkeys(keys) if key not in seen]):
141
+ return []
142
+ seen.extend(fresh)
143
+ self[SeenKeys].set(blob)
144
+ return fresh
145
+
122
146
  @classmethod
123
147
  def track(cls, model: type[BaseModel]) -> None:
124
148
  """Register ``model`` so it appears in ``tracked_models()`` and ``tracked_paths()``."""
@@ -227,5 +227,12 @@ def workflow_state(name: str) -> Callable[[type[T]], type[T]]:
227
227
  return wrap
228
228
 
229
229
 
230
+ class SeenKeys(BaseModel):
231
+ """Session-scoped record of keys observed by ``SessionStore.once``/``unseen``, namespaced by scope."""
232
+
233
+ seen: dict[str, list[str]] = Field(default_factory=dict)
234
+
235
+
230
236
  SessionStore.track(HookState)
231
237
  SessionStore.track(PrimitiveState)
238
+ SessionStore.track(SeenKeys)
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "capt-hook"
3
3
  # Inert sentinel — the real version is stamped from the release tag (uv version --frozen).
4
- version = "4.0.0"
4
+ version = "4.2.0"
5
5
  description = "Declarative hook framework for Claude Code"
6
6
  readme = "README.md"
7
7
  license = "PolyForm-Noncommercial-1.0.0"
File without changes
File without changes
File without changes
File without changes
File without changes