capt-hook 3.20.0__tar.gz → 4.0.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-3.20.0 → capt_hook-4.0.0}/PKG-INFO +3 -3
  2. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/context.py +9 -10
  3. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/fix.py +3 -2
  4. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/formats.py +41 -30
  5. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/scan.py +14 -19
  6. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/tests/helpers.py +5 -1
  7. capt_hook-4.0.0/captain_hook/tests/review_helpers.py +141 -0
  8. {capt_hook-3.20.0 → capt_hook-4.0.0}/pyproject.toml +3 -3
  9. {capt_hook-3.20.0 → capt_hook-4.0.0}/LICENSE +0 -0
  10. {capt_hook-3.20.0 → capt_hook-4.0.0}/README.md +0 -0
  11. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  12. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/__init__.py +0 -0
  13. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/__main__.py +0 -0
  14. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/app.py +0 -0
  15. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/ast_grep.py +0 -0
  16. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/classifiers/__init__.py +0 -0
  17. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/classifiers/conductor.py +0 -0
  18. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/classifiers/droid.py +0 -0
  19. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/classifiers/native.py +0 -0
  20. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/cli.py +0 -0
  21. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/command.py +0 -0
  22. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/conditions.py +0 -0
  23. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/decisions.py +0 -0
  24. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/dispatch.py +0 -0
  25. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/events.py +0 -0
  26. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/file.py +0 -0
  27. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/llm/__init__.py +0 -0
  28. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/loader.py +0 -0
  29. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/log.py +0 -0
  30. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/__init__.py +0 -0
  31. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/general/__init__.py +0 -0
  32. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  33. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/general/commands.py +0 -0
  34. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/general/docs.py +0 -0
  35. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/general/plans.py +0 -0
  36. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/general/prompts.py +0 -0
  37. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/general/review.py +0 -0
  38. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/general/tasks.py +0 -0
  39. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/go/__init__.py +0 -0
  40. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  41. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/go/testing.py +0 -0
  42. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/go/toolchain.py +0 -0
  43. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/manager.py +0 -0
  44. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/python/__init__.py +0 -0
  45. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  46. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/python/style.py +0 -0
  47. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/python/testing.py +0 -0
  48. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/python/toolchain.py +0 -0
  49. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/steering/__init__.py +0 -0
  50. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  51. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/packs/steering/steering.py +0 -0
  52. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/primitives/__init__.py +0 -0
  53. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/primitives/commands.py +0 -0
  54. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/primitives/lint.py +0 -0
  55. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/primitives/llm.py +0 -0
  56. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/primitives/nudge.py +0 -0
  57. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/primitives/rewrite.py +0 -0
  58. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/primitives/workflow.py +0 -0
  59. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/prompt.py +0 -0
  60. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/py.typed +0 -0
  61. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/__init__.py +0 -0
  62. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/cli.py +0 -0
  63. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/dashboard.py +0 -0
  64. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/judge.py +0 -0
  65. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/pipeline.py +0 -0
  66. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/repo.py +0 -0
  67. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/settings.py +0 -0
  68. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/store.py +0 -0
  69. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/review/sync.py +0 -0
  70. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/session.py +0 -0
  71. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/settings.py +0 -0
  72. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/signals/__init__.py +0 -0
  73. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/signals/nlp.py +0 -0
  74. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  75. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  76. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  77. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  78. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  79. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  80. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  81. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  82. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  83. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  84. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  85. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  86. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  87. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/state.py +0 -0
  88. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/style/__init__.py +0 -0
  89. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/style/ast_grep.py +0 -0
  90. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/style/matchers.py +0 -0
  91. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/style/scope.py +0 -0
  92. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/style/types.py +0 -0
  93. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/tasks.py +0 -0
  94. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  95. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/testing/__init__.py +0 -0
  96. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/testing/helpers.py +0 -0
  97. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/testing/session_cache.py +0 -0
  98. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/testing/types.py +0 -0
  99. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/tests/__init__.py +0 -0
  100. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/types.py +0 -0
  101. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/util/__init__.py +0 -0
  102. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/util/http.py +0 -0
  103. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/util/model_cache.py +0 -0
  104. {capt_hook-3.20.0 → capt_hook-4.0.0}/captain_hook/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 3.20.0
3
+ Version: 4.0.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
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
16
16
  Classifier: Topic :: Software Development :: Quality Assurance
17
17
  Classifier: Topic :: Software Development :: Testing
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: cc-transcript>=5,<6
19
+ Requires-Dist: cc-transcript>=7,<8
20
20
  Requires-Dist: pydantic>=2.0
21
21
  Requires-Dist: pydantic-settings>=2.0
22
22
  Requires-Dist: tree-sitter>=0.24
@@ -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.4.0
34
+ Requires-Dist: spawnllm>=0.5.0
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'
@@ -15,7 +15,8 @@ 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, run_cli
18
+ from spawnllm import call_sync, extract_sync
19
+ from spawnllm.proc import run_cli
19
20
 
20
21
  from captain_hook.classifiers import detect
21
22
  from captain_hook.llm import LlmBackends, TModel, TSpecialty
@@ -214,12 +215,10 @@ class HookContext:
214
215
  if transcript:
215
216
  template = f"{{transcript}}\n\n<task>\n{template}\n</task>"
216
217
  prompt = template.format(*args, **kwargs, transcript=self.transcript_block())
217
- return call_sync(
218
- prompt,
219
- backend=LlmBackends.for_specialty(specialty),
220
- model=model,
221
- agent=agent,
222
- response_model=response_model,
223
- cwd=os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR"),
224
- timeout=timeout,
225
- )
218
+ backend = LlmBackends.for_specialty(specialty)
219
+ cwd = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR")
220
+ if response_model is not None:
221
+ return extract_sync(
222
+ prompt, response_model, backend=backend, model=model, agent=agent, cwd=cwd, timeout=timeout
223
+ )
224
+ return call_sync(prompt, backend=backend, model=model, agent=agent, cwd=cwd, timeout=timeout)
@@ -38,8 +38,9 @@ from typing import TYPE_CHECKING, Literal
38
38
 
39
39
  from cc_transcript.filterspec import tool_uses
40
40
  from cc_transcript.mining.confidence import MEDIUM, VERY_HIGH, CandidateSignal
41
- from cc_transcript.mining.signals import CONFIDENCE_STEP, MiningSignal, adjust
41
+ from cc_transcript.mining.signals import MiningSignal
42
42
  from cc_transcript.mining.sourcekind import SourceKind
43
+ from cc_transcript.mining.spec import CONFIDENCE_STEP, bump
43
44
  from cc_transcript.models import AssistantEvent, OtherEvent, ToolResultBlock, UserEvent
44
45
 
45
46
  if TYPE_CHECKING:
@@ -211,7 +212,7 @@ def complaint_signal(marker: Marker, turns_back: int) -> CandidateSignal:
211
212
  VERY_HIGH if marker.strength == "strong" else MEDIUM,
212
213
  (f"{marker.strength}_marker", marker.misfire_class),
213
214
  )
214
- return adjust(base, CONFIDENCE_STEP, "tight_proximity") if turns_back <= TIGHT_PROXIMITY_TURNS else base
215
+ return bump(base, CONFIDENCE_STEP, "tight_proximity") if turns_back <= TIGHT_PROXIMITY_TURNS else base
215
216
 
216
217
 
217
218
  def iter_hook_complaint_signals(events: Sequence[TranscriptEvent], *, decisions: DecisionLog) -> Iterator[MiningSignal]:
@@ -2,32 +2,51 @@
2
2
 
3
3
  The generic :class:`ReviewComment`/:class:`ReviewFormat` types and the
4
4
  format-dispatch live in :mod:`cc_transcript.mining`; this module supplies
5
- the reviewer's policy — the three review formats it recognizes — and injects them
6
- into the domain's review-comment detector. The shapes are shared transcript
7
- conventions (inline ``In file:Lx:`` cites, conductor finding blocks, conductor
8
- workstream headers) first proven in cc-pushback's formats module, not
9
- cc-pushback specifics.
5
+ the reviewer's policy — the three review formats it recognizes — and assembles them
6
+ into the :class:`~cc_transcript.mining.ReviewSpec` the review-comment detector runs.
7
+ The shapes are shared transcript conventions (inline ``In file:Lx:`` cites,
8
+ conductor finding blocks, conductor workstream headers) first proven in
9
+ cc-pushback's formats module, not cc-pushback specifics.
10
+
11
+ ``conductor-finding`` is a Rust-portable :class:`~cc_transcript.mining.RegexReviewFormat`
12
+ (a declarative claim+suggestion group-join); ``superset-inline`` (lookahead) and
13
+ ``conductor-workstream`` (multi-pass header + body scan) stay
14
+ :class:`~cc_transcript.mining.CallableReviewFormat` escape hatches, which keep the
15
+ review-comment detector in Python for those formats.
10
16
  """
11
17
 
12
18
  from __future__ import annotations
13
19
 
14
20
  import re
15
21
 
16
- from cc_transcript.mining.formats import ReviewComment, ReviewFormat
22
+ from cc_transcript.mining.formats import ReviewComment
23
+ from cc_transcript.mining.spec import CallableReviewFormat, RegexReviewFormat, ReviewSpec
17
24
 
18
25
  SUPERSET_INLINE_RE = re.compile(r"^In ((?=\S*[./]|\S+?:L)\S+?)(?::L(\d+)(?:-(\d+))?)?: (.+)$", re.MULTILINE)
19
- CONDUCTOR_FINDING_RE = re.compile(
20
- r"^- file: (?P<file>\S+?):(?P<line>\d+)\s*$"
21
- r"(?:\n- theme: .+$)?"
22
- r"(?:\n- claim: (?P<claim>.+)$)?"
23
- r"(?:\n- suggestion: (?P<suggestion>.+)$)?",
24
- re.MULTILINE,
25
- )
26
26
  CONDUCTOR_WORKSTREAM_HEADER_RE = re.compile(
27
27
  r"^### (?P<id>[A-Z][\w-]*\d*)\s*\[(?P<kind>[A-Z]+)\]\s*—\s*(?P<title>.+)$",
28
28
  re.MULTILINE,
29
29
  )
30
30
  WORKSTREAM_BODY_RE = re.compile(r"^(?:FIX|Tests): .+$", re.MULTILINE)
31
+ CONDUCTOR_FINDING_FMT = RegexReviewFormat(
32
+ name="conductor-finding",
33
+ groups=(
34
+ (
35
+ "conductor-finding",
36
+ r"^- file: (\S+?):(\d+)\s*$"
37
+ r"(?:\n- theme: .+$)?"
38
+ r"(?:\n- claim: (.+)$)?"
39
+ r"(?:\n- suggestion: (.+)$)?",
40
+ ),
41
+ ),
42
+ file_group=1,
43
+ line_start_group=2,
44
+ line_end_group=None,
45
+ comment_groups=(3, 4),
46
+ join=" ",
47
+ multiline=True,
48
+ ignore_case=False,
49
+ )
31
50
 
32
51
 
33
52
  def extract_superset_inline(text: str) -> tuple[ReviewComment, ...]:
@@ -42,18 +61,6 @@ def extract_superset_inline(text: str) -> tuple[ReviewComment, ...]:
42
61
  )
43
62
 
44
63
 
45
- def extract_conductor_finding(text: str) -> tuple[ReviewComment, ...]:
46
- return tuple(
47
- ReviewComment(
48
- file=match.group("file"),
49
- line_start=int(match.group("line")),
50
- line_end=None,
51
- comment=" ".join(part.strip() for part in (match.group("claim"), match.group("suggestion")) if part),
52
- )
53
- for match in CONDUCTOR_FINDING_RE.finditer(text)
54
- )
55
-
56
-
57
64
  def extract_conductor_workstream(text: str) -> tuple[ReviewComment, ...]:
58
65
  headers = list(CONDUCTOR_WORKSTREAM_HEADER_RE.finditer(text))
59
66
  return tuple(
@@ -74,9 +81,13 @@ def extract_conductor_workstream(text: str) -> tuple[ReviewComment, ...]:
74
81
  )
75
82
 
76
83
 
77
- def formats() -> tuple[ReviewFormat, ...]:
78
- return (
79
- ReviewFormat("superset-inline", SUPERSET_INLINE_RE, extract_superset_inline),
80
- ReviewFormat("conductor-finding", CONDUCTOR_FINDING_RE, extract_conductor_finding),
81
- ReviewFormat("conductor-workstream", CONDUCTOR_WORKSTREAM_HEADER_RE, extract_conductor_workstream),
84
+ def review_spec() -> ReviewSpec:
85
+ return ReviewSpec(
86
+ regex_formats=(CONDUCTOR_FINDING_FMT,),
87
+ callable_formats=(
88
+ CallableReviewFormat("superset-inline", SUPERSET_INLINE_RE, extract_superset_inline),
89
+ CallableReviewFormat("conductor-workstream", CONDUCTOR_WORKSTREAM_HEADER_RE, extract_conductor_workstream),
90
+ ),
91
+ structured_formats=(),
92
+ surfaces=frozenset({"typed"}),
82
93
  )
@@ -58,20 +58,14 @@ from cc_transcript.filterspec import (
58
58
  from cc_transcript.ids import EventRef
59
59
  from cc_transcript.mining.candidates import FeedbackCandidate, dedup_key
60
60
  from cc_transcript.mining.filterspec import at_least, build_candidate_filter, keep_candidate
61
- from cc_transcript.mining.signals import (
62
- iter_interrupt_marker_signals,
63
- iter_plan_reentry_signals,
64
- iter_plan_rejection_signals,
65
- iter_review_comment_signals,
66
- iter_tool_denial_signals,
67
- iter_user_message_signals,
68
- )
61
+ from cc_transcript.mining.signals import mine
62
+ from cc_transcript.mining.spec import MiningSpec
69
63
  from cc_transcript.models import UserEvent
70
64
  from cc_transcript.parser import TranscriptParser
71
65
 
72
66
  from captain_hook.decisions import decisions_db_path, open_decision_log
73
67
  from captain_hook.review.fix import HOOK_COMPLAINT, iter_hook_complaint_signals
74
- from captain_hook.review.formats import formats
68
+ from captain_hook.review.formats import review_spec
75
69
  from captain_hook.review.repo import resolve_repo_key
76
70
  from captain_hook.review.store import CandidateKind
77
71
 
@@ -92,6 +86,14 @@ REVIEWER_MARKER = "capt-hook-session-reviewer"
92
86
 
93
87
  SPEC_DETECTORS = frozenset({"transcript_message", "plan_reentry", "review_comment"})
94
88
 
89
+ REVIEWER_MINING_SPEC = MiningSpec(review=review_spec())
90
+ """The reviewer's mining policy: all six core detectors with the reviewer's review formats.
91
+
92
+ Scoring, provenance, reentry lookback, and edit tools take the :class:`MiningSpec`
93
+ defaults; only the review-comment policy (the three reviewer formats, ``typed``
94
+ surfaces, no structured formats) is customized.
95
+ """
96
+
95
97
  STRICT_USER: FilterSpec = build_spec(
96
98
  keep_only("user"),
97
99
  drop_sidechain(),
@@ -191,23 +193,16 @@ def to_candidate(activity: SessionActivity, sig: MiningSignal) -> FeedbackCandid
191
193
 
192
194
 
193
195
  def detect(events: Sequence[TranscriptEvent]) -> Iterator[MiningSignal]:
194
- """Composes all six neutral mining iterators over one transcript's events.
196
+ """Mines all six neutral detectors over one transcript's events.
195
197
 
196
198
  Args:
197
199
  events: The transcript's full ordered event stream.
198
200
 
199
201
  Returns:
200
202
  Every mining signal the detectors recognize, ungated; review comments
201
- run under the reviewer's :func:`~captain_hook.review.formats.formats`.
203
+ run under the reviewer's :data:`REVIEWER_MINING_SPEC`.
202
204
  """
203
- return chain(
204
- iter_user_message_signals(events),
205
- iter_plan_rejection_signals(events),
206
- iter_plan_reentry_signals(events),
207
- iter_tool_denial_signals(events),
208
- iter_interrupt_marker_signals(events),
209
- iter_review_comment_signals(events, formats(), surfaces=frozenset({"typed"}), structured_formats=()),
210
- )
205
+ return mine(events, REVIEWER_MINING_SPEC)
211
206
 
212
207
 
213
208
  def candidates_from(
@@ -122,9 +122,13 @@ def make_pre_tool_event(
122
122
  tool_name: str = "Bash",
123
123
  tool_input: dict[str, Any] | None = None,
124
124
  ctx: Any = None,
125
+ *,
126
+ permission_mode: str | None = None,
125
127
  ) -> PreToolUseEvent:
126
128
  return PreToolUseEvent(
127
- _raw={"tool_name": tool_name} | ({"tool_input": tool_input} if tool_input is not None else {}),
129
+ _raw={"tool_name": tool_name}
130
+ | ({"tool_input": tool_input} if tool_input is not None else {})
131
+ | ({"permission_mode": permission_mode} if permission_mode is not None else {}),
128
132
  ctx=ctx or make_ctx(),
129
133
  )
130
134
 
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import json
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import pytest
10
+ from cc_transcript import parse_events_from_bytes
11
+
12
+ from captain_hook.review.judge import ReviewVerdict
13
+ from captain_hook.review.repo import RepoKey
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ from cc_transcript.models import TranscriptEvent
19
+
20
+ from captain_hook.review.judge import Category
21
+
22
+ REPO = RepoKey("github.com/yasyf/captain-hook")
23
+ BASE_TS = "2026-06-01T12:00:00+00:00"
24
+ CORRECTION = "no, never use a bare except here, always catch the specific parser error"
25
+ PROMPT_VERSION = 1
26
+
27
+ REVIEW_UUIDS = itertools.count()
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Verdict:
32
+ accepted: bool = True
33
+ confidence: float = 0.9
34
+ category: str = "durable_correction"
35
+ summary: str = "user corrected approach"
36
+ rationale: str = "explicit correction"
37
+
38
+
39
+ def next_uuid() -> str:
40
+ return f"uuid-{next(REVIEW_UUIDS)}"
41
+
42
+
43
+ def envelope(entry_type: str, **overrides: Any) -> dict[str, Any]:
44
+ return {
45
+ "type": entry_type,
46
+ "uuid": overrides.pop("uuid", next_uuid()),
47
+ "parentUuid": None,
48
+ "sessionId": overrides.pop("sessionId", "sess-1"),
49
+ "timestamp": overrides.pop("timestamp", BASE_TS),
50
+ "cwd": overrides.pop("cwd", "/repo"),
51
+ "gitBranch": "main",
52
+ "version": "1.2.3",
53
+ "isSidechain": False,
54
+ "isMeta": False,
55
+ "entrypoint": "cli",
56
+ **overrides,
57
+ }
58
+
59
+
60
+ def user_text(text: str, **overrides: Any) -> dict[str, Any]:
61
+ return envelope("user", message={"role": "user", "content": text}, **overrides)
62
+
63
+
64
+ def assistant_text(text: str, **overrides: Any) -> dict[str, Any]:
65
+ return envelope(
66
+ "assistant",
67
+ message={"role": "assistant", "model": "claude", "content": [{"type": "text", "text": text}]},
68
+ **overrides,
69
+ )
70
+
71
+
72
+ def assistant_tool_use(tool_id: str, name: str, tool_input: dict[str, Any], **overrides: Any) -> dict[str, Any]:
73
+ return envelope(
74
+ "assistant",
75
+ message={
76
+ "role": "assistant",
77
+ "model": "claude",
78
+ "content": [{"type": "tool_use", "id": tool_id, "name": name, "input": tool_input}],
79
+ },
80
+ **overrides,
81
+ )
82
+
83
+
84
+ def tool_result(tool_id: str, content: str, *, is_error: bool = False, **overrides: Any) -> dict[str, Any]:
85
+ return envelope(
86
+ "user",
87
+ message={
88
+ "role": "user",
89
+ "content": [{"type": "tool_result", "tool_use_id": tool_id, "content": content, "is_error": is_error}],
90
+ },
91
+ **overrides,
92
+ )
93
+
94
+
95
+ def correction_entries(*, session: str = "sess-1", timestamp: str = BASE_TS, **overrides: Any) -> list[dict[str, Any]]:
96
+ return [
97
+ assistant_text("I'll wrap the parser in a bare except", sessionId=session, timestamp=timestamp, **overrides),
98
+ user_text(CORRECTION, sessionId=session, timestamp=timestamp, **overrides),
99
+ ]
100
+
101
+
102
+ def parse(entries: list[dict[str, Any]]) -> list[TranscriptEvent]:
103
+ return parse_events_from_bytes("".join(json.dumps(entry) + "\n" for entry in entries).encode())
104
+
105
+
106
+ def write_transcript(path: Path, entries: list[dict[str, Any]]) -> Path:
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ path.write_text("".join(json.dumps(entry) + "\n" for entry in entries))
109
+ return path
110
+
111
+
112
+ def llm_backend_available() -> bool:
113
+ from cc_transcript.judge.llm import default_backend
114
+ from spawnllm import BackendUnavailable
115
+
116
+ try:
117
+ default_backend()
118
+ except BackendUnavailable:
119
+ return False
120
+ return True
121
+
122
+
123
+ requires_llm_backend = pytest.mark.skipif(
124
+ not llm_backend_available(),
125
+ reason="no installed, authenticated LLM backend (spawnllm.select_backend raised BackendUnavailable)",
126
+ )
127
+
128
+
129
+ def install_judge(
130
+ monkeypatch: pytest.MonkeyPatch, *, category: Category = "durable_style_rule", fail_on: str | None = None
131
+ ) -> list[str]:
132
+ calls: list[str] = []
133
+
134
+ async def judge(prompt: str) -> ReviewVerdict:
135
+ calls.append(prompt)
136
+ if fail_on is not None and fail_on in prompt:
137
+ raise subprocess.CalledProcessError(1, ["claude"])
138
+ return ReviewVerdict(category=category, summary="states a durable rule", confidence=0.9, rationale="r")
139
+
140
+ monkeypatch.setattr("captain_hook.review.judge.structured_judge", lambda *_, **__: judge)
141
+ return calls
@@ -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 = "3.20.0"
4
+ version = "4.0.0"
5
5
  description = "Declarative hook framework for Claude Code"
6
6
  readme = "README.md"
7
7
  license = "PolyForm-Noncommercial-1.0.0"
@@ -21,7 +21,7 @@ classifiers = [
21
21
  ]
22
22
  requires-python = ">=3.13"
23
23
  dependencies = [
24
- "cc-transcript>=5,<6",
24
+ "cc-transcript>=7,<8",
25
25
  "pydantic>=2.0",
26
26
  "pydantic-settings>=2.0",
27
27
  "tree-sitter>=0.24",
@@ -36,7 +36,7 @@ dependencies = [
36
36
  "lazy-object-proxy>=1.12.0",
37
37
  "filelock>=3",
38
38
  "loguru>=0.7.3",
39
- "spawnllm>=0.4.0",
39
+ "spawnllm>=0.5.0",
40
40
  ]
41
41
 
42
42
  [project.optional-dependencies]
File without changes
File without changes