capt-hook 12.2.0__tar.gz → 12.4.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 (180) hide show
  1. {capt_hook-12.2.0 → capt_hook-12.4.0}/PKG-INFO +1 -1
  2. {capt_hook-12.2.0 → capt_hook-12.4.0}/capt_hook_client/client.py +0 -1
  3. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/.claude-plugin/plugin.json +1 -1
  4. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/__init__.py +4 -0
  5. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/__init__.pyi +4 -0
  6. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/fixes/hooks/scratch_writes.py +1 -1
  7. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/tombstones.py +1 -1
  8. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/conditions.py +21 -4
  9. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/context.py +21 -3
  10. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/protocol.py +44 -21
  11. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/rewrite.py +1 -1
  12. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/signals/nlp.py +44 -23
  13. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +4 -3
  14. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +1 -1
  15. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/scanning-sessions/SKILL.md +1 -1
  16. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +49 -36
  17. capt_hook-12.4.0/captain_hook/turn.py +32 -0
  18. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/types.py +30 -1
  19. {capt_hook-12.2.0 → capt_hook-12.4.0}/pyproject.toml +1 -1
  20. {capt_hook-12.2.0 → capt_hook-12.4.0}/.gitignore +0 -0
  21. {capt_hook-12.2.0 → capt_hook-12.4.0}/LICENSE +0 -0
  22. {capt_hook-12.2.0 → capt_hook-12.4.0}/README.md +0 -0
  23. {capt_hook-12.2.0 → capt_hook-12.4.0}/capt_hook_client/__init__.py +0 -0
  24. {capt_hook-12.2.0 → capt_hook-12.4.0}/capt_hook_client/__main__.py +0 -0
  25. {capt_hook-12.2.0 → capt_hook-12.4.0}/capt_hook_client/key.py +0 -0
  26. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/__main__.py +0 -0
  27. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/app.py +0 -0
  28. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/ast_grep.py +0 -0
  29. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/__init__.py +0 -0
  30. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/fixes/__init__.py +0 -0
  31. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/fixes/hooks/__init__.py +0 -0
  32. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/fixes/hooks/_lib.py +0 -0
  33. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/fixes/hooks/teammate_permissions.py +0 -0
  34. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/__init__.py +0 -0
  35. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/__init__.py +0 -0
  36. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/_lib.py +0 -0
  37. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/commands.py +0 -0
  38. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/comments.py +0 -0
  39. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/deletions.py +0 -0
  40. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/detours.py +0 -0
  41. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/docs.py +0 -0
  42. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/models.py +0 -0
  43. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/plans.py +0 -0
  44. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/fragments/deliverable_rubric.md +0 -0
  45. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/fragments/workflow_script_header.md +0 -0
  46. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/browser_delegation_nudge.md +0 -0
  47. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/implementation_spawn_nudge.md +0 -0
  48. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/inline_edit_nudge.md +0 -0
  49. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/prose_spawn_gate.md +0 -0
  50. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/prose_workflow_nudge.md +0 -0
  51. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/review_routing_spawn_nudge.md +0 -0
  52. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/review_routing_workflow_nudge.md +0 -0
  53. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/writing_docs_spawn_nudge.md +0 -0
  54. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/models/writing_docs_workflow_nudge.md +0 -0
  55. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts/tools/preload_nudge.md +0 -0
  56. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/prompts.py +0 -0
  57. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/review.py +0 -0
  58. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/tasks.py +0 -0
  59. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/hooks/tools.py +0 -0
  60. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/general/pack.toml +0 -0
  61. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/go/__init__.py +0 -0
  62. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/go/hooks/__init__.py +0 -0
  63. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/go/hooks/testing.py +0 -0
  64. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/go/hooks/toolchain.py +0 -0
  65. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/performance/__init__.py +0 -0
  66. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/performance/hooks/__init__.py +0 -0
  67. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/performance/hooks/pipelining.py +0 -0
  68. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/python/__init__.py +0 -0
  69. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/python/hooks/__init__.py +0 -0
  70. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/python/hooks/style.py +0 -0
  71. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/python/hooks/testing.py +0 -0
  72. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/python/hooks/toolchain.py +0 -0
  73. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/steering/__init__.py +0 -0
  74. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/steering/hooks/__init__.py +0 -0
  75. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/steering/hooks/steering.py +0 -0
  76. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/steering/hooks/teammates.py +0 -0
  77. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/steering/hooks/workarounds.py +0 -0
  78. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/builtin_packs/steering/pack.toml +0 -0
  79. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/classifiers/__init__.py +0 -0
  80. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/classifiers/conductor.py +0 -0
  81. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/classifiers/droid.py +0 -0
  82. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/classifiers/native.py +0 -0
  83. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/cli.py +0 -0
  84. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/cmd.py +0 -0
  85. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/contexts.py +0 -0
  86. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/__init__.py +0 -0
  87. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/context.py +0 -0
  88. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/decision_writer.py +0 -0
  89. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/lifecycle.py +0 -0
  90. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/logsink.py +0 -0
  91. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/ops.py +0 -0
  92. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/registry.py +0 -0
  93. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/server.py +0 -0
  94. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/daemon/transcache.py +0 -0
  95. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/decisions.py +0 -0
  96. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/dispatch.py +0 -0
  97. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/doc_conventions.py +0 -0
  98. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/durable.py +0 -0
  99. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/events.py +0 -0
  100. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/fields.py +0 -0
  101. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/file.py +0 -0
  102. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/heartbeat.py +0 -0
  103. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/helper/__init__.py +0 -0
  104. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/helper/cli.py +0 -0
  105. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/helper/client.py +0 -0
  106. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/hooks/hooks.json +0 -0
  107. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/loader.py +0 -0
  108. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/log.py +0 -0
  109. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/packs/__init__.py +0 -0
  110. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/packs/manager.py +0 -0
  111. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/packs/plugins.py +0 -0
  112. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/__init__.py +0 -0
  113. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/commands.py +0 -0
  114. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/lint.py +0 -0
  115. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/llm.py +0 -0
  116. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/nudge.py +0 -0
  117. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/permissions.py +0 -0
  118. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/provision.py +0 -0
  119. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/primitives/workflow.py +0 -0
  120. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/prompt.py +0 -0
  121. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/py.typed +0 -0
  122. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/__init__.py +0 -0
  123. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/announce.py +0 -0
  124. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/cli.py +0 -0
  125. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/dashboard.py +0 -0
  126. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/fix.py +0 -0
  127. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/formats.py +0 -0
  128. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/judge.py +0 -0
  129. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/notify.py +0 -0
  130. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/pipeline.py +0 -0
  131. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/prompts.py +0 -0
  132. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/repo.py +0 -0
  133. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/routing.py +0 -0
  134. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/scan.py +0 -0
  135. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/settings.py +0 -0
  136. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/snapshot.py +0 -0
  137. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/status.py +0 -0
  138. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/store.py +0 -0
  139. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/sync.py +0 -0
  140. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/review/triage.py +0 -0
  141. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/session.py +0 -0
  142. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/settings.py +0 -0
  143. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/signals/__init__.py +0 -0
  144. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  145. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  146. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  147. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  148. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  149. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  150. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  151. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  152. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  153. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/state.py +0 -0
  154. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/style/__init__.py +0 -0
  155. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/style/ast_grep.py +0 -0
  156. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/style/matchers.py +0 -0
  157. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/style/scope.py +0 -0
  158. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/style/types.py +0 -0
  159. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/tasks.py +0 -0
  160. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  161. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/testing/__init__.py +0 -0
  162. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/testing/helpers.py +0 -0
  163. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/testing/session_cache.py +0 -0
  164. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/testing/types.py +0 -0
  165. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/transcripts.py +0 -0
  166. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/__init__.py +0 -0
  167. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/automode.py +0 -0
  168. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/caching.py +0 -0
  169. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/fs.py +0 -0
  170. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/globbing.py +0 -0
  171. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/http.py +0 -0
  172. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/model_cache.py +0 -0
  173. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/paths.py +0 -0
  174. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/proc.py +0 -0
  175. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/reqenv.py +0 -0
  176. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/scratch.py +0 -0
  177. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/shell.py +0 -0
  178. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/text.py +0 -0
  179. {capt_hook-12.2.0 → capt_hook-12.4.0}/captain_hook/util/vcs.py +0 -0
  180. {capt_hook-12.2.0 → capt_hook-12.4.0}/hatch_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 12.2.0
3
+ Version: 12.4.0
4
4
  Summary: Stop repeating yourself to Claude.
5
5
  Project-URL: Homepage, https://github.com/yasyf/captain-hook
6
6
  Project-URL: Documentation, https://yasyf.github.io/captain-hook/
@@ -140,7 +140,6 @@ def build_request(event: str, root: str, payload_raw: str, *, async_: bool) -> d
140
140
  "async": async_,
141
141
  "root": root,
142
142
  "cwd": os.getcwd(),
143
- "hooks": None,
144
143
  "env": key.request_env(),
145
144
  "payload_raw": payload_raw,
146
145
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "captain-hook",
3
- "version": "12.1.0",
3
+ "version": "12.3.0",
4
4
  "displayName": "Captain Hook",
5
5
  "description": "Skills for the capt-hook declarative hook framework: bootstrap gates and nudges from a repo's conventions, and translate style guides into enforced StyleRules.",
6
6
  "author": {
@@ -46,6 +46,7 @@ EXPORTS: dict[str, str] = {
46
46
  "Redirects": "captain_hook.conditions",
47
47
  "RewritingExistingPlan": "captain_hook.conditions",
48
48
  "ScratchPath": "captain_hook.conditions",
49
+ "UserSaid": "captain_hook.conditions",
49
50
  "workflow_opt_matches": "captain_hook.conditions",
50
51
  "workflow_opt_values": "captain_hook.conditions",
51
52
  "workflow_script_source": "captain_hook.conditions",
@@ -122,6 +123,7 @@ EXPORTS: dict[str, str] = {
122
123
  "Phrase": "captain_hook.signals.nlp",
123
124
  "has_nominal_subject": "captain_hook.signals.nlp",
124
125
  "is_past_predicate": "captain_hook.signals.nlp",
126
+ "subject_kind": "captain_hook.signals.nlp",
125
127
  "HookState": "captain_hook.state",
126
128
  "PrimitiveState": "captain_hook.state",
127
129
  "WorkflowState": "captain_hook.state",
@@ -129,6 +131,7 @@ EXPORTS: dict[str, str] = {
129
131
  "Task": "captain_hook.tasks",
130
132
  "Tasks": "captain_hook.tasks",
131
133
  "Allow": "captain_hook.testing.types",
134
+ "Turn": "captain_hook.turn",
132
135
  "Ask": "captain_hook.testing.types",
133
136
  "Block": "captain_hook.testing.types",
134
137
  "FileFixture": "captain_hook.testing.types",
@@ -167,6 +170,7 @@ EXPORTS: dict[str, str] = {
167
170
  "ToolInput": "captain_hook.types",
168
171
  "TouchedFile": "captain_hook.types",
169
172
  "UsedSkill": "captain_hook.types",
173
+ "UsedTool": "captain_hook.types",
170
174
  "Waiting": "captain_hook.types",
171
175
  "WorkflowScript": "captain_hook.types",
172
176
  "binary_supports": "captain_hook.util.fs",
@@ -42,6 +42,7 @@ from captain_hook.conditions import Headless as Headless
42
42
  from captain_hook.conditions import Redirects as Redirects
43
43
  from captain_hook.conditions import RewritingExistingPlan as RewritingExistingPlan
44
44
  from captain_hook.conditions import ScratchPath as ScratchPath
45
+ from captain_hook.conditions import UserSaid as UserSaid
45
46
  from captain_hook.conditions import workflow_opt_matches as workflow_opt_matches
46
47
  from captain_hook.conditions import workflow_opt_values as workflow_opt_values
47
48
  from captain_hook.conditions import workflow_script_source as workflow_script_source
@@ -118,6 +119,7 @@ from captain_hook.signals.nlp import NlpSignal as NlpSignal
118
119
  from captain_hook.signals.nlp import Phrase as Phrase
119
120
  from captain_hook.signals.nlp import has_nominal_subject as has_nominal_subject
120
121
  from captain_hook.signals.nlp import is_past_predicate as is_past_predicate
122
+ from captain_hook.signals.nlp import subject_kind as subject_kind
121
123
  from captain_hook.state import HookState as HookState
122
124
  from captain_hook.state import PrimitiveState as PrimitiveState
123
125
  from captain_hook.state import WorkflowState as WorkflowState
@@ -133,6 +135,7 @@ from captain_hook.testing.types import Input as Input
133
135
  from captain_hook.testing.types import Rewrite as Rewrite
134
136
  from captain_hook.testing.types import TranscriptFixture as TranscriptFixture
135
137
  from captain_hook.testing.types import Warn as Warn
138
+ from captain_hook.turn import Turn as Turn
136
139
  from captain_hook.types import Action as Action
137
140
  from captain_hook.types import Agent as Agent
138
141
  from captain_hook.types import And as And
@@ -163,6 +166,7 @@ from captain_hook.types import Tool as Tool
163
166
  from captain_hook.types import ToolInput as ToolInput
164
167
  from captain_hook.types import TouchedFile as TouchedFile
165
168
  from captain_hook.types import UsedSkill as UsedSkill
169
+ from captain_hook.types import UsedTool as UsedTool
166
170
  from captain_hook.types import Waiting as Waiting
167
171
  from captain_hook.types import WorkflowScript as WorkflowScript
168
172
  from captain_hook.util.fs import binary_supports as binary_supports
@@ -15,7 +15,7 @@ from captain_hook.builtin_packs.fixes.hooks._lib import McpTool
15
15
 
16
16
  approve(
17
17
  "scratch-dir writes under skip-permissions",
18
- only_if=[Tool("Edit|Write|MultiEdit|NotebookEdit"), ScratchPath(), SkipPermissions()],
18
+ only_if=[Tool.EditTools, ScratchPath(), SkipPermissions()],
19
19
  skip_if=[McpTool()],
20
20
  tests={
21
21
  Input(file="/tmp/sweep_arc.py", content="print(1)", skip_permissions=True): Allow(explicit=True),
@@ -82,7 +82,7 @@ def is_tombstone(text: str) -> bool:
82
82
  "deprecate",
83
83
  ),
84
84
  tense="completed",
85
- subject="no_nominal",
85
+ subject=("unnamed", "passive"),
86
86
  ),
87
87
  Clause(
88
88
  verb=Phrase("be"), adj=Phrase("previously", "formerly", "originally", "here"), tense="completed"
@@ -33,6 +33,7 @@ from captain_hook.types import (
33
33
  ToolInput,
34
34
  TouchedFile,
35
35
  UsedSkill,
36
+ UsedTool,
36
37
  Waiting,
37
38
  WorkflowScript,
38
39
  )
@@ -44,6 +45,7 @@ if TYPE_CHECKING:
44
45
  from cc_transcript.query import Session
45
46
 
46
47
  from captain_hook.events import BaseHookEvent
48
+ from captain_hook.signals.nlp import Clause
47
49
  from captain_hook.types import HookSpec
48
50
 
49
51
  # Prose and config file extensions that shouldn't, on their own, demand a code-review pass.
@@ -183,13 +185,24 @@ def is_project_file(evt: BaseHookEvent) -> bool:
183
185
 
184
186
 
185
187
  class UserSaid(CustomCondition):
186
- """Matches when the user's messages contain any of the given keywords."""
188
+ """Matches when any user prompt in the session matches one of ``patterns``.
187
189
 
188
- def __init__(self, *keywords: str) -> None:
189
- self.keywords = keywords
190
+ A string pattern is a case-insensitive regex, so plain keywords keep their
191
+ substring behavior; a :class:`~captain_hook.Clause` runs the dependency-clause
192
+ scan against each prompt. To match only the current turn's prompt, use
193
+ ``evt.ctx.turn.matches`` instead.
194
+
195
+ Example:
196
+ >>> UserSaid("just commit", Clause(noun=Phrase("test"), verb=Phrase("skip")))
197
+ """
198
+
199
+ def __init__(self, *patterns: str | Clause) -> None:
200
+ self.patterns = patterns
190
201
 
191
202
  def check(self, evt: BaseHookEvent) -> bool:
192
- return evt.ctx.t.user_said(*self.keywords)
203
+ from captain_hook.signals.nlp import scan_text
204
+
205
+ return any(scan_text(turn.prompt, self.patterns) for turn in evt.ctx.t.turns if turn.prompt)
193
206
 
194
207
 
195
208
  class AllEditsUnder(CustomCondition):
@@ -393,6 +406,10 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
393
406
  )
394
407
  case UsedSkill(names, subagents):
395
408
  return has_used_skill(evt.ctx.transcript, names, subagents=subagents)
409
+ case UsedTool(names, subagents, scope):
410
+ return (evt.ctx.turn if scope == "turn" else evt.ctx.transcript).has_tool(
411
+ "|".join(names), subagents=subagents
412
+ )
396
413
  case ReadFile(patterns, subagents):
397
414
  return has_read_glob(evt.ctx.transcript, *patterns, subagents=subagents)
398
415
  case TouchedFile(patterns, subagents):
@@ -17,6 +17,8 @@ if TYPE_CHECKING:
17
17
  from spawnllm import TModel, TSpecialty
18
18
 
19
19
  from captain_hook.settings import HooksSettings
20
+ from captain_hook.signals.nlp import Clause
21
+ from captain_hook.turn import Turn
20
22
 
21
23
 
22
24
  RECENT_WINDOW = 15
@@ -70,15 +72,31 @@ class HookContext:
70
72
  return self.conf
71
73
 
72
74
  @cached_property
73
- def turn(self) -> Session:
74
- """The one-turn view of the current turn (cached)."""
75
- return self.transcript.current_turn
75
+ def turn(self) -> Turn:
76
+ """The one-turn view of the current turn (cached), with prompt matching via ``matches``."""
77
+ from captain_hook.turn import Turn
78
+
79
+ return Turn((current := self.transcript.current_turn).turns, current.path)
76
80
 
77
81
  @cached_property
78
82
  def prior(self) -> Session:
79
83
  """The session window before the current turn's last exchange (cached)."""
80
84
  return self.transcript.prior()
81
85
 
86
+ def nlp(self, text: str, *patterns: str | Clause) -> bool:
87
+ """Whether ``text`` matches any pattern — the escape hatch for matching arbitrary prose.
88
+
89
+ A string pattern is a case-insensitive regex; a :class:`~captain_hook.Clause`
90
+ runs the dependency-clause scan. For the current turn's prompt, prefer
91
+ ``evt.ctx.turn.matches(*patterns)``.
92
+
93
+ Example:
94
+ >>> evt.ctx.nlp(evt.ctx.t.assistant_text(), Clause(noun=Phrase("test"), verb=Phrase("skip")))
95
+ """
96
+ from captain_hook.signals.nlp import scan_text
97
+
98
+ return scan_text(text, patterns)
99
+
82
100
  def transcript_text(self, *, window: int | None = None) -> str:
83
101
  """The transcript rendered turn by turn under the default budget.
84
102
 
@@ -104,7 +104,6 @@ class Request:
104
104
  async_: bool = False
105
105
  root: str | None = None
106
106
  cwd: str | None = None
107
- hooks: str | None = None
108
107
  env: dict[str, str] = field(default_factory=dict)
109
108
  payload_raw: str = ""
110
109
 
@@ -132,26 +131,50 @@ def decode_request(line: bytes) -> Request:
132
131
  data = json.loads(line)
133
132
  except (ValueError, UnicodeDecodeError) as exc:
134
133
  raise ProtocolError(f"malformed request JSON: {exc}") from exc
135
- match data:
136
- case {
137
- "v": int() as v,
138
- "kind": str() as kind,
139
- "client": {"version": str() as cv, "build": str() as cb, "pid": int() as cpid, "ppid": int() as cppid},
140
- } if kind in REQUEST_KINDS:
141
- return Request(
142
- v=v,
143
- kind=kind,
144
- client=ClientInfo(version=cv, build=cb, pid=cpid, ppid=cppid),
145
- event=data.get("event"),
146
- async_=bool(data.get("async", False)),
147
- root=data.get("root"),
148
- cwd=data.get("cwd"),
149
- hooks=data.get("hooks"),
150
- env=data.get("env") or {},
151
- payload_raw=data.get("payload_raw", ""),
152
- )
153
- case _:
154
- raise ProtocolError(f"invalid request shape: {data!r}")
134
+ if type(data) is not dict:
135
+ raise ProtocolError(f"invalid request shape: {data!r}")
136
+ kind = data.get("kind")
137
+ keys = {"v", "kind", "client"}
138
+ if kind == "event":
139
+ keys |= {"event", "async", "root", "cwd", "env", "payload_raw"}
140
+ if kind not in REQUEST_KINDS or set(data) != keys:
141
+ raise ProtocolError(f"invalid request shape: {data!r}")
142
+ client = data["client"]
143
+ if type(client) is not dict or set(client) != {"version", "build", "pid", "ppid"}:
144
+ raise ProtocolError(f"invalid request shape: {data!r}")
145
+ if not (
146
+ type(data["v"]) is int
147
+ and type(client["version"]) is str
148
+ and type(client["build"]) is str
149
+ and type(client["pid"]) is int
150
+ and type(client["ppid"]) is int
151
+ ):
152
+ raise ProtocolError(f"invalid request shape: {data!r}")
153
+ info = ClientInfo(version=client["version"], build=client["build"], pid=client["pid"], ppid=client["ppid"])
154
+ if kind != "event":
155
+ return Request(v=data["v"], kind=kind, client=info)
156
+ env = data["env"]
157
+ if not (
158
+ type(data["event"]) is str
159
+ and type(data["async"]) is bool
160
+ and type(data["root"]) is str
161
+ and type(data["cwd"]) is str
162
+ and type(env) is dict
163
+ and all(type(key) is str and type(value) is str for key, value in env.items())
164
+ and type(data["payload_raw"]) is str
165
+ ):
166
+ raise ProtocolError(f"invalid request shape: {data!r}")
167
+ return Request(
168
+ v=data["v"],
169
+ kind=kind,
170
+ client=info,
171
+ event=data["event"],
172
+ async_=data["async"],
173
+ root=data["root"],
174
+ cwd=data["cwd"],
175
+ env=env,
176
+ payload_raw=data["payload_raw"],
177
+ )
155
178
 
156
179
 
157
180
  def encode_response(response: Response) -> bytes:
@@ -65,7 +65,7 @@ def rewrite_code(
65
65
  handler.__name__ = handler.__qualname__ = hook_name("rewrite_code", None, f"{pattern}=>{replace}")
66
66
  on(
67
67
  Event.PreToolUse,
68
- only_if=[Tool("Edit|Write|MultiEdit|NotebookEdit"), *only_if],
68
+ only_if=[Tool.EditTools, *only_if],
69
69
  skip_if=skip_if,
70
70
  tests=tests,
71
71
  )(handler)
@@ -1,13 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import functools
4
+ import re
4
5
  from collections.abc import Sequence
5
6
  from dataclasses import dataclass
6
- from typing import TYPE_CHECKING, Literal
7
+ from typing import TYPE_CHECKING, Literal, get_args
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  from spacy.tokens import Doc, Span, Token
10
11
 
12
+ SubjectKind = Literal["unnamed", "passive", "actor"]
13
+
11
14
 
12
15
  @dataclass(frozen=True, slots=True)
13
16
  class PhraseFields:
@@ -96,18 +99,21 @@ class Clause:
96
99
  are *not* past predicates and *not* counterfactual modal-perfects —
97
100
  "will leave"/"leaving"/"leave it"/"will have left" but not
98
101
  "left the workspace", "left to clean up", or "should have left".
99
- subject: ``"no_nominal"`` vetoes verbs with both a substantive active
100
- subject (see ``has_nominal_subject``) and a direct object — "the
101
- parser removed the node" is vetoed while "we removed it", passives,
102
- and objectless elliptical passives ("config moved to settings.py",
103
- "logic migrated to the worker") still match. ``"none"`` vetoes any
104
- verb with a substantive active subject, transitive or not
105
- imperatives and pronoun subjects ("switch back to plan mode",
106
- "we should replan") still match while described behavior
107
- ("the daemon switches to degraded mode") does not.
102
+ subject: The subject shapes the verb may have (see ``subject_kind``), as
103
+ a tuple ``()`` (default) applies no constraint, and a bare string
104
+ means that one shape. ``"unnamed"``: no substantive active subject
105
+ imperatives ("switch back to plan mode"), pronoun subjects
106
+ ("we should replan"), and true passives ("the file was removed").
107
+ ``"passive"``: a substantive subject but no direct object
108
+ elliptical passives ("config moved to settings.py") and intransitive
109
+ actives ("the parser crashed"). ``"actor"``: a substantive subject
110
+ acting on a direct object — described behavior like "the parser
111
+ removed the node". ``("unnamed",)`` matches directives but not
112
+ descriptions; ``("unnamed", "passive")`` also admits statements
113
+ about the thing acted on.
108
114
 
109
115
  Example:
110
- >>> Clause(verb=Phrase("remove", "delete"), tense="completed", subject="no_nominal")
116
+ >>> Clause(verb=Phrase("remove", "delete"), tense="completed", subject=("unnamed", "passive"))
111
117
  """
112
118
 
113
119
  noun: Phrase | None = None
@@ -115,19 +121,23 @@ class Clause:
115
121
  adj: Phrase | None = None
116
122
  negated: bool = False
117
123
  tense: Literal["any", "completed", "prospective"] = "any"
118
- subject: Literal["any", "no_nominal", "none"] = "any"
124
+ subject: SubjectKind | tuple[SubjectKind, ...] = ()
119
125
 
120
126
  def __post_init__(self) -> None:
127
+ if isinstance(self.subject, str):
128
+ object.__setattr__(self, "subject", (self.subject,))
129
+ if invalid := set(self.subject) - set(get_args(SubjectKind)):
130
+ raise ValueError(f"Unknown subject kinds: {sorted(invalid)}")
121
131
  if (anchor := self.noun or self.verb) is None:
122
132
  raise ValueError("Clause needs a noun or verb anchor")
123
- if self.verb is None and (self.tense != "any" or self.subject != "any"):
133
+ if self.verb is None and (self.tense != "any" or self.subject):
124
134
  raise ValueError("Clause tense and subject constraints require a verb")
125
135
  if not (
126
136
  (self.noun and self.verb)
127
137
  or self.adj
128
138
  or self.negated
129
139
  or self.tense != "any"
130
- or self.subject != "any"
140
+ or self.subject
131
141
  or any(" " in lemma for lemma in anchor.lemmas)
132
142
  ):
133
143
  raise ValueError("Clause needs a second constraint or a compound anchor phrase")
@@ -234,14 +244,18 @@ def tense_matches(tense: Literal["any", "completed", "prospective"], tok: Token)
234
244
  return not is_past_predicate(tok) and not is_modal_perfect(tok)
235
245
 
236
246
 
237
- def subject_matches(subject: Literal["any", "no_nominal", "none"], tok: Token) -> bool:
238
- match subject:
239
- case "any":
240
- return True
241
- case "no_nominal":
242
- return not (has_nominal_subject(tok) and any(c.dep_ == "dobj" for c in tok.children))
243
- case "none":
244
- return not has_nominal_subject(tok)
247
+ def subject_kind(tok: Token) -> SubjectKind:
248
+ """The shape of ``tok``'s subject, as :class:`Clause`'s ``subject`` constraint sees it.
249
+
250
+ ``"unnamed"`` — no substantive active subject (see ``has_nominal_subject``);
251
+ ``"actor"`` — a substantive subject plus a direct object; ``"passive"`` — a
252
+ substantive subject without one.
253
+ """
254
+ if not has_nominal_subject(tok):
255
+ return "unnamed"
256
+ if any(c.dep_ == "dobj" for c in tok.children):
257
+ return "actor"
258
+ return "passive"
245
259
 
246
260
 
247
261
  def verb_candidates(clause: Clause, sent: Span) -> list[Token]:
@@ -251,7 +265,7 @@ def verb_candidates(clause: Clause, sent: Span) -> list[Token]:
251
265
  v
252
266
  for v in find_lemma_matches(clause.verb, sent, {"VERB", "AUX"})
253
267
  if tense_matches(clause.tense, v)
254
- and subject_matches(clause.subject, v)
268
+ and (not clause.subject or subject_kind(v) in clause.subject)
255
269
  and not (
256
270
  clause.tense == "prospective"
257
271
  and not clause.negated
@@ -278,3 +292,10 @@ def nlp_scan(clauses: Sequence[Clause], text: str) -> list[str]:
278
292
  if not text.strip():
279
293
  return []
280
294
  return [sent.text.strip() for sent in parse(text).sents if any(match_clause(clause, sent) for clause in clauses)]
295
+
296
+
297
+ def scan_text(text: str, patterns: Sequence[str | Clause]) -> bool:
298
+ clauses = [p for p in patterns if isinstance(p, Clause)]
299
+ return any(isinstance(p, str) and re.search(p, text, re.IGNORECASE) for p in patterns) or bool(
300
+ clauses and nlp_scan(clauses, text)
301
+ )
@@ -22,7 +22,7 @@ from captain_hook import (
22
22
  Allow, And, Agent, Ask, BaseHookEvent, Block, Event, FilePath, FromSubagent,
23
23
  HookResult, InlineTests, Input, Not, Or, Prompt, RanCommand, ReadFile, Rewrite, Runs,
24
24
  Signal, Signals, SkipPermissions, SourceEdits, TestFile, Tool, ToolInput, TouchedFile,
25
- TranscriptFixture, UsedSkill, Warn, WorkflowScript,
25
+ TranscriptFixture, UsedSkill, UsedTool, UserSaid, Warn, WorkflowScript,
26
26
  approve, block_command, deny, gate, hook, lint, llm_approve, llm_gate, llm_nudge, nudge, on,
27
27
  prompt_check, rewrite_command, set_tool_input, warn_command, workflow, Artifact, Step, text_matches,
28
28
  )
@@ -154,7 +154,7 @@ evaluated first.
154
154
  <!-- gen:conditions -->
155
155
  | Need | Use |
156
156
  |---|---|
157
- | Filter by tool name | `Tool("Bash")` or `Tool("Edit", "Write")` — exact names (not regex), aliases auto-expand (Bash=Execute, Write=Create, Agent=Task), MCP suffixes match |
157
+ | Filter by tool name | `Tool("Bash")` or `Tool("Edit", "Write")` — exact names (not regex), aliases auto-expand (Bash=Execute, Write=Create, Agent=Task), MCP suffixes match; `Tool.EditTools` = the prebuilt edit-shaped set (Edit, MultiEdit, NotebookEdit, Write) |
158
158
  | Filter by file path | `FilePath("*.py", "*.pyi")` |
159
159
  | Filter by bash command text | `CommandCondition(r"git\s+push")` (`captain_hook.types.Command`) — regex over the raw line and each parsed command |
160
160
  | Filter by file content being written | `Content(r"print\(")` (multiline regex over Edit new / Write content) |
@@ -165,6 +165,7 @@ evaluated first.
165
165
  | Event comes from a subagent/teammate | `FromSubagent()` — the payload carries an `agent_id`; matches the ask's *origin*, where `Agent` matches its *type* |
166
166
  | Session launched with bypass available | `SkipPermissions()` — walks to the nearest `claude` ancestor process and matches `--dangerously-skip-permissions` **or** `--allow-dangerously-skip-permissions`; availability counts as consent, whatever the active `permission_mode` |
167
167
  | Skill was invoked | `UsedSkill("codex")` — bare name also matches `plugin:name` |
168
+ | Tool was previously used | `UsedTool("EnterPlanMode", scope="turn")` — exact names (a `\|`-joined string works); `scope="turn"` searches only the current turn |
168
169
  | File was previously read | `ReadFile("TESTING.md")` — fnmatch globs; anchor dirs with `**/` |
169
170
  | Match only test files | `TestFile()` (`**/test_*.py`, `**/*_test.py`, `**/conftest.py`, `**/tests/**/*.py`, `**/*_test.go`, `**/*.test.*`, `**/*.spec.*`) |
170
171
  | Python source edits (skips tests by default, in-repo only) | `SourceEdits(lang="py")`; `lang` also `ts`, `go`, `rs`, ...; `project_only=False` to also match out-of-repo files |
@@ -177,7 +178,7 @@ evaluated first.
177
178
  | Custom logic | implement `CustomCondition` |
178
179
  <!-- /gen:conditions -->
179
180
 
180
- `ReadFile`/`TouchedFile`/`RanCommand`/`UsedSkill` inspect the session transcript — they are
181
+ `ReadFile`/`TouchedFile`/`RanCommand`/`UsedSkill`/`UsedTool` inspect the session transcript — they are
181
182
  how Stop gates know what already happened. Custom conditions are any object with a
182
183
  `check(self, evt: BaseHookEvent) -> bool` method (a Protocol — no inheritance needed):
183
184
 
@@ -50,7 +50,7 @@ All fields are keyword-only — `Block(pattern="...")`, never `Block("...")`.
50
50
 
51
51
  ## TranscriptFixture recipe
52
52
 
53
- Transcript-history conditions (`TouchedFile`, `RanCommand`, `ReadFile`, `UsedSkill`) read
53
+ Transcript-history conditions (`TouchedFile`, `RanCommand`, `ReadFile`, `UsedSkill`, `UsedTool`) read
54
54
  the session transcript, so their tests supply one. The message shape is Claude Code JSONL:
55
55
  `{"type": "assistant", "message": {"content": [<tool_use blocks>]}}`.
56
56
 
@@ -103,7 +103,7 @@ Review Progress:
103
103
  gression test; create-as-edit: EXTEND mode — broaden + new test pair)
104
104
  - [ ] Step 5: Verify (uvx --isolated capt-hook test green in the worktree)
105
105
  - [ ] Step 6: Slot check (review slots --repo <the Step-3 target>), then commit,
106
- push, gh pr create (verbatim evidence in the body)
106
+ push, gh pr create (Issue/Fix/Example body per pr-workflow.md)
107
107
  - [ ] Step 7: review update <ID> pr_open --pr-url <url>
108
108
  - [ ] Step 8: Final report
109
109
  ```
@@ -121,54 +121,67 @@ the amended hook and the mined rule.
121
121
  Title: `[capt-hook] <imperative rule statement>` — e.g.
122
122
  `[capt-hook] Block force-pushes to protected branches`.
123
123
 
124
- Body template:
124
+ Body template — three sections and the footer, nothing else:
125
125
 
126
- ```markdown
127
- ## Rule
126
+ ````markdown
127
+ ## Issue
128
128
 
129
- <one-sentence rule the corrections imply>
129
+ <1–2 sentences: the failure mode observed in this repo's sessions, with the
130
+ strongest correction woven in verbatim — e.g. "the agent kept committing without
131
+ running tests — corrected repeatedly ('run pytest before you commit', session
132
+ `<session_id>`, <YYYY-MM-DD>)".>
130
133
 
131
- ## Hook
134
+ ## Fix
132
135
 
133
- `.claude/hooks/<slug>.py` — <primitive> on <event>; fires on <offending shape>, stays
134
- silent on <benign neighbor>. Inline tests pass (`uvx --isolated capt-hook test`).
136
+ <1–2 sentences: `.claude/hooks/<slug>.py` — <primitive> on <event>; fires on
137
+ <offending shape>, stays silent on <benign neighbor>. Inline tests pass
138
+ (`uvx --isolated capt-hook test`).>
135
139
 
136
- ## Evidence
140
+ ## Example
137
141
 
138
- Corrections given in this repo's sessions, verbatim:
139
-
140
- - "<verbatim correction>" session `<session_id>`, <YYYY-MM-DD>
141
- - "<verbatim correction>" session `<session_id>`, <YYYY-MM-DD>
142
+ ```text
143
+ agent › <the tool call or action that used to slip through>
144
+ hook › <the hook's actual message>
145
+ agent <the corrected next step>
146
+ ```
142
147
 
143
148
  ---
144
149
  Opened by capt-hook's session reviewer (candidate #<ID>). Merging adopts the rule;
145
150
  closing rejects it and the reviewer will not re-propose this candidate.
146
- ```
147
-
148
- The Evidence section is the PR's case: every quote verbatim, each with its session id
149
- and date taken from the Step-2 verification the transcript file the quote was found
150
- in names the session (the JSONL filename stem is the session id) and the matching
151
- line's `timestamp` field gives the date.
151
+ ````
152
+
153
+ The Issue section carries the case: weave the 1–2 strongest corrections in
154
+ verbatim, each with its session id and date from the Step-2 verification (the
155
+ transcript's JSONL filename stem is the session id; the matching line's
156
+ `timestamp` field gives the date). Weaker duplicate quotes drop — one sharp quote
157
+ beats four bullets. The Example section is the sell: a short transcript vignette
158
+ of the recurring situation going better with the hook live, concrete enough that
159
+ the reviewer sees the exact moment the guard lands and what the agent does
160
+ instead. Keep the whole body under ~25 lines; anything the diff already shows
161
+ stays out of it.
152
162
 
153
163
  A fix PR adapts the template: title `[capt-hook] Fix <slug> misfiring on
154
- <misfire-class>`; the Rule section states what the hook wrongly fired on and the
155
- amendment chosen (tightened condition, re-fire guard, live state, demoted severity, or
156
- removal); the Hook section names the regression test pair (silent on the misfiring
157
- input, still firing on the genuine case); the Evidence section quotes Claude's
158
- verbatim complaints with their session ids and dates, plus the decision-ledger attribution
159
- (`target_hook_name`, the fire's event/action, and its message).
160
-
161
- A create-as-edit PR adapts it differently: title `[capt-hook] Broaden <hook-slug>:
162
- <imperative rule statement>`; the Hook section names the edited hook file
163
- (repo-relative), what broadened (condition, pattern, or carve-out), and the new test
164
- pair (fires on the newly covered shape, `Allow()` on its benign neighbor, all
165
- pre-existing tests untouched and green); Evidence is unchanged. When the edit
166
- targets a **pack** repo, the Rule section additionally carries a one-sentence
167
- universality justification why the broadening is correct for every consumer of
168
- the pack; a body you cannot write that sentence for is a PR that should not exist
169
- (route repo-local instead)and the footer notes the rule was mined from one
170
- repo's sessions and is proposed as universal, so the pack maintainer can reject on
171
- that axis alone. A repo-local edit needs neither; the repo's hooks are its own.
164
+ <misfire-class>`; Issue states what the hook wrongly fired on, quoting Claude's
165
+ verbatim complaint (with session id and date) plus the decision-ledger
166
+ attribution (`target_hook_name`, the fire's event/action, and its message); Fix
167
+ names the amendment chosen (tightened condition, re-fire guard, live state,
168
+ demoted severity, or removal) and the regression-test pair (silent on the
169
+ misfiring input, still firing on the genuine case); Example replays the false
170
+ positive going quiet — the once-misfiring input passing clean, beside the genuine
171
+ case still blocked.
172
+
173
+ A create-as-edit PR adapts it differently: title `[capt-hook] Broaden
174
+ <hook-slug>: <imperative rule statement>`; Fix names the edited hook file
175
+ (repo-relative), what broadened (condition, pattern, or carve-out), and the new
176
+ test pair (fires on the newly covered shape, `Allow()` on its benign neighbor,
177
+ all pre-existing tests untouched and green); Example shows the newly covered
178
+ shape being caught. When the edit targets a **pack** repo, Issue additionally
179
+ carries a one-sentence universality justification why the broadening is correct
180
+ for every consumer of the pack; a body you cannot write that sentence for is a PR
181
+ that should not exist (route repo-local instead) and the footer notes the rule
182
+ was mined from one repo's sessions and is proposed as universal, so the pack
183
+ maintainer can reject on that axis alone. A repo-local edit needs neither; the
184
+ repo's hooks are its own.
172
185
 
173
186
  ```bash
174
187
  gh pr create --title "<title>" --body "<body>" \
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from cc_transcript.query import Session
6
+
7
+ if TYPE_CHECKING:
8
+ from captain_hook.signals.nlp import Clause
9
+
10
+
11
+ class Turn(Session):
12
+ """The current turn as a one-turn :class:`~cc_transcript.query.Session` view.
13
+
14
+ What ``evt.ctx.turn`` returns: the full windowed transcript surface
15
+ (``user_text``, ``has_tool``, ``tool_calls``, …) plus prompt matching, so
16
+ hooks can test the turn's opening prompt without importing the NLP machinery.
17
+ """
18
+
19
+ __slots__ = ()
20
+
21
+ def matches(self, *patterns: str | Clause) -> bool:
22
+ """Whether the turn's opening prompt matches any pattern.
23
+
24
+ A string pattern is a case-insensitive regex; a
25
+ :class:`~captain_hook.Clause` runs the dependency-clause scan.
26
+
27
+ Example:
28
+ >>> evt.ctx.turn.matches(Clause(noun=Phrase("work"), verb=Phrase("stop", "halt")))
29
+ """
30
+ from captain_hook.signals.nlp import scan_text
31
+
32
+ return scan_text(self.user_text, patterns)