capt-hook 0.2.0__py3-none-any.whl

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 (57) hide show
  1. capt_hook-0.2.0.dist-info/METADATA +113 -0
  2. capt_hook-0.2.0.dist-info/RECORD +57 -0
  3. capt_hook-0.2.0.dist-info/WHEEL +4 -0
  4. capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
  5. capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
  6. captain_hook/__init__.py +246 -0
  7. captain_hook/__main__.py +6 -0
  8. captain_hook/app.py +278 -0
  9. captain_hook/classifiers/__init__.py +30 -0
  10. captain_hook/classifiers/conductor.py +35 -0
  11. captain_hook/classifiers/droid.py +20 -0
  12. captain_hook/classifiers/native.py +19 -0
  13. captain_hook/cli.py +341 -0
  14. captain_hook/command.py +356 -0
  15. captain_hook/conditions.py +136 -0
  16. captain_hook/context.py +161 -0
  17. captain_hook/dispatch.py +107 -0
  18. captain_hook/events.py +318 -0
  19. captain_hook/file.py +120 -0
  20. captain_hook/llm/__init__.py +9 -0
  21. captain_hook/llm/backends.py +152 -0
  22. captain_hook/loader.py +62 -0
  23. captain_hook/log.py +60 -0
  24. captain_hook/primitives/__init__.py +51 -0
  25. captain_hook/primitives/audit.py +71 -0
  26. captain_hook/primitives/commands.py +61 -0
  27. captain_hook/primitives/lint.py +216 -0
  28. captain_hook/primitives/llm.py +376 -0
  29. captain_hook/primitives/nudge.py +95 -0
  30. captain_hook/prompt.py +103 -0
  31. captain_hook/py.typed +1 -0
  32. captain_hook/session.py +158 -0
  33. captain_hook/settings.py +120 -0
  34. captain_hook/signals/__init__.py +86 -0
  35. captain_hook/signals/nlp.py +105 -0
  36. captain_hook/state.py +221 -0
  37. captain_hook/styleguide/__init__.py +183 -0
  38. captain_hook/styleguide/query.py +238 -0
  39. captain_hook/styleguide/scope.py +46 -0
  40. captain_hook/styleguide/types.py +70 -0
  41. captain_hook/tasks.py +112 -0
  42. captain_hook/templates/example_hook.py.tmpl +85 -0
  43. captain_hook/testing/__init__.py +10 -0
  44. captain_hook/testing/helpers.py +392 -0
  45. captain_hook/testing/session_cache.py +50 -0
  46. captain_hook/testing/types.py +88 -0
  47. captain_hook/tests/__init__.py +27 -0
  48. captain_hook/tests/helpers.py +361 -0
  49. captain_hook/tools.py +59 -0
  50. captain_hook/transcript/__init__.py +572 -0
  51. captain_hook/transcript/inputs.py +226 -0
  52. captain_hook/transcript/models.py +186 -0
  53. captain_hook/types.py +381 -0
  54. captain_hook/util/__init__.py +0 -0
  55. captain_hook/util/model_cache.py +87 -0
  56. captain_hook/utils.py +27 -0
  57. captain_hook/workflow.py +119 -0
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import inspect
5
+ import logging
6
+ from collections.abc import Sequence
7
+ from typing import TYPE_CHECKING
8
+
9
+ from captain_hook.app import on
10
+ from captain_hook.state import hook_name
11
+ from captain_hook.styleguide.query import (
12
+ Assignment,
13
+ Call,
14
+ Class,
15
+ ControlFlow,
16
+ Definition,
17
+ Function,
18
+ Import,
19
+ Kind,
20
+ Module,
21
+ Query,
22
+ Slot,
23
+ TypeChecking,
24
+ annotated_slots,
25
+ annotations,
26
+ calls,
27
+ has_future_annotations,
28
+ has_keyword,
29
+ is_name,
30
+ name_of,
31
+ named,
32
+ parent_map,
33
+ string_literals,
34
+ )
35
+ from captain_hook.styleguide.scope import changed_lines, read_source, reconstruct_pre
36
+ from captain_hook.styleguide.types import StyleDiffRule, StyleRule, Violation
37
+ from captain_hook.types import Action, Event, FilePath, HookResult, TCondition, TestFile, Tool
38
+
39
+ if TYPE_CHECKING:
40
+ from captain_hook.events import BaseHookEvent
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ GUARD_ONLY_IF: tuple[TCondition, ...] = (Tool("Edit|Write"), FilePath("*.py", project_only=False))
45
+ GUARD_SKIP_IF: tuple[TCondition, ...] = (TestFile(),)
46
+
47
+ __all__ = [
48
+ "Assignment",
49
+ "Call",
50
+ "Class",
51
+ "ControlFlow",
52
+ "Definition",
53
+ "Function",
54
+ "Import",
55
+ "Kind",
56
+ "Module",
57
+ "Query",
58
+ "Slot",
59
+ "StyleDiffRule",
60
+ "StyleRule",
61
+ "TypeChecking",
62
+ "Violation",
63
+ "annotated_slots",
64
+ "annotations",
65
+ "calls",
66
+ "has_future_annotations",
67
+ "has_keyword",
68
+ "is_name",
69
+ "name_of",
70
+ "named",
71
+ "parent_map",
72
+ "string_literals",
73
+ "styleguide",
74
+ ]
75
+
76
+
77
+ def styleguide(
78
+ *rules: type[StyleRule],
79
+ block: bool = False,
80
+ only_if: Sequence[TCondition] = (),
81
+ skip_if: Sequence[TCondition] = (),
82
+ events: Event | None = None,
83
+ max_shown: int = 5,
84
+ ) -> None:
85
+ """Register one change-scoped hook applying the given style rules to Python edits and writes.
86
+
87
+ Each rule is a [`StyleRule`][captain_hook.styleguide.StyleRule] (or
88
+ [`StyleDiffRule`][captain_hook.styleguide.StyleDiffRule]) subclass whose docstring is its
89
+ message. The single registered hook parses the edited file once, runs every rule against the
90
+ post-edit tree, scopes each violation to the changed lines, and emits one aggregated warning
91
+ (or block, when ``block`` is set). Call again with different ``only_if`` / ``skip_if`` /
92
+ ``events`` / ``block`` to register a separately scoped hook.
93
+
94
+ Args:
95
+ rules: ``StyleRule`` / ``StyleDiffRule`` subclasses to apply.
96
+ block: Block the tool call instead of warning.
97
+ only_if: Extra conditions ANDed onto the built-in ``Edit|Write`` + ``*.py`` guards.
98
+ skip_if: Extra conditions ORed onto the built-in test-file skip.
99
+ events: Override the default ``PostToolUse`` targeting.
100
+ max_shown: Maximum violations shown per rule.
101
+
102
+ Example:
103
+ >>> styleguide(NoPrint, NoBareExcept)
104
+ >>> styleguide(NoSqlInjection, block=True, only_if=[FilePath("api/**/*.py")])
105
+ """
106
+ if not (instances := [validate(rule)() for rule in rules]):
107
+ return
108
+
109
+ def handler(evt: BaseHookEvent) -> HookResult | None:
110
+ try:
111
+ return run_rules(instances, evt, block=block, max_shown=max_shown)
112
+ except Exception:
113
+ logger.warning("styleguide hook failed", exc_info=True)
114
+ return None
115
+
116
+ handler.__name__ = handler.__qualname__ = hook_name("styleguide", None, "+".join(r.slug() for r in instances))
117
+
118
+ on(
119
+ events or Event.PostToolUse,
120
+ only_if=(*GUARD_ONLY_IF, *only_if),
121
+ skip_if=(*GUARD_SKIP_IF, *skip_if),
122
+ tests={key: expected for inst in instances for key, expected in type(inst).tests.items()},
123
+ )(handler)
124
+
125
+
126
+ def validate(rule: type[StyleRule]) -> type[StyleRule]:
127
+ if not (isinstance(rule, type) and issubclass(rule, StyleRule)):
128
+ raise TypeError(f"styleguide() expects StyleRule subclasses, got {rule!r}")
129
+ if rule.__doc__ is None:
130
+ raise ValueError(f"{rule.__name__} must define a docstring — it is the rule's message")
131
+ return rule
132
+
133
+
134
+ def run_rules(rules: list[StyleRule], evt: BaseHookEvent, *, block: bool, max_shown: int) -> HookResult | None:
135
+ if (source := read_source(evt)) is None:
136
+ return None
137
+ try:
138
+ tree = ast.parse(source)
139
+ except SyntaxError:
140
+ return None
141
+ pre = reconstruct_pre(evt, source)
142
+ changed = changed_lines(pre, source)
143
+ pre_tree = parse_quietly(pre) if any(isinstance(r, StyleDiffRule) for r in rules) else None
144
+ if not (
145
+ sections := [
146
+ section
147
+ for rule in rules
148
+ if (section := run_one(rule, tree, pre_tree, source, changed, max_shown)) is not None
149
+ ]
150
+ ):
151
+ return None
152
+ return HookResult(action=Action.block if block else Action.warn, message="\n\n".join(sections))
153
+
154
+
155
+ def run_one(
156
+ rule: StyleRule,
157
+ tree: ast.Module,
158
+ pre_tree: ast.Module | None,
159
+ source: str,
160
+ changed: set[int],
161
+ max_shown: int,
162
+ ) -> str | None:
163
+ if rule.trigger and rule.trigger not in source:
164
+ return None
165
+ match rule:
166
+ case StyleDiffRule() if pre_tree is not None:
167
+ violations = rule.check(pre_tree, tree)
168
+ case StyleDiffRule():
169
+ return None
170
+ case _:
171
+ violations = rule.check(tree)
172
+ if not (scoped := [v for v in violations if v.line in changed]):
173
+ return None
174
+ block = rule.sep.join(f"{v.label} (line {v.line})" for v in scoped[:max_shown])
175
+ doc = inspect.cleandoc(type(rule).__doc__ or "")
176
+ return doc.format(violations=block) if "{violations}" in doc else f"{doc}{rule.sep}{block}"
177
+
178
+
179
+ def parse_quietly(source: str) -> ast.Module | None:
180
+ try:
181
+ return ast.parse(source)
182
+ except SyntaxError:
183
+ return None
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections.abc import Callable, Iterator, Mapping
5
+ from dataclasses import dataclass, replace
6
+
7
+ from captain_hook.styleguide.types import Violation
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Kind:
12
+ """A matchable category of AST node — a set of node types and/or a predicate.
13
+
14
+ Compose with ``|`` (matches either), narrow with the constructor, or build one with the
15
+ [`calls`][captain_hook.styleguide.calls] / [`named`][captain_hook.styleguide.named]
16
+ factories. Authoring a new rule is usually a matter of combining existing `Kind`s rather
17
+ than adding a framework helper.
18
+
19
+ Example:
20
+ >>> Definition = Function | Class
21
+ >>> Decorated = Kind(test=lambda n: bool(getattr(n, "decorator_list", None)))
22
+ """
23
+
24
+ types: tuple[type[ast.AST], ...] = ()
25
+ test: Callable[[ast.AST], bool] | None = None
26
+ label: str = "Kind"
27
+
28
+ def matches(self, node: ast.AST) -> bool:
29
+ return (not self.types or isinstance(node, self.types)) and (self.test is None or self.test(node))
30
+
31
+ def __or__(self, other: Kind) -> Kind:
32
+ return Kind(test=lambda n: self.matches(n) or other.matches(n), label=f"{self.label}|{other.label}")
33
+
34
+
35
+ def is_type_checking_test(test: ast.expr) -> bool:
36
+ """Return whether ``test`` is the ``TYPE_CHECKING`` / ``typing.TYPE_CHECKING`` condition."""
37
+ match test:
38
+ case ast.Name(id="TYPE_CHECKING"):
39
+ return True
40
+ case ast.Attribute(value=ast.Name(id="typing"), attr="TYPE_CHECKING"):
41
+ return True
42
+ case _:
43
+ return False
44
+
45
+
46
+ Module = Kind((ast.Module,), label="Module")
47
+ Class = Kind((ast.ClassDef,), label="Class")
48
+ Function = Kind((ast.FunctionDef, ast.AsyncFunctionDef), label="Function")
49
+ Definition = Class | Function
50
+ Import = Kind((ast.Import, ast.ImportFrom), label="Import")
51
+ Call = Kind((ast.Call,), label="Call")
52
+ Assignment = Kind((ast.Assign, ast.AnnAssign), label="Assignment")
53
+ ControlFlow = Kind(
54
+ (ast.If, ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith, ast.Try, ast.ExceptHandler),
55
+ label="ControlFlow",
56
+ )
57
+ TypeChecking = Kind((ast.If,), lambda n: is_type_checking_test(n.test), label="TypeChecking")
58
+
59
+
60
+ def calls(name: str) -> Kind:
61
+ """A `Kind` matching a call to the named function, e.g. ``calls("zip")``."""
62
+ return Kind((ast.Call,), lambda n: isinstance(n.func, ast.Name) and n.func.id == name, label=f"calls({name})")
63
+
64
+
65
+ def named(*names: str) -> Kind:
66
+ """A `Kind` matching a class/function/assignment/argument bound to one of ``names``."""
67
+ return Kind(test=lambda n: name_of(n) in names, label=f"named({', '.join(names)})")
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class Query:
72
+ """A fluent, immutable filter over the nodes of an AST tree.
73
+
74
+ Start with ``Query.of(tree)``, chain refinements (each returns a new `Query`), and finish
75
+ with a terminal — iterate it, call ``exists()``, or ``violations(label)`` to turn the
76
+ survivors into [`Violation`][captain_hook.styleguide.Violation]s.
77
+
78
+ Example:
79
+ >>> Query.of(tree).matching(Import).directly_inside(ControlFlow).not_inside(TypeChecking)
80
+ """
81
+
82
+ tree: ast.Module
83
+ items: tuple[ast.AST, ...]
84
+ parents: Mapping[ast.AST, ast.AST]
85
+
86
+ @classmethod
87
+ def of(cls, tree: ast.Module) -> Query:
88
+ return cls(tree, tuple(ast.walk(tree)), parent_map(tree))
89
+
90
+ def matching(self, kind: Kind) -> Query:
91
+ """Keep only nodes matching ``kind``."""
92
+ return self.keeping(kind.matches)
93
+
94
+ def where(self, predicate: Callable[[ast.AST], bool]) -> Query:
95
+ """Keep only nodes satisfying ``predicate`` — the escape hatch for one-off conditions."""
96
+ return self.keeping(predicate)
97
+
98
+ def inside(self, kind: Kind) -> Query:
99
+ """Keep nodes with any ancestor matching ``kind``."""
100
+ return self.keeping(lambda n: any(kind.matches(a) for a in self.ancestors(n)))
101
+
102
+ def directly_inside(self, kind: Kind) -> Query:
103
+ """Keep nodes whose immediate parent matches ``kind``."""
104
+ return self.keeping(lambda n: (p := self.parents.get(n)) is not None and kind.matches(p))
105
+
106
+ def not_inside(self, kind: Kind) -> Query:
107
+ """Keep nodes with no ancestor matching ``kind`` (e.g. ``not_inside(TypeChecking)``)."""
108
+ return self.keeping(lambda n: not any(kind.matches(a) for a in self.ancestors(n)))
109
+
110
+ def after_first(self, boundary: Kind) -> Query:
111
+ """Keep body-statements that follow the first sibling matching ``boundary``.
112
+
113
+ The basis for "declarations must precede code" rules: pair with ``directly_inside`` to
114
+ scope it to a module or class body.
115
+ """
116
+ return self.keeping(self.is_late(boundary))
117
+
118
+ def violations(self, label: str | Callable[[ast.AST], str]) -> Iterator[Violation]:
119
+ """Turn the surviving nodes into `Violation`s located at each node's line."""
120
+ return (Violation(node.lineno, label(node) if callable(label) else label) for node in self.items)
121
+
122
+ def exists(self) -> bool:
123
+ return bool(self.items)
124
+
125
+ def keeping(self, predicate: Callable[[ast.AST], bool]) -> Query:
126
+ return replace(self, items=tuple(node for node in self.items if predicate(node)))
127
+
128
+ def ancestors(self, node: ast.AST) -> Iterator[ast.AST]:
129
+ cur = self.parents.get(node)
130
+ while cur is not None:
131
+ yield cur
132
+ cur = self.parents.get(cur)
133
+
134
+ def is_late(self, boundary: Kind) -> Callable[[ast.AST], bool]:
135
+ def late(node: ast.AST) -> bool:
136
+ body = getattr(self.parents.get(node), "body", None)
137
+ if not isinstance(body, list) or node not in body:
138
+ return False
139
+ cut = next((i for i, stmt in enumerate(body) if boundary.matches(stmt)), None)
140
+ return cut is not None and body.index(node) > cut
141
+
142
+ return late
143
+
144
+ def __iter__(self) -> Iterator[ast.AST]:
145
+ return iter(self.items)
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class Slot:
150
+ """An annotated slot: a variable, function return, or parameter and its annotation expr."""
151
+
152
+ name: str
153
+ annotation: ast.expr
154
+ line: int
155
+ is_return: bool = False
156
+
157
+
158
+ def parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]:
159
+ """Map every node in ``tree`` to its parent node."""
160
+ return {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
161
+
162
+
163
+ def name_of(node: ast.AST) -> str | None:
164
+ """The name a node binds or defines: a class, function, simple assignment target, or argument."""
165
+ match node:
166
+ case ast.ClassDef(name=name) | ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name):
167
+ return name
168
+ case ast.Assign(targets=[ast.Name(id=name), *_]) | ast.AnnAssign(target=ast.Name(id=name)):
169
+ return name
170
+ case ast.arg(arg=name):
171
+ return name
172
+ case _:
173
+ return None
174
+
175
+
176
+ def is_name(expr: ast.expr, name: str) -> bool:
177
+ """Return whether ``expr`` is exactly the bare name ``name`` (e.g. ``is_name(ann, "Any")``)."""
178
+ return isinstance(expr, ast.Name) and expr.id == name
179
+
180
+
181
+ def has_keyword(call: ast.Call, name: str) -> bool:
182
+ """Return whether ``call`` passes a keyword argument named ``name``."""
183
+ return any(kw.arg == name for kw in call.keywords)
184
+
185
+
186
+ def has_future_annotations(tree: ast.Module) -> bool:
187
+ """Return whether the module begins with ``from __future__ import annotations``."""
188
+ return any(
189
+ isinstance(node, ast.ImportFrom)
190
+ and node.module == "__future__"
191
+ and any(alias.name == "annotations" for alias in node.names)
192
+ for node in tree.body
193
+ )
194
+
195
+
196
+ def annotations(tree: ast.Module) -> Iterator[ast.expr]:
197
+ """Every annotation expression in ``tree`` — annotated assignments, returns, and arguments."""
198
+ for node in ast.walk(tree):
199
+ match node:
200
+ case ast.AnnAssign(annotation=ann):
201
+ yield ann
202
+ case ast.FunctionDef(returns=ann) | ast.AsyncFunctionDef(returns=ann) if ann is not None:
203
+ yield ann
204
+ case ast.arg(annotation=ann) if ann is not None:
205
+ yield ann
206
+
207
+
208
+ def string_literals(expr: ast.expr) -> Iterator[ast.Constant]:
209
+ """String-literal type references within an annotation, skipping ``Literal``/``Annotated`` metadata."""
210
+ match expr:
211
+ case ast.Constant(value=str()):
212
+ yield expr
213
+ case ast.Subscript(value=ast.Name(id="Literal") | ast.Attribute(attr="Literal")):
214
+ return
215
+ case ast.Subscript(value=ast.Name(id="Annotated") | ast.Attribute(attr="Annotated"), slice=ast.Tuple(elts=[first, *_])):
216
+ yield from string_literals(first)
217
+ case _:
218
+ yield from (ref for child in ast.iter_child_nodes(expr) if isinstance(child, ast.expr) for ref in string_literals(child))
219
+
220
+
221
+ def annotated_slots(tree: ast.Module) -> Iterator[Slot]:
222
+ """Every annotated slot in ``tree`` as a [`Slot`][captain_hook.styleguide.Slot].
223
+
224
+ Covers annotated variables, function return types, and positional/keyword parameters —
225
+ enough to drive any annotation-shape rule via a predicate on ``slot.annotation``.
226
+ """
227
+ for node in ast.walk(tree):
228
+ match node:
229
+ case ast.AnnAssign(target=ast.Name(id=name), annotation=ann):
230
+ yield Slot(name, ann, node.lineno)
231
+ case ast.FunctionDef() | ast.AsyncFunctionDef():
232
+ if node.returns is not None:
233
+ yield Slot(node.name, node.returns, node.lineno, is_return=True)
234
+ yield from (
235
+ Slot(arg.arg, arg.annotation, arg.lineno)
236
+ for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs)
237
+ if arg.annotation is not None
238
+ )
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from captain_hook.events import BaseHookEvent
8
+
9
+
10
+ def read_source(evt: BaseHookEvent) -> str | None:
11
+ if evt.file and evt.file.path.exists():
12
+ try:
13
+ return evt.file.read_text()
14
+ except (OSError, UnicodeDecodeError):
15
+ pass
16
+ return evt.content
17
+
18
+
19
+ def reconstruct_pre(evt: BaseHookEvent, source: str) -> str:
20
+ """Best-effort pre-edit source: an Edit's ``new_string`` swapped back to ``old_string``.
21
+
22
+ Returns ``""`` when there is nothing to diff against (a Write, or an Edit whose fragment
23
+ can't be located) — the conservative choice that treats the whole file as changed.
24
+ """
25
+ match (evt.old, evt.content):
26
+ case (str() as old, str() as new) if new and new in source:
27
+ return source.replace(new, old, 1)
28
+ case _:
29
+ return ""
30
+
31
+
32
+ def changed_lines(pre: str, source: str) -> set[int]:
33
+ """1-based line numbers in ``source`` that differ from ``pre``.
34
+
35
+ An empty ``pre`` means "everything changed" (a Write or an unlocatable Edit), so every line
36
+ of ``source`` is returned.
37
+ """
38
+ new_lines = source.splitlines()
39
+ if not pre:
40
+ return set(range(1, len(new_lines) + 1))
41
+ return {
42
+ i
43
+ for tag, _, _, lo, hi in difflib.SequenceMatcher(a=pre.splitlines(), b=new_lines, autojunk=False).get_opcodes()
44
+ if tag in {"replace", "insert"}
45
+ for i in range(lo + 1, hi + 1)
46
+ }
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, ClassVar
8
+
9
+ from captain_hook.utils import kebab
10
+
11
+ if TYPE_CHECKING:
12
+ from captain_hook.testing import InlineTests
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Violation:
17
+ """A single style violation, located by line so the runner can scope it to the edit.
18
+
19
+ Attributes:
20
+ line: 1-based line number of the offending construct in the post-edit file.
21
+ label: Short human-readable description, rendered as ``"{label} (line {line})"``.
22
+ """
23
+
24
+ line: int
25
+ label: str
26
+
27
+
28
+ class StyleRule(ABC):
29
+ """Base class for a single-tree AST style rule applied to Python edits and writes.
30
+
31
+ Subclass it, write the rule's message as the class **docstring** (``{violations}`` is
32
+ substituted at fire time), and implement ``check``. The class name is the rule's identity —
33
+ ``NoNestedImports`` becomes ``"no-nested-imports"``.
34
+
35
+ Example:
36
+ ```python
37
+ class NoPrint(StyleRule):
38
+ \"\"\"
39
+ print() calls don't belong in committed code:
40
+ - {violations}
41
+ \"\"\"
42
+ def check(self, tree):
43
+ for node in ast.walk(tree):
44
+ match node:
45
+ case ast.Call(func=ast.Name(id="print")):
46
+ yield Violation(node.lineno, "print() call")
47
+ ```
48
+ """
49
+
50
+ trigger: ClassVar[str | None] = None
51
+ sep: ClassVar[str] = "\n - "
52
+ tests: ClassVar[InlineTests] = {}
53
+
54
+ @classmethod
55
+ def slug(cls) -> str:
56
+ return kebab(cls.__name__)
57
+
58
+ @abstractmethod
59
+ def check(self, tree: ast.Module) -> Iterator[Violation]: ...
60
+
61
+
62
+ class StyleDiffRule(StyleRule):
63
+ """Base class for a diff rule: flags constructs newly introduced by the change.
64
+
65
+ Like [`StyleRule`][captain_hook.styleguide.StyleRule], but ``check`` receives both the
66
+ pre-edit and post-edit module trees, so it can report only what the edit *added*.
67
+ """
68
+
69
+ @abstractmethod
70
+ def check(self, pre: ast.Module, post: ast.Module) -> Iterator[Violation]: ... # type: ignore[override]
captain_hook/tasks.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, ClassVar, overload
9
+
10
+ from loguru import logger
11
+
12
+
13
+ @dataclass(frozen=True, kw_only=True)
14
+ class Task:
15
+ """A task read from Claude Code's native task store (``~/.claude/tasks/<list-id>/<id>.json``)."""
16
+
17
+ OPEN_STATUSES: ClassVar[tuple[str, ...]] = ("pending", "in_progress")
18
+
19
+ id: str
20
+ subject: str
21
+ status: str
22
+ description: str = ""
23
+ owner: str | None = None
24
+ blocked_by: tuple[str, ...] = ()
25
+ blocks: tuple[str, ...] = ()
26
+
27
+ @property
28
+ def is_open(self) -> bool:
29
+ return self.status in self.OPEN_STATUSES
30
+
31
+ @classmethod
32
+ def from_raw(cls, raw: dict[str, Any]) -> Task:
33
+ return cls(
34
+ id=str(raw.get("id", "")),
35
+ subject=raw.get("subject") or "",
36
+ status=raw.get("status") or "pending",
37
+ description=raw.get("description") or "",
38
+ owner=raw.get("owner") or None,
39
+ blocked_by=tuple(raw.get("blockedBy") or ()),
40
+ blocks=tuple(raw.get("blocks") or ()),
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Tasks(Sequence[Task]):
46
+ """The live task list for one session, read from the native store rather than the transcript.
47
+
48
+ Always keyed by the exact list id (session id) — a session with no store has no
49
+ tasks, never another session's. This is the source of truth for completion gates;
50
+ transcript-derived ``task_ops()`` misses updates made by subagents, teammates, or
51
+ resumed sessions.
52
+ """
53
+
54
+ tasks: tuple[Task, ...] = ()
55
+
56
+ @classmethod
57
+ def resolve_root(cls) -> Path:
58
+ """Resolve the root of Claude Code's native task store (``<config-dir>/tasks``)."""
59
+ if explicit := os.environ.get("CAPTAIN_HOOK_TASKS_DIR"):
60
+ return Path(explicit)
61
+ config_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
62
+ return config_dir / "tasks"
63
+
64
+ @classmethod
65
+ def for_session(cls, session_id: str, *, root: Path | None = None) -> Tasks:
66
+ """Load the task list stored under ``session_id``, empty when absent."""
67
+ list_dir = (root or cls.resolve_root()) / session_id
68
+ if not session_id or not list_dir.is_dir():
69
+ return cls()
70
+ tasks: list[Task] = []
71
+ for path in list_dir.glob("*.json"):
72
+ try:
73
+ tasks.append(Task.from_raw(json.loads(path.read_text())))
74
+ except (OSError, ValueError):
75
+ logger.bind(path=str(path)).opt(exception=True).warning("failed to read task file")
76
+ return cls(tuple(sorted(tasks, key=lambda t: (not t.id.isdigit(), int(t.id) if t.id.isdigit() else 0, t.id))))
77
+
78
+ @overload
79
+ def __getitem__(self, index: int) -> Task: ...
80
+ @overload
81
+ def __getitem__(self, index: slice) -> tuple[Task, ...]: ...
82
+ def __getitem__(self, index: int | slice) -> Task | tuple[Task, ...]:
83
+ return self.tasks[index]
84
+
85
+ def __len__(self) -> int:
86
+ return len(self.tasks)
87
+
88
+ def get(self, task_id: str) -> Task | None:
89
+ return next((t for t in self.tasks if t.id == task_id), None)
90
+
91
+ def with_status(self, *statuses: str) -> tuple[Task, ...]:
92
+ return tuple(t for t in self.tasks if t.status in statuses)
93
+
94
+ @property
95
+ def pending(self) -> tuple[Task, ...]:
96
+ return self.with_status("pending")
97
+
98
+ @property
99
+ def in_progress(self) -> tuple[Task, ...]:
100
+ return self.with_status("in_progress")
101
+
102
+ @property
103
+ def completed(self) -> tuple[Task, ...]:
104
+ return self.with_status("completed")
105
+
106
+ @property
107
+ def open(self) -> tuple[Task, ...]:
108
+ return self.with_status(*Task.OPEN_STATUSES)
109
+
110
+ @property
111
+ def all_completed(self) -> bool:
112
+ return not self.open