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.
- {capt_hook-4.0.0 → capt_hook-4.2.0}/PKG-INFO +1 -1
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/context.py +89 -14
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/review.py +10 -6
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/llm.py +20 -8
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/session.py +25 -1
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/state.py +7 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/pyproject.toml +1 -1
- {capt_hook-4.0.0 → capt_hook-4.2.0}/LICENSE +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/README.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/__main__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/app.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/cli.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/command.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/conditions.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/decisions.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/events.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/file.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/loader.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/log.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/packs/steering/steering.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/prompt.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/py.typed +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/store.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/settings.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/style/types.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/tasks.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/tests/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/tests/helpers.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/tests/review_helpers.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/types.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/util/http.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/utils.py +0 -0
|
@@ -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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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=
|
|
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,
|
|
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
|
-
|
|
33
|
-
"You
|
|
34
|
-
"changes
|
|
35
|
-
"
|
|
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
|
|
206
|
-
``
|
|
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
|
|
261
|
-
``
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md
RENAMED
|
File without changes
|
|
File without changes
|
{capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md
RENAMED
|
File without changes
|
{capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md
RENAMED
|
File without changes
|
{capt_hook-4.0.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/references/review-cli.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|