capt-hook 4.1.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.1.0 → capt_hook-4.2.0}/PKG-INFO +1 -1
  2. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/context.py +44 -16
  3. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/session.py +25 -1
  4. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/state.py +7 -0
  5. {capt_hook-4.1.0 → capt_hook-4.2.0}/pyproject.toml +1 -1
  6. {capt_hook-4.1.0 → capt_hook-4.2.0}/LICENSE +0 -0
  7. {capt_hook-4.1.0 → capt_hook-4.2.0}/README.md +0 -0
  8. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  9. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/__init__.py +0 -0
  10. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/__main__.py +0 -0
  11. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/app.py +0 -0
  12. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/ast_grep.py +0 -0
  13. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/classifiers/__init__.py +0 -0
  14. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/classifiers/conductor.py +0 -0
  15. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/classifiers/droid.py +0 -0
  16. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/classifiers/native.py +0 -0
  17. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/cli.py +0 -0
  18. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/command.py +0 -0
  19. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/conditions.py +0 -0
  20. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/decisions.py +0 -0
  21. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/dispatch.py +0 -0
  22. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/events.py +0 -0
  23. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/file.py +0 -0
  24. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/llm/__init__.py +0 -0
  25. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/loader.py +0 -0
  26. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/log.py +0 -0
  27. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/__init__.py +0 -0
  28. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/general/__init__.py +0 -0
  29. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  30. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/general/commands.py +0 -0
  31. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/general/docs.py +0 -0
  32. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/general/plans.py +0 -0
  33. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/general/prompts.py +0 -0
  34. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/general/review.py +0 -0
  35. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/general/tasks.py +0 -0
  36. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/go/__init__.py +0 -0
  37. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  38. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/go/testing.py +0 -0
  39. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/go/toolchain.py +0 -0
  40. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/manager.py +0 -0
  41. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/python/__init__.py +0 -0
  42. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  43. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/python/style.py +0 -0
  44. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/python/testing.py +0 -0
  45. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/python/toolchain.py +0 -0
  46. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/steering/__init__.py +0 -0
  47. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  48. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/packs/steering/steering.py +0 -0
  49. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/primitives/__init__.py +0 -0
  50. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/primitives/commands.py +0 -0
  51. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/primitives/lint.py +0 -0
  52. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/primitives/llm.py +0 -0
  53. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/primitives/nudge.py +0 -0
  54. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/primitives/rewrite.py +0 -0
  55. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/primitives/workflow.py +0 -0
  56. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/prompt.py +0 -0
  57. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/py.typed +0 -0
  58. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/__init__.py +0 -0
  59. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/cli.py +0 -0
  60. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/dashboard.py +0 -0
  61. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/fix.py +0 -0
  62. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/formats.py +0 -0
  63. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/judge.py +0 -0
  64. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/pipeline.py +0 -0
  65. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/repo.py +0 -0
  66. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/scan.py +0 -0
  67. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/settings.py +0 -0
  68. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/store.py +0 -0
  69. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/review/sync.py +0 -0
  70. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/settings.py +0 -0
  71. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/signals/__init__.py +0 -0
  72. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/signals/nlp.py +0 -0
  73. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  74. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  75. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  76. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  77. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  78. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  79. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  80. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  81. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  82. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  83. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  84. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  85. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  86. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/style/__init__.py +0 -0
  87. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/style/ast_grep.py +0 -0
  88. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/style/matchers.py +0 -0
  89. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/style/scope.py +0 -0
  90. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/style/types.py +0 -0
  91. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/tasks.py +0 -0
  92. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  93. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/testing/__init__.py +0 -0
  94. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/testing/helpers.py +0 -0
  95. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/testing/session_cache.py +0 -0
  96. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/testing/types.py +0 -0
  97. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/tests/__init__.py +0 -0
  98. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/tests/helpers.py +0 -0
  99. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/tests/review_helpers.py +0 -0
  100. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/types.py +0 -0
  101. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/util/__init__.py +0 -0
  102. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/util/http.py +0 -0
  103. {capt_hook-4.1.0 → capt_hook-4.2.0}/captain_hook/util/model_cache.py +0 -0
  104. {capt_hook-4.1.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.1.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
@@ -35,10 +35,14 @@ if TYPE_CHECKING:
35
35
  RECENT_WINDOW = 15
36
36
 
37
37
 
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
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
42
46
 
43
47
 
44
48
  class LenientToolUseBlock(ToolUseBlock):
@@ -195,23 +199,37 @@ class HookContext:
195
199
  except (subprocess.CalledProcessError, FileNotFoundError):
196
200
  return None
197
201
 
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``.
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``.
200
211
 
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.
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.
203
217
 
204
218
  Args:
205
- source: ``"uncommitted"`` (working tree, the default), ``"staged"``, or any git ref.
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.
206
224
  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.
225
+ budget: Token budget for both the ``ccx`` output and the bounded git fallback.
211
226
  """
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:
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:
214
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)
215
233
  match source:
216
234
  case "uncommitted":
217
235
  args = ["diff"]
@@ -219,7 +237,17 @@ class HookContext:
219
237
  args = ["diff", "--staged"]
220
238
  case ref:
221
239
  args = ["diff", ref]
222
- return self.git(*args, *(("--", scope) if scope else ()))
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] ..."
223
251
 
224
252
  @cached_property
225
253
  def changed_paths(self) -> frozenset[Path] | None:
@@ -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.1.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