capt-hook 5.0.0__tar.gz → 5.1.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 (105) hide show
  1. {capt_hook-5.0.0 → capt_hook-5.1.0}/PKG-INFO +1 -1
  2. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/classifiers/__init__.py +1 -3
  3. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/classifiers/conductor.py +8 -8
  4. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/cli.py +2 -2
  5. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/loader.py +1 -3
  6. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/general/capt-hook.toml +1 -1
  7. capt_hook-5.1.0/captain_hook/packs/general/models.py +133 -0
  8. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/general/tasks.py +1 -3
  9. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/manager.py +15 -21
  10. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/dashboard.py +1 -3
  11. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/fix.py +9 -14
  12. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/formats.py +4 -2
  13. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/judge.py +74 -86
  14. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/pipeline.py +9 -8
  15. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/scan.py +4 -7
  16. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/store.py +126 -130
  17. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/settings.py +8 -8
  18. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/style/__init__.py +2 -5
  19. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/style/matchers.py +5 -8
  20. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/testing/helpers.py +1 -3
  21. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/util/http.py +1 -3
  22. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/util/model_cache.py +11 -8
  23. {capt_hook-5.0.0 → capt_hook-5.1.0}/pyproject.toml +1 -1
  24. capt_hook-5.0.0/captain_hook/packs/general/models.py +0 -67
  25. {capt_hook-5.0.0 → capt_hook-5.1.0}/LICENSE +0 -0
  26. {capt_hook-5.0.0 → capt_hook-5.1.0}/README.md +0 -0
  27. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  28. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/__init__.py +0 -0
  29. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/__main__.py +0 -0
  30. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/app.py +0 -0
  31. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/ast_grep.py +0 -0
  32. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/classifiers/droid.py +0 -0
  33. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/classifiers/native.py +0 -0
  34. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/command.py +0 -0
  35. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/conditions.py +0 -0
  36. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/context.py +0 -0
  37. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/decisions.py +0 -0
  38. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/dispatch.py +0 -0
  39. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/durable.py +0 -0
  40. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/events.py +0 -0
  41. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/fields.py +0 -0
  42. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/file.py +0 -0
  43. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/log.py +0 -0
  44. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/__init__.py +0 -0
  45. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/general/__init__.py +0 -0
  46. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/general/commands.py +0 -0
  47. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/general/docs.py +0 -0
  48. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/general/plans.py +0 -0
  49. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/general/prompts.py +0 -0
  50. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/general/review.py +0 -0
  51. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/go/__init__.py +0 -0
  52. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  53. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/go/testing.py +0 -0
  54. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/go/toolchain.py +0 -0
  55. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/python/__init__.py +0 -0
  56. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  57. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/python/style.py +0 -0
  58. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/python/testing.py +0 -0
  59. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/python/toolchain.py +0 -0
  60. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/steering/__init__.py +0 -0
  61. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  62. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/packs/steering/steering.py +0 -0
  63. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/primitives/__init__.py +0 -0
  64. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/primitives/commands.py +0 -0
  65. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/primitives/lint.py +0 -0
  66. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/primitives/llm.py +0 -0
  67. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/primitives/nudge.py +0 -0
  68. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/primitives/rewrite.py +0 -0
  69. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/primitives/workflow.py +0 -0
  70. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/prompt.py +0 -0
  71. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/py.typed +0 -0
  72. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/__init__.py +0 -0
  73. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/cli.py +0 -0
  74. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/repo.py +0 -0
  75. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/settings.py +0 -0
  76. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/review/sync.py +0 -0
  77. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/session.py +0 -0
  78. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/signals/__init__.py +0 -0
  79. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/signals/nlp.py +0 -0
  80. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  81. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  82. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  83. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  84. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  85. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  86. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  87. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  88. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  89. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  90. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  91. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  92. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  93. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/state.py +0 -0
  94. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/style/ast_grep.py +0 -0
  95. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/style/scope.py +0 -0
  96. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/style/types.py +0 -0
  97. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/tasks.py +0 -0
  98. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  99. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/testing/__init__.py +0 -0
  100. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/testing/session_cache.py +0 -0
  101. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/testing/types.py +0 -0
  102. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/types.py +0 -0
  103. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/util/__init__.py +0 -0
  104. {capt_hook-5.0.0 → capt_hook-5.1.0}/captain_hook/util/fs.py +0 -0
  105. {capt_hook-5.0.0 → capt_hook-5.1.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.0.0
3
+ Version: 5.1.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
@@ -9,8 +9,6 @@ if TYPE_CHECKING:
9
9
  from cc_transcript.activity import UserClassifier
10
10
  from cc_transcript.models import TranscriptEvent
11
11
 
12
- CLASSIFIER_MODULES = ("droid", "conductor", "native")
13
-
14
12
 
15
13
  def detect(
16
14
  cwd: str | None = None,
@@ -20,7 +18,7 @@ def detect(
20
18
  """Auto-detect the environment and return the user classifier for turn segmentation."""
21
19
  return next(
22
20
  mod.classifier
23
- for name in CLASSIFIER_MODULES
21
+ for name in ("droid", "conductor", "native")
24
22
  if (mod := importlib.import_module(f".{name}", __package__))
25
23
  and mod.detect(cwd=cwd, transcript_path=transcript_path, events=events)
26
24
  )
@@ -10,16 +10,16 @@ if TYPE_CHECKING:
10
10
 
11
11
  from cc_transcript.models import TranscriptEvent
12
12
 
13
- SYNTHETIC_PREFIXES = (
14
- "<system_instruction>",
15
- "<task-notification>",
16
- "<local-command-caveat>",
17
- "<command-name>",
18
- )
19
-
20
13
 
21
14
  def classifier(event: UserEvent) -> bool:
22
- return native_user_classifier(event) and not event.text.strip().startswith(SYNTHETIC_PREFIXES)
15
+ return native_user_classifier(event) and not event.text.strip().startswith(
16
+ (
17
+ "<system_instruction>",
18
+ "<task-notification>",
19
+ "<local-command-caveat>",
20
+ "<command-name>",
21
+ )
22
+ )
23
23
 
24
24
 
25
25
  def detect(
@@ -27,7 +27,6 @@ from captain_hook.types import Event
27
27
 
28
28
  DIST_NAME = "capt-hook"
29
29
  EVENT_NAMES = ", ".join(n for e in Event if (n := e.name))
30
- MARKETPLACE = {"captain-hook": {"source": {"source": "github", "repo": "yasyf/captain-hook"}}}
31
30
  PLUGIN_ID = "captain-hook@captain-hook"
32
31
 
33
32
 
@@ -79,7 +78,8 @@ def register_marketplace(root: Path) -> None:
79
78
  settings_path,
80
79
  existing
81
80
  | {
82
- "extraKnownMarketplaces": existing.get("extraKnownMarketplaces", {}) | MARKETPLACE,
81
+ "extraKnownMarketplaces": existing.get("extraKnownMarketplaces", {})
82
+ | {"captain-hook": {"source": {"source": "github", "repo": "yasyf/captain-hook"}}},
83
83
  "enabledPlugins": existing.get("enabledPlugins", {}) | {PLUGIN_ID: True},
84
84
  },
85
85
  )
@@ -17,8 +17,6 @@ from captain_hook.settings import build_settings
17
17
 
18
18
  CONF_MODULE = "conf"
19
19
  PACK_PACKAGE_PREFIX = "captain_hook._packs"
20
- SKIP_MARKER = "__capt_hook_skip__"
21
- SKIP_MARKER_RE = re.compile(rf"^{SKIP_MARKER}\s*=\s*True\b", re.MULTILINE)
22
20
 
23
21
 
24
22
  def is_test_module(fqn: str) -> bool:
@@ -28,7 +26,7 @@ def is_test_module(fqn: str) -> bool:
28
26
 
29
27
  def is_skip_marked(path: Path) -> bool:
30
28
  """True when the module declares ``__capt_hook_skip__ = True`` — a library, not an auto-loaded hook."""
31
- return path.is_file() and bool(SKIP_MARKER_RE.search(path.read_text()))
29
+ return path.is_file() and bool(re.search(r"^__capt_hook_skip__\s*=\s*True\b", path.read_text(), re.MULTILINE))
32
30
 
33
31
 
34
32
  def import_or_reload(fqn: str, fresh_this_pass: set[str]) -> ModuleType:
@@ -1,4 +1,4 @@
1
1
  name = "general"
2
- version = "0.2.0"
2
+ version = "0.3.0"
3
3
  description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, and a code-review gate."
4
4
  hooks = "."
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import (
4
+ Agent,
5
+ Allow,
6
+ Block,
7
+ Event,
8
+ Input,
9
+ Rewrite,
10
+ Tool,
11
+ ToolInput,
12
+ Warn,
13
+ WorkflowScript,
14
+ hook,
15
+ nudge,
16
+ set_tool_input,
17
+ )
18
+
19
+ hook(
20
+ Event.PreToolUse,
21
+ only_if=[Tool("Agent|Task"), ToolInput("model", r"(?i)\bhaiku\b")],
22
+ skip_if=[
23
+ ToolInput(
24
+ "prompt",
25
+ r"(?i)\b(classif|label|tag|categoriz|per.file|one (fact|thing)|mechanical|probe|ping"
26
+ r"|echo|smoke|count|capacit|extract)",
27
+ )
28
+ ],
29
+ message=(
30
+ "This subagent is pinned to haiku. Route a subagent to haiku only for a single-fact "
31
+ "mechanical step — classifying, labeling, tagging, counting, or probing one thing per "
32
+ "item. Anything that carries judgment should run on sonnet; use model='sonnet', or drop "
33
+ "model entirely to inherit the session model. If this genuinely is a mechanical "
34
+ "single-fact step, say 'mechanical' in the prompt and retry — the haiku pin will be "
35
+ "allowed. See CLAUDE.md § Plan Execution & Orchestration (Models)."
36
+ ),
37
+ block=True,
38
+ tests={
39
+ Input(model="haiku", prompt="implement the retry backoff in the client"): Block(),
40
+ Input(model="haiku", prompt="classify each file's language"): Allow(),
41
+ Input(model="haiku", prompt="Probe subagent capacity: spawn and return the word ok"): Allow(),
42
+ Input(model="haiku", prompt="mechanical step: return the repo's default branch name"): Allow(),
43
+ Input(model="haiku", prompt="count the TODO markers in src/"): Allow(),
44
+ Input(prompt="implement the retry backoff in the client"): Allow(),
45
+ Input(model="sonnet", prompt="implement the retry backoff in the client"): Allow(),
46
+ },
47
+ )
48
+
49
+ hook(
50
+ Event.PreToolUse,
51
+ only_if=[
52
+ Tool("Agent|Task"),
53
+ 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)"),
55
+ ToolInput(
56
+ "prompt",
57
+ r"(?i)\b(readme|docs?|documentation|blog|changelog|release notes|announcement"
58
+ r"|tutorial|guide|prose|copywrit|marketing copy|article|newsletter|email"
59
+ r"|pr description|commit message)\b",
60
+ ),
61
+ ],
62
+ skip_if=[ToolInput("prompt", r"(?i)\b(classif|label|tag|categoriz|count|extract|mechanical)")],
63
+ message=(
64
+ "This subagent is pinned to a non-fable model but its prompt is prose/writing work. "
65
+ "All writing — docs, READMEs, release notes, any user-facing text — routes to fable: "
66
+ "drop model to inherit the session model, or pass model='fable'. "
67
+ "See CLAUDE.md § Plan Execution & Orchestration (Models)."
68
+ ),
69
+ block=True,
70
+ tests={
71
+ Input(model="sonnet", prompt="Write the README quickstart for this repo"): Block(),
72
+ Input(model="opus", prompt="draft the release notes for v2"): Block(),
73
+ Input(model="haiku", prompt="update the CHANGELOG entry for the fix"): Block(),
74
+ Input(model="fable", prompt="write the README quickstart"): Allow(),
75
+ Input(prompt="write the README quickstart"): Allow(),
76
+ Input(model="sonnet", prompt="review the README for factual errors"): Allow(),
77
+ Input(model="sonnet", prompt="update the retry backoff config"): Allow(),
78
+ Input(model="haiku", prompt="label each README section with its Diataxis mode"): Allow(),
79
+ },
80
+ )
81
+
82
+ set_tool_input(
83
+ "model",
84
+ "sonnet",
85
+ tool="Agent|Task",
86
+ only_if=[Agent("Explore|claude-code-guide")],
87
+ note=(
88
+ "Upgraded this recon subagent from the silent haiku default to sonnet, per the Models "
89
+ "table in CLAUDE.md § Plan Execution & Orchestration (Models)."
90
+ ),
91
+ tests={
92
+ Input(agent_type="Explore"): Rewrite(model="sonnet"),
93
+ Input(agent_type="Explore", model="haiku"): Allow(),
94
+ Input(agent_type="general-purpose"): Allow(),
95
+ },
96
+ )
97
+
98
+ nudge(
99
+ """
100
+ This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
101
+ map steps; a judgment-bearing stage should inherit the session model or route up. See
102
+ CLAUDE.md § Plan Execution & Orchestration (Models).
103
+ """,
104
+ only_if=[Tool("Workflow"), WorkflowScript(model="haiku")],
105
+ events=Event.PreToolUse,
106
+ max_fires=2,
107
+ tests={
108
+ Input(script="steps:\n - agent: reviewer\n model: 'haiku'\n"): Warn(),
109
+ Input(script="steps:\n - agent: reviewer\n model: 'sonnet'\n"): Allow(),
110
+ },
111
+ )
112
+
113
+ nudge(
114
+ """
115
+ This workflow script pins a non-fable model on what looks like prose/writing stages. All
116
+ writing — docs, READMEs, release notes, any user-facing text — routes to fable: inherit the
117
+ session model or pin model: 'fable'. See CLAUDE.md § Plan Execution & Orchestration (Models).
118
+ """,
119
+ only_if=[
120
+ Tool("Workflow"),
121
+ WorkflowScript(
122
+ pattern=r"(?i)\b(readme|docs?|documentation|blog|changelog|release notes|prose)\b",
123
+ model="haiku|sonnet|opus",
124
+ ),
125
+ ],
126
+ events=Event.PreToolUse,
127
+ max_fires=2,
128
+ tests={
129
+ Input(script="steps:\n - agent: write the README intro\n model: 'sonnet'\n"): Warn(),
130
+ Input(script="steps:\n - agent: write the README intro\n model: 'fable'\n"): Allow(),
131
+ Input(script="steps:\n - agent: fix the retry backoff\n model: 'sonnet'\n"): Allow(),
132
+ },
133
+ )
@@ -22,8 +22,6 @@ from captain_hook import (
22
22
  OVERRIDE_TOKEN = "REMAINING_TASKS_ACKNOWLEDGED"
23
23
  TASK_DRIFT_THRESHOLD = 8
24
24
 
25
- EXPLORATION_TOOLS = "Bash|Grep|Glob|WebSearch|WebFetch|LSP|Skill"
26
-
27
25
  IMPERATIVES = (
28
26
  r"\b(?:add|fix|update|change|remove|create|implement|refactor|"
29
27
  r"move|rename|delete|replace|extract|split|merge|convert|migrate)\b"
@@ -54,7 +52,7 @@ class DriftedFromTasks(CustomCondition):
54
52
  if not evt.tasks.open:
55
53
  return False
56
54
  since = evt.ctx.t.after(tool="TaskCreate|TaskUpdate|TaskList|TaskGet")
57
- return since.tool_calls.named(EXPLORATION_TOOLS).count() >= TASK_DRIFT_THRESHOLD
55
+ return since.tool_calls.named("Bash|Grep|Glob|WebSearch|WebFetch|LSP|Skill").count() >= TASK_DRIFT_THRESHOLD
58
56
 
59
57
 
60
58
  gate(
@@ -35,23 +35,13 @@ from filelock import FileLock
35
35
  from captain_hook import state
36
36
  from captain_hook.util import http
37
37
 
38
- PACKS_TOML = "packs.toml"
39
38
  PACK_MANIFEST = "capt-hook.toml"
40
- # A pack's manifest may sit in .claude/ (preferred) or at the repo root.
41
- MANIFEST_MEMBERS = (f".claude/{PACK_MANIFEST}", PACK_MANIFEST)
42
39
  SHA_MARKER = ".sha"
43
- META_SUFFIX = ".meta"
44
- FASTPATH_NAME = ".resolve-fastpath"
45
40
  LATEST_REF = "latest"
46
41
  # Moving refs (@latest / a branch / a bare default-branch source) re-resolve to a fresh
47
42
  # commit at most once per this window; within it the cached commit is used with no network.
48
43
  REFRESH_TTL_SECONDS = 24 * 60 * 60
49
- SOURCE_RE = re.compile(r"^github:(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+?)(?:@(?P<ref>[\w./-]+))?$")
50
44
  PACK_NAME_RE = re.compile(r"[a-z][a-z0-9-]*")
51
- GITHUB_REPO = "https://api.github.com/repos/{owner}/{repo}"
52
- GITHUB_COMMIT = "https://api.github.com/repos/{owner}/{repo}/commits/{ref}"
53
- GITHUB_LATEST_RELEASE = "https://api.github.com/repos/{owner}/{repo}/releases/latest"
54
- GITHUB_TARBALL = "https://github.com/{owner}/{repo}/archive/{sha}.tar.gz"
55
45
 
56
46
 
57
47
  class PackError(Exception):
@@ -66,7 +56,7 @@ class PackSource:
66
56
 
67
57
  @classmethod
68
58
  def parse(cls, raw: str) -> PackSource:
69
- if not (m := SOURCE_RE.match(raw)):
59
+ if not (m := re.match(r"^github:(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+?)(?:@(?P<ref>[\w./-]+))?$", raw)):
70
60
  raise PackError(f"invalid pack source {raw!r}; expected github:owner/repo[@ref]")
71
61
  return cls(owner=m["owner"], repo=m["repo"], ref=m["ref"])
72
62
 
@@ -147,7 +137,7 @@ class PackMeta:
147
137
 
148
138
 
149
139
  def packs_toml_path(root: Path) -> Path:
150
- return root / ".claude" / "hooks" / PACKS_TOML
140
+ return root / ".claude" / "hooks" / "packs.toml"
151
141
 
152
142
 
153
143
  def manifest_in(root: Path) -> Path:
@@ -215,24 +205,25 @@ def packs_cache_root() -> Path:
215
205
 
216
206
 
217
207
  def meta_path(name: str) -> Path:
218
- return packs_cache_root() / f"{name}{META_SUFFIX}"
208
+ return packs_cache_root() / f"{name}.meta"
219
209
 
220
210
 
221
211
  def resolve_ref(source: PackSource) -> str:
222
212
  """Resolve a source's effective git ref: @latest via the latest release, else the ref or default branch."""
223
213
  match source.ref:
224
214
  case None:
225
- return http.github_get_json(GITHUB_REPO.format(owner=source.owner, repo=source.repo))["default_branch"]
215
+ url = f"https://api.github.com/repos/{source.owner}/{source.repo}"
216
+ return http.github_get_json(url)["default_branch"]
226
217
  case ref if ref == LATEST_REF:
227
- return http.github_get_json(GITHUB_LATEST_RELEASE.format(owner=source.owner, repo=source.repo))["tag_name"]
218
+ url = f"https://api.github.com/repos/{source.owner}/{source.repo}/releases/latest"
219
+ return http.github_get_json(url)["tag_name"]
228
220
  case ref:
229
221
  return ref
230
222
 
231
223
 
232
224
  def resolve_commit(source: PackSource) -> str:
233
- return http.github_get_json(GITHUB_COMMIT.format(owner=source.owner, repo=source.repo, ref=resolve_ref(source)))[
234
- "sha"
235
- ]
225
+ url = f"https://api.github.com/repos/{source.owner}/{source.repo}/commits/{resolve_ref(source)}"
226
+ return http.github_get_json(url)["sha"]
236
227
 
237
228
 
238
229
  def strip_top_level(tf: tarfile.TarFile) -> Iterator[tarfile.TarInfo]:
@@ -266,14 +257,17 @@ def fetch_commit(source: PackSource, sha: str) -> ResolvedPack:
266
257
  root.mkdir(parents=True, exist_ok=True)
267
258
  with FileLock(str(root / f"{sha}.lock")):
268
259
  tarball = root / f".tarball-{sha}.tar.gz"
269
- http.github_download(GITHUB_TARBALL.format(owner=source.owner, repo=source.repo, sha=sha), tarball)
260
+ url = f"https://github.com/{source.owner}/{source.repo}/archive/{sha}.tar.gz"
261
+ http.github_download(url, tarball)
270
262
  staging = root / f".staging-{sha}"
271
263
  if staging.exists():
272
264
  shutil.rmtree(staging)
273
265
  with tarfile.open(tarball) as tf:
274
266
  members = list(strip_top_level(tf))
275
267
  by_path = {m.path: m for m in members}
276
- manifest_member = next((by_path[p] for p in MANIFEST_MEMBERS if p in by_path), None)
268
+ manifest_member = next(
269
+ (by_path[p] for p in (f".claude/{PACK_MANIFEST}", PACK_MANIFEST) if p in by_path), None
270
+ )
277
271
  if manifest_member is None:
278
272
  raise PackError(f"pack manifest {PACK_MANIFEST} missing in {source}")
279
273
  tf.extract(manifest_member, staging, filter="data")
@@ -414,7 +408,7 @@ def all_cached_and_fresh(entries: Sequence[PackEntry], now: float) -> bool:
414
408
 
415
409
 
416
410
  def fastpath_path(root: Path) -> Path:
417
- return packs_cache_root() / f"{sha256(str(root.resolve()).encode()).hexdigest()[:16]}{FASTPATH_NAME}"
411
+ return packs_cache_root() / f"{sha256(str(root.resolve()).encode()).hexdigest()[:16]}.resolve-fastpath"
418
412
 
419
413
 
420
414
  def fastpath_unchanged(root: Path, entries: Sequence[PackEntry], now: float) -> bool:
@@ -31,8 +31,6 @@ if TYPE_CHECKING:
31
31
  from captain_hook.review.settings import ReviewSettings
32
32
  from captain_hook.review.store import CandidateView, SpawnHealth
33
33
 
34
- BAR_FILLED = "█"
35
- BAR_EMPTY = "░"
36
34
  DETAIL_WIDTH = 80
37
35
  KIND_STYLE = {CandidateKind.CREATE: "cyan", CandidateKind.FIX: "magenta"}
38
36
  SPAWN_STALE_AFTER = timedelta(days=7)
@@ -81,7 +79,7 @@ def trim(text: str, *, width: int = DETAIL_WIDTH) -> str:
81
79
  def bar(done: int, need: int, *, width: int = 5) -> str:
82
80
  cells = min(need, width)
83
81
  filled = min(cells, round(done / need * cells)) if need else cells
84
- return BAR_FILLED * filled + BAR_EMPTY * (cells - filled)
82
+ return "█" * filled + "░" * (cells - filled)
85
83
 
86
84
 
87
85
  def targets(view: CandidateView, settings: ReviewSettings) -> tuple[tuple[str, int, int], ...]:
@@ -62,12 +62,8 @@ HOOK_COMPLAINT = SourceKind("hook_complaint")
62
62
  PROXIMITY_TURNS = 3
63
63
  TIGHT_PROXIMITY_TURNS = 1
64
64
  STOP_FEEDBACK_PREFIX = "Stop hook feedback:\n"
65
- PRIMITIVES_DIR = "captain_hook/primitives/"
66
- HOOKS_DIR = ".claude/hooks"
67
65
  PACKS_DIR = "captain_hook/packs"
68
66
 
69
- HOOK_VOCAB_RE = re.compile(r"\b(?:hook|reminder|nudge|gate|guard)s?\b", re.IGNORECASE)
70
-
71
67
  STRONG_MARKERS: tuple[tuple[str, re.Pattern[str]], ...] = tuple(
72
68
  (misfire_class, re.compile(pattern, re.IGNORECASE))
73
69
  for misfire_class, pattern in (
@@ -82,12 +78,6 @@ STRONG_MARKERS: tuple[tuple[str, re.Pattern[str]], ...] = tuple(
82
78
  )
83
79
  )
84
80
 
85
- HEDGED_MARKER_RE = re.compile(
86
- r"(?:\bi think\b|\bseems?\b|\bmay\b|\bmight\b|\blooks like\b)"
87
- r"[\s\S]{0,80}?(?:false[ -]positive|misfir|re-?fir|shouldn'?t have fired|spurious|unnecessar|wrong(?:ly)?)",
88
- re.IGNORECASE,
89
- )
90
-
91
81
  COMPLIANCE_RE = re.compile(
92
82
  r"(?:\bI'?ll\b|\blet me\b|\bgoing to\b|\bgood (?:point|catch)\b|\bnoted\b)"
93
83
  r"[\s\S]{0,40}?(?:hook|reminder|gate|nudge)",
@@ -127,9 +117,14 @@ class Fingerprint:
127
117
 
128
118
 
129
119
  def classify_marker(text: str) -> Marker | None:
130
- if not HOOK_VOCAB_RE.search(text):
120
+ if not re.search(r"\b(?:hook|reminder|nudge|gate|guard)s?\b", text, re.IGNORECASE):
131
121
  return None
132
- hedged = HEDGED_MARKER_RE.search(text)
122
+ hedged = re.search(
123
+ r"(?:\bi think\b|\bseems?\b|\bmay\b|\bmight\b|\blooks like\b)"
124
+ r"[\s\S]{0,80}?(?:false[ -]positive|misfir|re-?fir|shouldn'?t have fired|spurious|unnecessar|wrong(?:ly)?)",
125
+ text,
126
+ re.IGNORECASE,
127
+ )
133
128
  strong = next(((cls, m) for cls, rx in STRONG_MARKERS if (m := rx.search(text))), None)
134
129
  if strong is not None and hedged is None:
135
130
  return Marker("strong", strong[0], strong[1].group(0))
@@ -207,7 +202,7 @@ def attribute_fingerprint(
207
202
  def resolve_target(decision: Decision) -> tuple[str, str] | None:
208
203
  from captain_hook.packs.manager import builtin_packs
209
204
 
210
- if PRIMITIVES_DIR not in decision.source_file:
205
+ if "captain_hook/primitives/" not in decision.source_file:
211
206
  return decision.source_file, decision.kind
212
207
  module, sep, _ = decision.kind.partition(":")
213
208
  if not sep or not module:
@@ -216,7 +211,7 @@ def resolve_target(decision: Decision) -> tuple[str, str] | None:
216
211
  case [pack, mod] if (pack_dir := builtin_packs().get(pack)) and (pack_dir / f"{mod}.py").is_file():
217
212
  return f"{PACKS_DIR}/{pack}/{mod}.py", decision.kind
218
213
  case parts if all(part.isidentifier() for part in parts):
219
- return f"{HOOKS_DIR}/{parts[-1]}.py", decision.kind
214
+ return f".claude/hooks/{parts[-1]}.py", decision.kind
220
215
  case _:
221
216
  return None
222
217
 
@@ -27,7 +27,6 @@ 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
- WORKSTREAM_BODY_RE = re.compile(r"^(?:FIX|Tests): .+$", re.MULTILINE)
31
30
  CONDUCTOR_FINDING_FMT = RegexReviewFormat(
32
31
  name="conductor-finding",
33
32
  groups=(
@@ -70,7 +69,10 @@ def extract_conductor_workstream(text: str) -> tuple[ReviewComment, ...]:
70
69
  line_end=None,
71
70
  comment=" ".join(
72
71
  [f"{header.group('id')} [{header.group('kind')}] {header.group('title').strip()}"]
73
- + [line.group(0).strip() for line in WORKSTREAM_BODY_RE.finditer(text[header.end() : end])]
72
+ + [
73
+ line.group(0).strip()
74
+ for line in re.finditer(r"^(?:FIX|Tests): .+$", text[header.end() : end], re.MULTILINE)
75
+ ]
74
76
  ),
75
77
  )
76
78
  for header, end in zip(
@@ -47,7 +47,6 @@ TRIGGER_BUDGET = Budget(turn_chars=2000, tool_chars=6000)
47
47
  CONTEXT_BUDGET = Budget()
48
48
 
49
49
  DURABLE_CATEGORIES = frozenset({"durable_style_rule", "workflow_rule", "tooling_rule", "safety_guard"})
50
- ACCEPTED_CATEGORIES = DURABLE_CATEGORIES | {"misfire_confirmed"}
51
50
 
52
51
  Category = Literal[
53
52
  "durable_style_rule",
@@ -63,81 +62,6 @@ Category = Literal[
63
62
  "ambient_mention",
64
63
  ]
65
64
 
66
- JUDGE_PROMPT = """\
67
- You are auditing one piece of feedback a developer gave an AI coding assistant
68
- (Claude), deciding whether it is a DURABLE correction worth encoding as an
69
- automated hook — a rule that should fire in every future session of this
70
- repository — or feedback that only mattered in the moment.
71
-
72
- Pick exactly one category:
73
- - durable_style_rule: a standing code-style or API-design rule ("never use a
74
- bare except", "always frozen dataclasses") that future code must follow.
75
- - workflow_rule: a standing rule about process — how to plan, commit, test,
76
- review, or communicate ("always run the tests before claiming done").
77
- - tooling_rule: a standing rule about which tool or command to use ("use uv,
78
- not pip", "search with rg, not grep").
79
- - safety_guard: a standing guard against a dangerous action ("never force-push
80
- to main", "never edit generated files").
81
- - one_off_correction: fixes the assistant's current output without stating a
82
- reusable rule ("rename this one", "the test you broke is test_foo").
83
- - task_specific: a rule scoped to the current task or file, not the repository
84
- ("for this migration keep both columns").
85
- - preference_unclear: corrective in tone, but the underlying rule cannot be
86
- stated precisely enough to automate.
87
- - ambient_noise: not corrective at all — status updates, questions, new tasks.
88
-
89
- The first four categories are durable; the rest are not. A durable correction
90
- states (or clearly implies) a rule that would be violated again and could be
91
- checked mechanically. Words like "always", "never", or "stop doing X" are
92
- strong durability signals; a rule that names one specific line, variable, or
93
- test is task-scoped, not durable.
94
-
95
- summary: ONE neutral sentence naming the rule the feedback implies (or what the
96
- user reacted to when there is no rule). Write it for every category.
97
- confidence: your probability (0 to 1) that your durable-vs-not call is correct.
98
- rationale: one short clause.
99
-
100
- Respond with strict JSON matching the schema — no extra keys, no prose.
101
-
102
- [source: {source_kind}]
103
- {context}
104
- === FEEDBACK TO CLASSIFY ===
105
- {text}"""
106
-
107
- FIX_JUDGE_PROMPT = """\
108
- You are auditing one remark an AI coding assistant (Claude) made about an
109
- automated hook that fired during its session, deciding whether the remark
110
- REPORTS A MISFIRE — the hook firing wrongly or redundantly — or something else.
111
-
112
- Pick exactly one category:
113
- - misfire_confirmed: the remark asserts the hook fired wrongly — it re-fired on
114
- content already addressed, flagged a false positive, or fired outside its
115
- intended scope — and the surrounding conversation is consistent with that
116
- claim.
117
- - compliance: the remark acknowledges the hook's message and follows it (or
118
- promises to follow it going forward).
119
- - ambient_mention: the hook is merely described, quoted, or referenced in
120
- passing, with no claim that it fired wrongly.
121
-
122
- Only misfire_confirmed marks the hook as worth amending. A remark that both
123
- complies and dismisses ("noted, but this re-fired on text I already fixed") is
124
- misfire_confirmed — the dismissal is the signal. A remark that merely reports
125
- the hook fired, or works around it without disputing it, is not.
126
-
127
- summary: ONE neutral sentence naming what the hook did and what Claude claims
128
- about it. Write it for every category.
129
- confidence: your probability (0 to 1) that your misfire-vs-not call is correct.
130
- rationale: one short clause.
131
-
132
- Respond with strict JSON matching the schema — no extra keys, no prose.
133
-
134
- [hook: {hook_name} ({event}/{action})]
135
- === the hook's fire message ===
136
- {fire_message}
137
- {context}
138
- === REMARK TO CLASSIFY ===
139
- {text}"""
140
-
141
65
 
142
66
  class ReviewVerdict(BaseModel):
143
67
  """One judge verdict on a stored correction.
@@ -157,7 +81,7 @@ class ReviewVerdict(BaseModel):
157
81
  @property
158
82
  def accepted(self) -> bool:
159
83
  """Whether the category marks a durable correction or a confirmed misfire."""
160
- return self.category in ACCEPTED_CATEGORIES
84
+ return self.category in DURABLE_CATEGORIES | {"misfire_confirmed"}
161
85
 
162
86
 
163
87
  @dataclass(frozen=True, slots=True)
@@ -208,19 +132,83 @@ async def render_context(window: ContextWindow) -> tuple[str, Fidelity]:
208
132
 
209
133
 
210
134
  def build_create_prompt(row: Mapping[str, object], context: str) -> str:
211
- return JUDGE_PROMPT.format(source_kind=row["source_kind"], context=context, text=row["text"])
135
+ return f"""\
136
+ You are auditing one piece of feedback a developer gave an AI coding assistant
137
+ (Claude), deciding whether it is a DURABLE correction worth encoding as an
138
+ automated hook — a rule that should fire in every future session of this
139
+ repository — or feedback that only mattered in the moment.
140
+
141
+ Pick exactly one category:
142
+ - durable_style_rule: a standing code-style or API-design rule ("never use a
143
+ bare except", "always frozen dataclasses") that future code must follow.
144
+ - workflow_rule: a standing rule about process — how to plan, commit, test,
145
+ review, or communicate ("always run the tests before claiming done").
146
+ - tooling_rule: a standing rule about which tool or command to use ("use uv,
147
+ not pip", "search with rg, not grep").
148
+ - safety_guard: a standing guard against a dangerous action ("never force-push
149
+ to main", "never edit generated files").
150
+ - one_off_correction: fixes the assistant's current output without stating a
151
+ reusable rule ("rename this one", "the test you broke is test_foo").
152
+ - task_specific: a rule scoped to the current task or file, not the repository
153
+ ("for this migration keep both columns").
154
+ - preference_unclear: corrective in tone, but the underlying rule cannot be
155
+ stated precisely enough to automate.
156
+ - ambient_noise: not corrective at all — status updates, questions, new tasks.
157
+
158
+ The first four categories are durable; the rest are not. A durable correction
159
+ states (or clearly implies) a rule that would be violated again and could be
160
+ checked mechanically. Words like "always", "never", or "stop doing X" are
161
+ strong durability signals; a rule that names one specific line, variable, or
162
+ test is task-scoped, not durable.
163
+
164
+ summary: ONE neutral sentence naming the rule the feedback implies (or what the
165
+ user reacted to when there is no rule). Write it for every category.
166
+ confidence: your probability (0 to 1) that your durable-vs-not call is correct.
167
+ rationale: one short clause.
168
+
169
+ Respond with strict JSON matching the schema — no extra keys, no prose.
170
+
171
+ [source: {row["source_kind"]}]
172
+ {context}
173
+ === FEEDBACK TO CLASSIFY ===
174
+ {row["text"]}"""
212
175
 
213
176
 
214
177
  def build_fix_prompt(row: Mapping[str, object], context: str) -> str:
215
178
  payload: dict[str, object] = json.loads(str(row["payload_json"]))
216
- return FIX_JUDGE_PROMPT.format(
217
- hook_name=payload["target_hook_name"],
218
- event=payload["event"],
219
- action=payload["action"],
220
- fire_message=payload["fire_message"],
221
- context=context,
222
- text=row["text"],
223
- )
179
+ return f"""\
180
+ You are auditing one remark an AI coding assistant (Claude) made about an
181
+ automated hook that fired during its session, deciding whether the remark
182
+ REPORTS A MISFIRE — the hook firing wrongly or redundantly — or something else.
183
+
184
+ Pick exactly one category:
185
+ - misfire_confirmed: the remark asserts the hook fired wrongly — it re-fired on
186
+ content already addressed, flagged a false positive, or fired outside its
187
+ intended scope — and the surrounding conversation is consistent with that
188
+ claim.
189
+ - compliance: the remark acknowledges the hook's message and follows it (or
190
+ promises to follow it going forward).
191
+ - ambient_mention: the hook is merely described, quoted, or referenced in
192
+ passing, with no claim that it fired wrongly.
193
+
194
+ Only misfire_confirmed marks the hook as worth amending. A remark that both
195
+ complies and dismisses ("noted, but this re-fired on text I already fixed") is
196
+ misfire_confirmed — the dismissal is the signal. A remark that merely reports
197
+ the hook fired, or works around it without disputing it, is not.
198
+
199
+ summary: ONE neutral sentence naming what the hook did and what Claude claims
200
+ about it. Write it for every category.
201
+ confidence: your probability (0 to 1) that your misfire-vs-not call is correct.
202
+ rationale: one short clause.
203
+
204
+ Respond with strict JSON matching the schema — no extra keys, no prose.
205
+
206
+ [hook: {payload["target_hook_name"]} ({payload["event"]}/{payload["action"]})]
207
+ === the hook's fire message ===
208
+ {payload["fire_message"]}
209
+ {context}
210
+ === REMARK TO CLASSIFY ===
211
+ {row["text"]}"""
224
212
 
225
213
 
226
214
  async def build_prompt(row: Mapping[str, object]) -> tuple[str, Fidelity]: