capt-hook 5.1.0__tar.gz → 6.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 (107) hide show
  1. {capt_hook-5.1.0 → capt_hook-6.0.0}/PKG-INFO +2 -2
  2. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/__init__.py +4 -1
  3. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/ast_grep.py +67 -16
  4. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/cli.py +25 -5
  5. capt_hook-6.0.0/captain_hook/contexts.py +151 -0
  6. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/durable.py +3 -1
  7. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/events.py +35 -0
  8. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/loader.py +20 -1
  9. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/capt-hook.toml +2 -1
  10. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/models.py +89 -1
  11. capt_hook-6.0.0/captain_hook/packs/general/tombstones.py +189 -0
  12. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/manager.py +9 -1
  13. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/steering/capt-hook.toml +2 -1
  14. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/llm.py +42 -1
  15. capt_hook-6.0.0/captain_hook/signals/nlp.py +203 -0
  16. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +16 -4
  17. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +1 -1
  18. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/state.py +9 -7
  19. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/testing/helpers.py +72 -35
  20. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/testing/types.py +2 -0
  21. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/types.py +3 -0
  22. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/model_cache.py +24 -0
  23. {capt_hook-5.1.0 → capt_hook-6.0.0}/pyproject.toml +2 -2
  24. capt_hook-5.1.0/captain_hook/signals/nlp.py +0 -107
  25. {capt_hook-5.1.0 → capt_hook-6.0.0}/LICENSE +0 -0
  26. {capt_hook-5.1.0 → capt_hook-6.0.0}/README.md +0 -0
  27. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  28. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/__main__.py +0 -0
  29. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/app.py +0 -0
  30. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/classifiers/__init__.py +0 -0
  31. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/classifiers/conductor.py +0 -0
  32. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/classifiers/droid.py +0 -0
  33. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/classifiers/native.py +0 -0
  34. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/command.py +0 -0
  35. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/conditions.py +0 -0
  36. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/context.py +0 -0
  37. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/decisions.py +0 -0
  38. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/dispatch.py +0 -0
  39. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/fields.py +0 -0
  40. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/file.py +0 -0
  41. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/log.py +0 -0
  42. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/__init__.py +0 -0
  43. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/__init__.py +0 -0
  44. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/commands.py +0 -0
  45. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/docs.py +0 -0
  46. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/plans.py +0 -0
  47. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/prompts.py +0 -0
  48. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/review.py +0 -0
  49. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/general/tasks.py +0 -0
  50. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/go/__init__.py +0 -0
  51. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  52. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/go/testing.py +0 -0
  53. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/go/toolchain.py +0 -0
  54. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/__init__.py +0 -0
  55. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  56. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/style.py +0 -0
  57. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/testing.py +0 -0
  58. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/python/toolchain.py +0 -0
  59. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/steering/__init__.py +0 -0
  60. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/packs/steering/steering.py +0 -0
  61. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/__init__.py +0 -0
  62. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/commands.py +0 -0
  63. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/lint.py +0 -0
  64. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/nudge.py +0 -0
  65. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/rewrite.py +0 -0
  66. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/primitives/workflow.py +0 -0
  67. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/prompt.py +0 -0
  68. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/py.typed +0 -0
  69. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/__init__.py +0 -0
  70. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/cli.py +0 -0
  71. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/dashboard.py +0 -0
  72. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/fix.py +0 -0
  73. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/formats.py +0 -0
  74. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/judge.py +0 -0
  75. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/pipeline.py +0 -0
  76. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/repo.py +0 -0
  77. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/scan.py +0 -0
  78. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/settings.py +0 -0
  79. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/store.py +0 -0
  80. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/review/sync.py +0 -0
  81. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/session.py +0 -0
  82. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/settings.py +0 -0
  83. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/signals/__init__.py +0 -0
  84. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  85. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  86. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  87. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  88. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  89. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  90. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  91. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  92. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  93. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  94. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  95. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/__init__.py +0 -0
  96. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/ast_grep.py +0 -0
  97. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/matchers.py +0 -0
  98. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/scope.py +0 -0
  99. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/style/types.py +0 -0
  100. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/tasks.py +0 -0
  101. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  102. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/testing/__init__.py +0 -0
  103. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/testing/session_cache.py +0 -0
  104. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/__init__.py +0 -0
  105. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/fs.py +0 -0
  106. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/http.py +0 -0
  107. {capt_hook-5.1.0 → capt_hook-6.0.0}/captain_hook/util/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 5.1.0
3
+ Version: 6.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>=7,<8
19
+ Requires-Dist: cc-transcript>=7.1,<8
20
20
  Requires-Dist: pydantic>=2.0
21
21
  Requires-Dist: pydantic-settings>=2.0
22
22
  Requires-Dist: tree-sitter>=0.24
@@ -21,8 +21,10 @@ from cc_transcript.tools import (
21
21
 
22
22
  from captain_hook import file, style, util
23
23
  from captain_hook.app import hook, on
24
+ from captain_hook.ast_grep import COMMENT_TYPES
24
25
  from captain_hook.command import CommandLine, ParsedCommand, Redirect
25
26
  from captain_hook.context import HookContext
27
+ from captain_hook.contexts import AfterEdit, BeforeEdit, Introduced, PromptContext, apply_contexts
26
28
  from captain_hook.durable import DurableSlot, DurableState, DurableStore
27
29
  from captain_hook.events import (
28
30
  BaseHookEvent,
@@ -32,6 +34,7 @@ from captain_hook.events import (
32
34
  PreCompactEvent,
33
35
  PreToolUseEvent,
34
36
  SessionEndEvent,
37
+ SessionStartEvent,
35
38
  StopEvent,
36
39
  SubagentStartEvent,
37
40
  SubagentStopEvent,
@@ -66,7 +69,7 @@ from captain_hook.primitives.workflow import Artifact, Step, Workflow, text_matc
66
69
  from captain_hook.prompt import Prompt
67
70
  from captain_hook.session import SessionSlot, SessionStore, session_state
68
71
  from captain_hook.settings import HooksSettings, build_settings
69
- from captain_hook.signals.nlp import Clause, NlpSignal, Phrase
72
+ from captain_hook.signals.nlp import Clause, NlpSignal, Phrase, has_nominal_subject, is_past_predicate
70
73
  from captain_hook.state import HookState, PrimitiveState, WorkflowState, workflow_state
71
74
  from captain_hook.tasks import Task, Tasks
72
75
  from captain_hook.testing import Allow, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
@@ -14,7 +14,7 @@ long names (``"python"``).
14
14
  from __future__ import annotations
15
15
 
16
16
  import re
17
- from collections.abc import Iterator
17
+ from collections.abc import Iterable, Iterator, Set
18
18
  from dataclasses import dataclass
19
19
  from typing import TYPE_CHECKING
20
20
 
@@ -29,6 +29,13 @@ EXT_TO_LANG: dict[str, str] = {glob.removeprefix("*."): lang for lang, globs in
29
29
 
30
30
  TEMPLATE_VAR = re.compile(r"\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)")
31
31
 
32
+ COMMENT_TYPES: frozenset[str] = frozenset({"comment", "line_comment", "block_comment"})
33
+ """Tree-sitter node kinds that denote a comment, across every supported grammar.
34
+
35
+ The union covers every [`LANG_GLOBS`][captain_hook.types.LANG_GLOBS] grammar; a future grammar
36
+ whose top-level comment kind is named differently would silently miss.
37
+ """
38
+
32
39
 
33
40
  @dataclass(frozen=True, slots=True)
34
41
  class Match:
@@ -39,6 +46,31 @@ class Match:
39
46
  text: str
40
47
 
41
48
 
49
+ @dataclass(frozen=True, slots=True)
50
+ class SyntaxNode:
51
+ """One node of a parsed syntax tree — the framework's face over the ast-grep binding node."""
52
+
53
+ raw: SgNode
54
+
55
+ @property
56
+ def kind(self) -> str:
57
+ return self.raw.kind()
58
+
59
+ @property
60
+ def text(self) -> str:
61
+ return self.raw.text()
62
+
63
+ def descendants(self) -> Iterator[SyntaxNode]:
64
+ """Every node below this one, in document order."""
65
+ for raw in self.raw.children():
66
+ yield (child := SyntaxNode(raw))
67
+ yield from child.descendants()
68
+
69
+ def to_match(self) -> Match:
70
+ r = self.raw.range()
71
+ return Match(line=r.start.line + 1, end_line=r.end.line + 1, text=self.raw.text())
72
+
73
+
42
74
  def lang_for_path(path: Path) -> str | None:
43
75
  """Infer an ast-grep language id from a file extension, or ``None`` when unsupported."""
44
76
  return EXT_TO_LANG.get(path.suffix.removeprefix("."))
@@ -49,15 +81,10 @@ def has_metavar(text: str) -> bool:
49
81
  return TEMPLATE_VAR.search(text) is not None
50
82
 
51
83
 
52
- def parse(source: str, lang: str) -> SgNode:
84
+ def parse(source: str, lang: str) -> SyntaxNode:
53
85
  from ast_grep_py import SgRoot
54
86
 
55
- return SgRoot(source, lang).root()
56
-
57
-
58
- def to_match(node: SgNode) -> Match:
59
- r = node.range()
60
- return Match(line=r.start.line + 1, end_line=r.end.line + 1, text=node.text())
87
+ return SyntaxNode(SgRoot(source, lang).root())
61
88
 
62
89
 
63
90
  def match_key(m: Match) -> str:
@@ -66,22 +93,46 @@ def match_key(m: Match) -> str:
66
93
 
67
94
  def matches(source: str, lang: str, pattern: str) -> bool:
68
95
  """Whether ``pattern`` matches anywhere in ``source`` — the cheap boolean for conditions."""
69
- return parse(source, lang).find(pattern=pattern) is not None
96
+ return parse(source, lang).raw.find(pattern=pattern) is not None
70
97
 
71
98
 
72
99
  def find_all(source: str, lang: str, pattern: str) -> Iterator[Match]:
73
100
  """Every structural match of ``pattern`` in ``source``, as 1-based-line :class:`Match` objects."""
74
- return (to_match(node) for node in parse(source, lang).find_all(pattern=pattern))
101
+ return (SyntaxNode(node).to_match() for node in parse(source, lang).raw.find_all(pattern=pattern))
75
102
 
76
103
 
77
- def find_introduced(old: str, new: str, lang: str, pattern: str) -> Iterator[Match]:
78
- """Matches present in ``new`` whose construct was absent from ``old`` the diff helper.
104
+ def find_kinds(source: str, lang: str, kinds: Set[str]) -> Iterator[Match]:
105
+ """Every node whose tree-sitter kind is in ``kinds``, in document order.
106
+
107
+ Kinds are read off parsed nodes rather than passed to the ast-grep kind
108
+ matcher, which raises on kind names a grammar doesn't define.
109
+ """
110
+ return (n.to_match() for n in parse(source, lang).descendants() if n.kind in kinds)
111
+
112
+
113
+ def comments(source: str, lang: str) -> Iterator[Match]:
114
+ """Every comment in ``source``, in document order — regardless of language."""
115
+ return find_kinds(source, lang, COMMENT_TYPES)
116
+
117
+
118
+ def introduced(old: Iterable[Match], new: Iterable[Match]) -> Iterator[Match]:
119
+ """Matches in ``new`` whose construct was absent from ``old``.
79
120
 
80
121
  Identity is the match's whitespace-normalized text, not its range (which shifts as
81
122
  surrounding code moves) — so a pre-existing construct is never reported as newly added.
82
123
  """
83
- before = {match_key(m) for m in find_all(old, lang, pattern)}
84
- return (m for m in find_all(new, lang, pattern) if match_key(m) not in before)
124
+ before = {match_key(m) for m in old}
125
+ return (m for m in new if match_key(m) not in before)
126
+
127
+
128
+ def find_introduced(old: str, new: str, lang: str, pattern: str) -> Iterator[Match]:
129
+ """Matches of ``pattern`` present in ``new`` but absent from ``old`` — the diff helper."""
130
+ return introduced(find_all(old, lang, pattern), find_all(new, lang, pattern))
131
+
132
+
133
+ def introduced_comments(old: str, new: str, lang: str) -> Iterator[Match]:
134
+ """Comments present in ``new`` whose text was absent from ``old``."""
135
+ return introduced(comments(old, lang), comments(new, lang))
85
136
 
86
137
 
87
138
  def rewrite(source: str, lang: str, pattern: str, replace: str) -> str:
@@ -90,7 +141,7 @@ def rewrite(source: str, lang: str, pattern: str, replace: str) -> str:
90
141
  ``replace`` uses ast-grep's ``$VAR`` / ``$$$VAR`` fix syntax, each metavariable filled from the
91
142
  match it names. Returns ``source`` unchanged when nothing matches.
92
143
  """
93
- root = parse(source, lang)
144
+ root = parse(source, lang).raw
94
145
  if not (edits := [node.replace(fill_template(node, replace)) for node in root.find_all(pattern=pattern)]):
95
146
  return source
96
147
  return root.commit_edits(edits)
@@ -103,7 +154,7 @@ def capture(source: str, lang: str, pattern: str) -> dict[str, str] | None:
103
154
  original-source span covering its matches, so whitespace is preserved (mirroring :func:`fill_template`).
104
155
  A pattern with no metavars that still matches yields an empty dict — present but empty.
105
156
  """
106
- if (node := parse(source, lang).find(pattern=pattern)) is None:
157
+ if (node := parse(source, lang).raw.find(pattern=pattern)) is None:
107
158
  return None
108
159
 
109
160
  def value(m: re.Match[str]) -> str:
@@ -8,6 +8,7 @@ import subprocess
8
8
  import sys
9
9
  import time
10
10
  from collections import defaultdict
11
+ from collections.abc import Sequence
11
12
  from dataclasses import dataclass
12
13
  from pathlib import Path
13
14
  from typing import Any
@@ -18,7 +19,7 @@ from cc_transcript.ids import SessionId
18
19
  from captain_hook.app import _state, load_gitignore, reset
19
20
  from captain_hook.context import HookContext, load_transcript
20
21
  from captain_hook.dispatch import dispatch
21
- from captain_hook.loader import CONF_MODULE, discover_hooks, discover_pack, is_skip_marked
22
+ from captain_hook.loader import CONF_MODULE, discover_hooks, discover_pack, is_skip_marked, register_nlp_provisioning
22
23
  from captain_hook.log import setup_logging
23
24
  from captain_hook.packs import manager
24
25
  from captain_hook.review.cli import review
@@ -35,19 +36,22 @@ class CliState:
35
36
  root: Path
36
37
  hooks: str
37
38
 
38
- def discover(self) -> None:
39
+ def discover(self) -> list[manager.ResolvedPack]:
39
40
  reset()
40
41
  load_gitignore(self.root)
41
42
  discover_hooks(self.hooks)
42
43
  resolved, missing = manager.resolve_enabled_packs(self.root)
43
44
  for pack_ in resolved:
44
45
  discover_pack(pack_.entry.name, pack_.path)
46
+ if any(pack_.manifest.nlp for pack_ in resolved):
47
+ register_nlp_provisioning()
45
48
  if missing:
46
49
  print(
47
50
  f"capt-hook: packs unavailable (offline and not cached): {', '.join(missing)} "
48
51
  "— run `capt-hook pack update` when online",
49
52
  file=sys.stderr,
50
53
  )
54
+ return resolved
51
55
 
52
56
 
53
57
  def example_hook_source() -> str:
@@ -222,12 +226,26 @@ def print_hook_summary(label: str, summary: dict[str, str], deferred_to: str) ->
222
226
  click.echo(f" deferred to {deferred_to}: {', '.join(deferred)}")
223
227
 
224
228
 
229
+ def provision_nlp(resolved: Sequence[manager.ResolvedPack]) -> None:
230
+ from captain_hook.util import http
231
+ from captain_hook.util.model_cache import ensure_nlp_resources
232
+
233
+ if not any(pack_.manifest.nlp for pack_ in resolved):
234
+ return
235
+ click.echo("Provisioning NLP resources (spaCy en_core_web_sm ~13MB + oewn:2025 lexicon ~231MB, cached)...")
236
+ try:
237
+ ensure_nlp_resources()
238
+ except (http.GitHubFetchError, OSError) as e:
239
+ click.echo(f" deferred (offline?): {e} — the SessionStart hook will retry at session start")
240
+
241
+
225
242
  def regenerate_settings(state: CliState) -> None:
226
- state.discover()
243
+ resolved = state.discover()
227
244
  settings_path = state.root / ".claude" / "settings.json"
228
245
  merged, summary = merge_settings(".claude/hooks", settings_path)
229
246
  write_settings(settings_path, merged)
230
247
  print_hook_summary(str(settings_path.relative_to(state.root)), summary, sibling_settings(settings_path).name)
248
+ provision_nlp(resolved)
231
249
 
232
250
 
233
251
  def settings_drift(root: Path) -> set[str]:
@@ -320,9 +338,10 @@ def init_project(root: Path, *, review: bool = True) -> None:
320
338
  example.write_text(example_hook_source())
321
339
 
322
340
  settings_path = root / ".claude" / "settings.json"
323
- CliState(root=root, hooks=str(hooks_dir)).discover()
341
+ resolved = CliState(root=root, hooks=str(hooks_dir)).discover()
324
342
  merged, summary = merge_settings(".claude/hooks", settings_path)
325
343
  write_settings(settings_path, merged)
344
+ provision_nlp(resolved)
326
345
 
327
346
  register_marketplace(root)
328
347
 
@@ -507,7 +526,7 @@ def run(state: CliState, event: str, async_: bool) -> None:
507
526
  @click.pass_obj
508
527
  def register_hooks_cmd(state: CliState, hooks_dir: str, dry_run: bool, from_source: str) -> None:
509
528
  """Register captain-hook's event hooks into .claude/settings.json."""
510
- state.discover()
529
+ resolved = state.discover()
511
530
  settings_path = state.root / ".claude" / "settings.json"
512
531
  merged, summary = merge_settings(hooks_dir, settings_path, from_source=from_source)
513
532
  if dry_run:
@@ -515,6 +534,7 @@ def register_hooks_cmd(state: CliState, hooks_dir: str, dry_run: bool, from_sour
515
534
  return
516
535
  write_settings(settings_path, merged)
517
536
  print_hook_summary(str(settings_path), summary, sibling_settings(settings_path).name)
537
+ provision_nlp(resolved)
518
538
 
519
539
 
520
540
  @cli.command()
@@ -0,0 +1,151 @@
1
+ """Declarative prompt contexts: named XML evidence blocks attached to LLM primitives.
2
+
3
+ A :class:`PromptContext` resolves one ``<tag>…</tag>`` block from the current event at
4
+ evaluation time. Pass instances via ``contexts=[...]`` on ``llm_nudge``/``llm_gate``;
5
+ a ``required`` context whose content is empty skips the LLM call entirely. The ambient
6
+ defaults :class:`BeforeEdit` and :class:`AfterEdit` attach to every primitive, carrying
7
+ the pending edit's before/after text on edit-shaped events and nothing elsewhere.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+ from functools import partial
15
+ from typing import TYPE_CHECKING, Protocol
16
+
17
+ from captain_hook.ast_grep import find_all, find_kinds, introduced, lang_for_path
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Sequence, Set
21
+
22
+ from captain_hook.events import BaseHookEvent
23
+ from captain_hook.prompt import Prompt
24
+
25
+ SNAKE_CASE = re.compile(r"(?<!^)(?=[A-Z])")
26
+
27
+
28
+ class PromptContext(Protocol):
29
+ """One declarative XML block attached to an LLM primitive's prompt.
30
+
31
+ ``content(evt)`` runs at evaluation time; ``None`` or empty content omits the
32
+ block. When a ``required`` context yields empty content, the primitive skips
33
+ the LLM call entirely — nothing fires and no fire is consumed.
34
+ """
35
+
36
+ tag: str
37
+ required: bool
38
+
39
+ def content(self, evt: BaseHookEvent) -> str | None: ...
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class BeforeEdit:
44
+ """The pending edit's pre-image, as a ``<before_edit>`` block.
45
+
46
+ Reads :attr:`~captain_hook.events.ToolHookEvent.replaced` — an Edit's old text, a
47
+ MultiEdit's joined olds, or a Write's current on-disk content — so it carries
48
+ content only at ``PreToolUse`` on edit-shaped events. Attached to every LLM
49
+ primitive as a default context; ``required=False`` because it is ambient
50
+ enrichment, empty (and omitted) on non-edit events. Pass ``BeforeEdit(required=True)``
51
+ explicitly to gate a hook on a non-empty pre-image.
52
+ """
53
+
54
+ tag: str = "before_edit"
55
+ required: bool = False
56
+
57
+ def content(self, evt: BaseHookEvent) -> str | None:
58
+ return evt.replaced
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class AfterEdit:
63
+ """The pending edit's new text, as an ``<after_edit>`` block.
64
+
65
+ Reads :attr:`~captain_hook.events.BaseHookEvent.content` — the text an Edit,
66
+ Write, MultiEdit, or NotebookEdit is about to land. Attached to every LLM
67
+ primitive as a default context; ``required=False`` because it is ambient
68
+ enrichment, empty (and omitted) on non-edit events.
69
+ """
70
+
71
+ tag: str = "after_edit"
72
+ required: bool = False
73
+
74
+ def content(self, evt: BaseHookEvent) -> str | None:
75
+ return evt.content
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class Introduced:
80
+ """Constructs the pending edit newly introduces, as an auto-tagged block.
81
+
82
+ Exactly one of ``kind``/``pattern`` selects the constructs: ``kind`` names
83
+ tree-sitter node kinds (a bare string or any set — normalized to ``frozenset``;
84
+ see :data:`~captain_hook.ast_grep.COMMENT_TYPES`), ``pattern`` is an ast-grep
85
+ pattern. Extraction diffs the event's before/after text, so only constructs
86
+ absent before the edit appear; files without a supported language yield nothing.
87
+
88
+ ``required`` defaults to ``True``: a context you attach explicitly IS the
89
+ evidence — no evidence, no LLM call. ``tag`` auto-derives from the class name in
90
+ snake_case (``Introduced()`` renders ``<introduced>``; a subclass named
91
+ ``TombstoneComments`` renders ``<tombstone_comments>``). Subclass and override
92
+ :meth:`keep` to filter which introduced constructs count.
93
+
94
+ Example:
95
+ >>> llm_nudge("...", contexts=[Introduced(pattern="print($$$)")],
96
+ ... events=Event.PreToolUse, only_if=[Tool("Edit", "Write", "MultiEdit")])
97
+ """
98
+
99
+ kind: str | Set[str] | None = None
100
+ pattern: str | None = None
101
+ required: bool = True
102
+ tag: str | None = None
103
+
104
+ def __post_init__(self) -> None:
105
+ if (self.kind is None) == (self.pattern is None):
106
+ raise ValueError(f"{type(self).__name__} requires exactly one of kind or pattern")
107
+ if self.kind is not None and not isinstance(self.kind, frozenset):
108
+ object.__setattr__(self, "kind", frozenset([self.kind] if isinstance(self.kind, str) else self.kind))
109
+ if self.tag is None:
110
+ object.__setattr__(self, "tag", SNAKE_CASE.sub("_", type(self).__name__).lower())
111
+
112
+ def keep(self, text: str) -> bool:
113
+ """Whether an introduced construct's text belongs in the block — override to filter."""
114
+ return True
115
+
116
+ def content(self, evt: BaseHookEvent) -> str | None:
117
+ if (
118
+ not (file := evt.file)
119
+ or not (lang := lang_for_path(file.path))
120
+ or (old := evt.replaced) is None
121
+ or (new := evt.content) is None
122
+ ):
123
+ return None
124
+ extract = (
125
+ partial(find_kinds, lang=lang, kinds=self.kind)
126
+ if self.pattern is None
127
+ else partial(find_all, lang=lang, pattern=self.pattern)
128
+ )
129
+ return "\n".join(m.text for m in introduced(extract(old), extract(new)) if self.keep(m.text))
130
+
131
+
132
+ def with_defaults(contexts: Sequence[PromptContext]) -> tuple[PromptContext, ...]:
133
+ defaults: tuple[PromptContext, ...] = (BeforeEdit(), AfterEdit())
134
+ return (*contexts, *(d for d in defaults if not any(isinstance(c, type(d)) for c in contexts)))
135
+
136
+
137
+ def apply_contexts(
138
+ prompt: Prompt, evt: BaseHookEvent, contexts: Sequence[PromptContext], *, max_len: int = 2000
139
+ ) -> Prompt | None:
140
+ """Append each context's block to ``prompt`` in order, each capped at ``max_len`` characters.
141
+
142
+ A context yielding ``None`` or whitespace-only content has its block omitted;
143
+ when that context is ``required``, returns ``None`` instead — the caller must
144
+ skip the LLM call entirely.
145
+ """
146
+ for c in contexts:
147
+ text = c.content(evt)
148
+ if c.required and not (text and text.strip()):
149
+ return None
150
+ prompt = prompt.context(c.tag, text and text[:max_len])
151
+ return prompt
@@ -26,11 +26,13 @@ if TYPE_CHECKING:
26
26
 
27
27
  Scope = Literal["project", "global"]
28
28
 
29
+ DURABLE_ROOT_OVERRIDE: list[Path] = []
30
+
29
31
 
30
32
  def durable_root() -> Path:
31
33
  from captain_hook.settings import resolve_state_dir
32
34
 
33
- return resolve_state_dir() / "hooks" / "durable"
35
+ return DURABLE_ROOT_OVERRIDE[-1] if DURABLE_ROOT_OVERRIDE else resolve_state_dir() / "hooks" / "durable"
34
36
 
35
37
 
36
38
  def project_key(repo_root: Path) -> str:
@@ -129,6 +129,10 @@ class BaseHookEvent:
129
129
  def old(self) -> str | None:
130
130
  return None
131
131
 
132
+ @property
133
+ def replaced(self) -> str | None:
134
+ return None
135
+
132
136
  @property
133
137
  def agent_type(self) -> str | None:
134
138
  return None
@@ -227,6 +231,23 @@ class ToolHookEvent(BaseHookEvent):
227
231
  case _:
228
232
  return None
229
233
 
234
+ @property
235
+ def replaced(self) -> str | None:
236
+ """The pre-image of the text this call overwrites.
237
+
238
+ An Edit's ``old``, a MultiEdit's newline-joined olds, or — for a Write — the
239
+ file's current on-disk content (``""`` for a new file). ``None`` for other
240
+ tools. The Write arm reads the disk, so it is only meaningful at
241
+ ``PreToolUse``: once the Write lands, disk already holds the new text.
242
+ """
243
+ match self.input:
244
+ case EditCall() | MultiEditCall():
245
+ return self.old
246
+ case WriteCall(file_path=path):
247
+ return p.read_text() if (p := Path(path)).exists() else ""
248
+ case _:
249
+ return None
250
+
230
251
  @property
231
252
  def agent_type(self) -> str | None:
232
253
  return call.agent_type if isinstance(call := self.input, TaskCall) else None
@@ -383,6 +404,20 @@ class NotificationEvent(BaseHookEvent):
383
404
  return self._raw.get("notification_type")
384
405
 
385
406
 
407
+ @dataclass
408
+ class SessionStartEvent(BaseHookEvent):
409
+ """Fires when a session starts, providing what triggered it. Warns inject context; it cannot block.
410
+
411
+ ``source`` is one of ``"startup"``, ``"resume"``, ``"clear"``, or ``"compact"``.
412
+ """
413
+
414
+ event_name: ClassVar[Event] = Event.SessionStart
415
+
416
+ @property
417
+ def source(self) -> str:
418
+ return self._raw["source"]
419
+
420
+
386
421
  @dataclass
387
422
  class SessionEndEvent(BaseHookEvent):
388
423
  """Fires when the session ends, providing the termination reason. Output is ignored, so it cannot block."""
@@ -9,11 +9,16 @@ import re
9
9
  import sys
10
10
  from pathlib import Path
11
11
  from types import ModuleType
12
+ from typing import TYPE_CHECKING
12
13
 
13
14
  from loguru import logger
14
15
 
15
- from captain_hook.app import State, _state
16
+ from captain_hook.app import State, _state, on
16
17
  from captain_hook.settings import build_settings
18
+ from captain_hook.types import Event
19
+
20
+ if TYPE_CHECKING:
21
+ from captain_hook.events import BaseHookEvent
17
22
 
18
23
  CONF_MODULE = "conf"
19
24
  PACK_PACKAGE_PREFIX = "captain_hook._packs"
@@ -105,6 +110,20 @@ def import_pack_module(fqn: str, path: Path) -> ModuleType:
105
110
  return module
106
111
 
107
112
 
113
+ def register_nlp_provisioning() -> None:
114
+ """Register the shared SessionStart hook that eagerly provisions NLP resources.
115
+
116
+ Called once per discovery pass when any resolved pack manifest declares ``nlp``,
117
+ so both event dispatch and settings generation see the async hook.
118
+ """
119
+
120
+ @on(Event.SessionStart, async_=True)
121
+ def provision_nlp_resources(evt: BaseHookEvent) -> None:
122
+ from captain_hook.util.model_cache import ensure_nlp_resources
123
+
124
+ ensure_nlp_resources()
125
+
126
+
108
127
  def discover_pack(name: str, pack_dir: Path) -> None:
109
128
  pkg = f"{PACK_PACKAGE_PREFIX}.{re.sub(r'\W', '_', name)}"
110
129
  ensure_pack_package(PACK_PACKAGE_PREFIX, [])
@@ -1,4 +1,5 @@
1
1
  name = "general"
2
- version = "0.3.0"
2
+ version = "0.5.0"
3
3
  description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, and a code-review gate."
4
4
  hooks = "."
5
+ nlp = true
@@ -1,21 +1,41 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
4
+
3
5
  from captain_hook import (
4
6
  Agent,
5
7
  Allow,
8
+ BaseHookEvent,
6
9
  Block,
7
10
  Event,
8
11
  Input,
9
12
  Rewrite,
13
+ TaskCall,
10
14
  Tool,
11
15
  ToolInput,
12
16
  Warn,
13
17
  WorkflowScript,
14
18
  hook,
19
+ llm_nudge,
15
20
  nudge,
16
21
  set_tool_input,
17
22
  )
18
23
 
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class DelegatedSpawn:
27
+ """Gating context: the pending Agent/Task call's model pin, agent type, and prompt."""
28
+
29
+ tag: str = "delegated_spawn"
30
+ required: bool = True
31
+
32
+ def content(self, evt: BaseHookEvent) -> str | None:
33
+ if (call := evt.as_input(TaskCall)) is None or not call.prompt:
34
+ return None
35
+ model = call.model or "(none — inherits the session model, fable)"
36
+ return f"model: {model}\nagent_type: {call.agent_type or '(default)'}\nprompt:\n{call.prompt}"
37
+
38
+
19
39
  hook(
20
40
  Event.PreToolUse,
21
41
  only_if=[Tool("Agent|Task"), ToolInput("model", r"(?i)\bhaiku\b")],
@@ -51,7 +71,10 @@ hook(
51
71
  only_if=[
52
72
  Tool("Agent|Task"),
53
73
  ToolInput("model", r"(?i)\b(haiku|sonnet|opus)\b"),
54
- ToolInput("prompt", r"(?i)\b(writ(e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)"),
74
+ ToolInput(
75
+ "prompt",
76
+ r"(?i)\b(writ(e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)",
77
+ ),
55
78
  ToolInput(
56
79
  "prompt",
57
80
  r"(?i)\b(readme|docs?|documentation|blog|changelog|release notes|announcement"
@@ -95,6 +118,71 @@ set_tool_input(
95
118
  },
96
119
  )
97
120
 
121
+ llm_nudge(
122
+ """Decide whether this delegated subagent should run on opus-4.8 instead of fable-5.
123
+
124
+ <delegated_spawn> holds the pending Agent/Task call: its model pin (or that it inherits
125
+ the session model, fable), agent type, and prompt.
126
+
127
+ The Models rubric: implementation delegates to opus-4.8 at xhigh — opus is ~2x cheaper
128
+ than fable and nearly as capable. Fable's lanes are orchestration, review, hard
129
+ planning/design/diagnosis, all prose/writing, and implementation that is very sensitive
130
+ or error-prone (auth, migrations, concurrency, data loss, crypto, subtle algorithms).
131
+
132
+ Set fire=true only when the prompt is clearly routine implementation — building, fixing,
133
+ wiring, or refactoring code — with no fable-lane signal. A prompt that reviews, plans,
134
+ designs, diagnoses a hard bug, writes prose, or touches a sensitive surface stays on
135
+ fable: fire=false. When uncertain, fire=false — the agent may have chosen fable
136
+ deliberately, and a false alarm teaches it to ignore this nudge. Keep reasoning under
137
+ 40 words.
138
+
139
+ <examples>
140
+ <example fire="true">
141
+ Implement the pagination endpoint in api/users.py per the spec in the plan.
142
+ Routine implementation with no sensitivity signal — the opus xhigh lane.
143
+ </example>
144
+ <example fire="true">
145
+ Add a --json flag to the export command and thread it through the formatter.
146
+ Well-scoped feature wiring; the default implementation lane.
147
+ </example>
148
+ <example fire="false">
149
+ Review the diff for correctness and concurrency issues.
150
+ Review is fable's lane.
151
+ </example>
152
+ <example fire="false">
153
+ Design the migration strategy for the sharded session store.
154
+ Hard planning/design stays on fable.
155
+ </example>
156
+ <example fire="false">
157
+ Implement the token-refresh race fix in the auth middleware.
158
+ Auth plus concurrency: sensitive, error-prone implementation stays on fable.
159
+ </example>
160
+ </examples>""",
161
+ message=lambda r: (
162
+ f"This delegation would run on fable, but it reads as routine implementation. {r.reasoning} "
163
+ "Implementation defaults to model='opus' + effort='xhigh' (~2x cheaper, nearly as capable); "
164
+ "a well-scoped edit to existing code can also go to gpt-5.5 via the codex skill behind a "
165
+ "model='sonnet' low-effort wrapper. Keep fable if this genuinely is sensitive or error-prone. "
166
+ "See CLAUDE.md § Plan Execution & Orchestration (Models)."
167
+ ),
168
+ contexts=[DelegatedSpawn()],
169
+ events=Event.PreToolUse,
170
+ only_if=[Tool("Agent|Task")],
171
+ skip_if=[
172
+ ToolInput("model", r"(?i)\b(opus|sonnet|haiku)\b"),
173
+ Agent("Explore|claude-code-guide"),
174
+ ],
175
+ agent=False,
176
+ transcript=False,
177
+ tests={
178
+ Input(prompt="implement the pagination endpoint in api/users.py"): Warn(pattern="opus"),
179
+ Input(model="fable", prompt="add a --json flag to the export command"): Warn(pattern="opus"),
180
+ Input(model="opus", prompt="implement the pagination endpoint in api/users.py"): Allow(),
181
+ Input(model="sonnet", prompt="scan the repo for TODO markers"): Allow(),
182
+ Input(agent_type="Explore", prompt="find where the config loader lives"): Allow(),
183
+ },
184
+ )
185
+
98
186
  nudge(
99
187
  """
100
188
  This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact