capt-hook 6.5.0__tar.gz → 7.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {capt_hook-6.5.0 → capt_hook-7.0.0}/PKG-INFO +2 -4
  2. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/__init__.py +10 -3
  3. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/app.py +1 -1
  4. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/cli.py +6 -1
  5. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/conditions.py +8 -2
  6. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/context.py +3 -2
  7. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/dispatch.py +15 -0
  8. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/events.py +51 -10
  9. capt_hook-7.0.0/captain_hook/packs/fixes/capt-hook.toml +4 -0
  10. capt_hook-7.0.0/captain_hook/packs/fixes/teammate_permissions.py +66 -0
  11. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/go/testing.py +6 -6
  12. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/python/testing.py +8 -6
  13. capt_hook-7.0.0/captain_hook/packs/steering/__init__.py +1 -0
  14. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/steering/steering.py +9 -1
  15. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/__init__.py +1 -0
  16. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/commands.py +2 -1
  17. capt_hook-7.0.0/captain_hook/primitives/permissions.py +162 -0
  18. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/prompt.py +15 -2
  19. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +34 -12
  20. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +7 -5
  21. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +6 -1
  22. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +2 -2
  23. capt_hook-7.0.0/captain_hook/testing/__init__.py +11 -0
  24. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/testing/helpers.py +25 -6
  25. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/testing/types.py +28 -5
  26. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/types.py +51 -8
  27. capt_hook-7.0.0/captain_hook/util/automode.py +53 -0
  28. capt_hook-7.0.0/captain_hook/util/proc.py +59 -0
  29. {capt_hook-6.5.0 → capt_hook-7.0.0}/pyproject.toml +8 -4
  30. capt_hook-6.5.0/captain_hook/command.py +0 -382
  31. capt_hook-6.5.0/captain_hook/testing/__init__.py +0 -1
  32. {capt_hook-6.5.0 → capt_hook-7.0.0}/LICENSE +0 -0
  33. {capt_hook-6.5.0 → capt_hook-7.0.0}/README.md +0 -0
  34. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  35. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/__main__.py +0 -0
  36. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/ast_grep.py +0 -0
  37. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/classifiers/__init__.py +0 -0
  38. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/classifiers/conductor.py +0 -0
  39. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/classifiers/droid.py +0 -0
  40. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/classifiers/native.py +0 -0
  41. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/contexts.py +0 -0
  42. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/decisions.py +0 -0
  43. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/durable.py +0 -0
  44. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/fields.py +0 -0
  45. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/file.py +0 -0
  46. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/loader.py +0 -0
  47. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/log.py +0 -0
  48. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/__init__.py +0 -0
  49. {capt_hook-6.5.0/captain_hook/packs/general → capt_hook-7.0.0/captain_hook/packs/fixes}/__init__.py +0 -0
  50. {capt_hook-6.5.0/captain_hook/packs/go → capt_hook-7.0.0/captain_hook/packs/general}/__init__.py +0 -0
  51. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/_lib.py +0 -0
  52. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  53. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/commands.py +0 -0
  54. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/detours.py +0 -0
  55. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/docs.py +0 -0
  56. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/models.py +0 -0
  57. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/plans.py +0 -0
  58. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/prompts.py +0 -0
  59. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/review.py +0 -0
  60. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/tasks.py +0 -0
  61. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/tombstones.py +0 -0
  62. {capt_hook-6.5.0/captain_hook/packs/python → capt_hook-7.0.0/captain_hook/packs/go}/__init__.py +0 -0
  63. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  64. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/go/toolchain.py +0 -0
  65. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/manager.py +0 -0
  66. {capt_hook-6.5.0/captain_hook/packs/steering → capt_hook-7.0.0/captain_hook/packs/python}/__init__.py +0 -0
  67. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  68. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/python/style.py +0 -0
  69. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/python/toolchain.py +0 -0
  70. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  71. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/lint.py +0 -0
  72. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/llm.py +0 -0
  73. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/nudge.py +0 -0
  74. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/rewrite.py +0 -0
  75. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/workflow.py +0 -0
  76. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/py.typed +0 -0
  77. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/__init__.py +0 -0
  78. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/cli.py +0 -0
  79. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/dashboard.py +0 -0
  80. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/fix.py +0 -0
  81. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/formats.py +0 -0
  82. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/judge.py +0 -0
  83. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/pipeline.py +0 -0
  84. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/repo.py +0 -0
  85. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/scan.py +0 -0
  86. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/settings.py +0 -0
  87. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/store.py +0 -0
  88. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/sync.py +0 -0
  89. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/session.py +0 -0
  90. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/settings.py +0 -0
  91. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/signals/__init__.py +0 -0
  92. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/signals/nlp.py +0 -0
  93. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  94. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  95. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  96. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  97. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  98. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  99. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  100. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  101. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  102. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/state.py +0 -0
  103. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/__init__.py +0 -0
  104. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/ast_grep.py +0 -0
  105. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/matchers.py +0 -0
  106. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/scope.py +0 -0
  107. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/types.py +0 -0
  108. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/tasks.py +0 -0
  109. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  110. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/testing/session_cache.py +0 -0
  111. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/__init__.py +0 -0
  112. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/fs.py +0 -0
  113. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/http.py +0 -0
  114. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/model_cache.py +0 -0
  115. {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 6.5.0
3
+ Version: 7.0.0
4
4
  Summary: Stop repeating yourself to Claude.
5
5
  Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
6
  Author: Yasyf Mohamedali
@@ -16,11 +16,9 @@ Classifier: Programming Language :: Python :: 3 :: Only
16
16
  Classifier: Topic :: Software Development :: Quality Assurance
17
17
  Classifier: Topic :: Software Development :: Testing
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: cc-transcript>=7.1,<8
19
+ Requires-Dist: cc-transcript>=8,<9
20
20
  Requires-Dist: pydantic>=2.0
21
21
  Requires-Dist: pydantic-settings>=2.0
22
- Requires-Dist: tree-sitter>=0.24
23
- Requires-Dist: tree-sitter-bash>=0.23
24
22
  Requires-Dist: ast-grep-py>=0.39,<1
25
23
  Requires-Dist: funcy>=2.0
26
24
  Requires-Dist: spacy>=3.7
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from cc_transcript.command import Command, CommandLine, Redirect
3
4
  from cc_transcript.tools import (
4
5
  BashCall,
5
6
  EditCall,
@@ -22,7 +23,6 @@ from cc_transcript.tools import (
22
23
  from captain_hook import file, style, util
23
24
  from captain_hook.app import hook, on
24
25
  from captain_hook.ast_grep import COMMENT_TYPES
25
- from captain_hook.command import CommandLine, ParsedCommand, Redirect
26
26
  from captain_hook.conditions import workflow_opt_values, workflow_script_source
27
27
  from captain_hook.context import HookContext
28
28
  from captain_hook.contexts import AfterEdit, BeforeEdit, Introduced, PromptContext, apply_contexts
@@ -30,6 +30,7 @@ from captain_hook.durable import DurableSlot, DurableState, DurableStore
30
30
  from captain_hook.events import (
31
31
  BaseHookEvent,
32
32
  NotificationEvent,
33
+ PermissionRequestEvent,
33
34
  PostToolUseEvent,
34
35
  PostToolUseFailureEvent,
35
36
  PreCompactEvent,
@@ -40,6 +41,7 @@ from captain_hook.events import (
40
41
  SubagentStartEvent,
41
42
  SubagentStopEvent,
42
43
  ToolHookEvent,
44
+ ToolRewriteEvent,
43
45
  UserPromptSubmitEvent,
44
46
  )
45
47
  from captain_hook.fields import Deque
@@ -48,8 +50,12 @@ from captain_hook.primitives import (
48
50
  GateVerdict,
49
51
  NudgeVerdict,
50
52
  PromptCheckVerdict,
53
+ SafetyVerdict,
54
+ approve,
51
55
  block_command,
56
+ deny,
52
57
  gate,
58
+ llm_approve,
53
59
  llm_evaluate,
54
60
  llm_gate,
55
61
  llm_nudge,
@@ -73,18 +79,18 @@ from captain_hook.settings import HooksSettings, build_settings
73
79
  from captain_hook.signals.nlp import Clause, NlpSignal, Phrase, has_nominal_subject, is_past_predicate
74
80
  from captain_hook.state import HookState, PrimitiveState, WorkflowState, workflow_state
75
81
  from captain_hook.tasks import Task, Tasks
76
- from captain_hook.testing import Allow, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
82
+ from captain_hook.testing import Allow, Ask, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
77
83
  from captain_hook.types import (
78
84
  Action,
79
85
  Agent,
80
86
  And,
81
- Command,
82
87
  Content,
83
88
  CustomCommandLineCondition,
84
89
  CustomCondition,
85
90
  CustomInputTypeCondition,
86
91
  Event,
87
92
  FilePath,
93
+ FromSubagent,
88
94
  HookResponse,
89
95
  HookResult,
90
96
  InPlanMode,
@@ -96,6 +102,7 @@ from captain_hook.types import (
96
102
  Runs,
97
103
  Signal,
98
104
  Signals,
105
+ SkipPermissions,
99
106
  SourceEdits,
100
107
  TCondition,
101
108
  TestFile,
@@ -40,7 +40,7 @@ HookHandler = Callable[["BaseHookEvent"], "HookResult | None"]
40
40
  VALID_CONDITION_TYPES = tuple(t for t in get_args(TCondition) if t is not CustomCondition)
41
41
  VALID_CONDITION_NAMES = ", ".join(t.__name__ for t in VALID_CONDITION_TYPES) + ", or a CustomCondition"
42
42
 
43
- _TOOL_EVENTS = Event.PreToolUse | Event.PostToolUse | Event.PostToolUseFailure
43
+ _TOOL_EVENTS = Event.PreToolUse | Event.PostToolUse | Event.PostToolUseFailure | Event.PermissionRequest
44
44
 
45
45
  # Conditions that read the current event's tool input can only match on a tool event; a
46
46
  # condition absent from this map (transcript-history conditions, InPlanMode, Waiting,
@@ -259,7 +259,12 @@ def settings_drift(root: Path) -> set[str]:
259
259
  def warn_settings_drift(
260
260
  output: dict[str, Any] | None, event: Event, root: Path | None, session_dir: Path | None, *, async_: bool
261
261
  ) -> dict[str, Any] | None:
262
- if async_ or root is None or session_dir is None or event in (Event.Stop | Event.SubagentStop):
262
+ if (
263
+ async_
264
+ or root is None
265
+ or session_dir is None
266
+ or event in (Event.Stop | Event.SubagentStop | Event.PermissionRequest)
267
+ ):
263
268
  return output
264
269
  marker = session_dir / ".drift_surfaced"
265
270
  if marker.exists():
@@ -16,6 +16,7 @@ from captain_hook.types import (
16
16
  Content,
17
17
  CustomCondition,
18
18
  FilePath,
19
+ FromSubagent,
19
20
  InPlanMode,
20
21
  Not,
21
22
  Or,
@@ -23,6 +24,7 @@ from captain_hook.types import (
23
24
  RanCommand,
24
25
  ReadFile,
25
26
  Runs,
27
+ SkipPermissions,
26
28
  SourceEdits,
27
29
  TCondition,
28
30
  TestFile,
@@ -228,8 +230,8 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
228
230
  return has_read_glob(evt.ctx.transcript, *patterns, subagents=subagents)
229
231
  case TouchedFile(patterns, subagents):
230
232
  return evt.ctx.transcript.has_edit_to(*patterns, subagents=subagents)
231
- case RanCommand(pattern, subagents):
232
- return evt.ctx.transcript.has_command(pattern, subagents=subagents)
233
+ case RanCommand(argv, subagents):
234
+ return evt.ctx.transcript.has_command(*argv, subagents=subagents)
233
235
  case Runs(argv):
234
236
  return bool(argv and (cl := evt.command_line) and any(cmd.argv[: len(argv)] == argv for cmd in cl.commands))
235
237
  case InPlanMode():
@@ -239,6 +241,10 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
239
241
  )
240
242
  case Waiting():
241
243
  return is_waiting(evt)
244
+ case FromSubagent():
245
+ return evt.is_subagent
246
+ case SkipPermissions():
247
+ return evt.skip_permissions
242
248
  case And(conditions):
243
249
  return all(check_condition(sub, evt) for sub in conditions)
244
250
  case Not(condition):
@@ -7,7 +7,8 @@ from functools import cached_property
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING, Any, Literal, overload
9
9
 
10
- from cc_transcript.activity import SessionActivity, meta_of
10
+ from cc_transcript.activity import SessionActivity
11
+ from cc_transcript.filterspec import event_meta
11
12
  from cc_transcript.ids import SessionId
12
13
  from cc_transcript.models import AssistantEvent, ToolUseBlock
13
14
  from cc_transcript.parser import parse_events_from_bytes
@@ -84,7 +85,7 @@ def lift_session(events: Sequence[TranscriptEvent], *, path: Path | None = None)
84
85
  events=events,
85
86
  )
86
87
  session_id = next(
87
- (meta.session_id for event in events if (meta := meta_of(event)) is not None),
88
+ (meta.session_id for event in events if (meta := event_meta(event)) is not None),
88
89
  SessionId(path.stem if path else "unknown"),
89
90
  )
90
91
  return Session.from_activity(
@@ -67,10 +67,25 @@ def execute_hook(
67
67
  return result
68
68
 
69
69
 
70
+ def format_permission_decision(result: HookResult) -> dict[str, Any] | None:
71
+ match result.action:
72
+ case Action.allow:
73
+ decision: dict[str, Any] = {"behavior": "allow"}
74
+ case Action.rewrite:
75
+ decision = {"behavior": "allow", "updatedInput": result.updated_input}
76
+ case Action.block:
77
+ decision = {"behavior": "deny"} | ({"message": result.message} if result.message else {})
78
+ case Action.warn:
79
+ return None
80
+ return {"hookSpecificOutput": {"hookEventName": Event.PermissionRequest.name, "decision": decision}}
81
+
82
+
70
83
  def format_output(event: Event, result: HookResult) -> dict[str, Any] | None:
71
84
  """Render a ``HookResult`` as the JSON envelope Claude Code expects on stdout for *event*."""
72
85
  if event in (Event.Stop | Event.SubagentStop):
73
86
  return {"decision": "block", "reason": result.message} if result.action is not Action.allow else None
87
+ if event is Event.PermissionRequest:
88
+ return format_permission_decision(result)
74
89
 
75
90
  match result.action:
76
91
  case Action.block:
@@ -25,10 +25,10 @@ from captain_hook.types import Event
25
25
  if TYPE_CHECKING:
26
26
  from collections.abc import Callable
27
27
 
28
+ from cc_transcript.command import CommandLine as CommandLineType
28
29
  from cc_transcript.ids import ToolDigest
29
30
  from cc_transcript.tools import ToolCall
30
31
 
31
- from captain_hook.command import CommandLine as CommandLineType
32
32
  from captain_hook.context import HookContext
33
33
  from captain_hook.tasks import Tasks
34
34
  from captain_hook.types import HookResult
@@ -81,6 +81,18 @@ class BaseHookEvent:
81
81
 
82
82
  return T.for_session(self.session_id)
83
83
 
84
+ @cached_property
85
+ def skip_permissions(self) -> bool:
86
+ """Whether the session's ``claude`` process was launched with permission bypass available.
87
+
88
+ Walks the process tree to the nearest ``claude`` ancestor and reports whether that
89
+ process was started with ``--dangerously-skip-permissions`` or
90
+ ``--allow-dangerously-skip-permissions``.
91
+ """
92
+ from captain_hook.util.proc import claude_skip_permissions
93
+
94
+ return claude_skip_permissions()
95
+
84
96
  @property
85
97
  def user_prompt(self) -> str | None:
86
98
  return self._raw.get("prompt")
@@ -125,7 +137,7 @@ class BaseHookEvent:
125
137
  def command(self) -> str | None:
126
138
  return self.input.command if isinstance(self.input, BashCall) else None
127
139
 
128
- @cached_property
140
+ @property
129
141
  def command_line(self) -> CommandLineType | None:
130
142
  return None
131
143
 
@@ -213,11 +225,11 @@ class ToolHookEvent(BaseHookEvent):
213
225
  def _tool_input(self) -> dict[str, Any]:
214
226
  return self._raw.get("tool_input", {})
215
227
 
216
- @cached_property
228
+ @property
217
229
  def command_line(self) -> CommandLineType | None:
218
- from captain_hook.command import CommandLine
230
+ from cc_transcript.command import parse_command_line
219
231
 
220
- return CommandLine.parse(cmd) if (cmd := self.command) else None
232
+ return parse_command_line(cmd) if (cmd := self.command) else None
221
233
 
222
234
  @property
223
235
  def content(self) -> str | None:
@@ -267,9 +279,9 @@ class ToolHookEvent(BaseHookEvent):
267
279
  return call.agent_type if isinstance(call := self.input, TaskCall) else None
268
280
 
269
281
  def command_matches(self, *patterns: str) -> bool:
270
- if not (cl := self.command_line):
282
+ if not (cl := self.command_line) or (cmd := cl.primary) is None:
271
283
  return False
272
- return any(cl.primary.matches(p) for p in patterns)
284
+ return any(cmd.matches(p) for p in patterns)
273
285
 
274
286
  def file_matches(self, *globs: str) -> bool:
275
287
  return bool(self.file) and self.file.matches(*globs)
@@ -279,10 +291,12 @@ class ToolHookEvent(BaseHookEvent):
279
291
 
280
292
 
281
293
  @dataclass
282
- class PreToolUseEvent(ToolHookEvent):
283
- """Fires before a tool is executed. Return a block result to prevent execution."""
294
+ class ToolRewriteEvent(ToolHookEvent):
295
+ """Tool event whose input can be rewritten before it runs.
284
296
 
285
- event_name: ClassVar[Event] = Event.PreToolUse
297
+ Base for the events where Claude Code accepts an ``updatedInput`` decision
298
+ (``PreToolUse`` and ``PermissionRequest``), adding the ``rewrite*`` helpers.
299
+ """
286
300
 
287
301
  def rewrite(self, updated_input: dict[str, Any], *, note: str | None = None) -> HookResult:
288
302
  """Allow the tool but replace its input with ``updated_input`` (Claude Code's ``updatedInput``).
@@ -322,6 +336,33 @@ class PreToolUseEvent(ToolHookEvent):
322
336
  return self.rewrite(updated, note=note) if updated != base else None
323
337
 
324
338
 
339
+ @dataclass
340
+ class PreToolUseEvent(ToolRewriteEvent):
341
+ """Fires before a tool is executed. Return a block result to prevent execution."""
342
+
343
+ event_name: ClassVar[Event] = Event.PreToolUse
344
+
345
+
346
+ @dataclass
347
+ class PermissionRequestEvent(ToolRewriteEvent):
348
+ """Fires when a permission dialog would be shown.
349
+
350
+ ``allow``/``block``/``rewrite`` answer the dialog (block maps to a deny with the
351
+ message shown to the user); ``None`` and ``warn`` fall through, so the dialog
352
+ shows normally.
353
+ """
354
+
355
+ event_name: ClassVar[Event] = Event.PermissionRequest
356
+
357
+ @property
358
+ def permission_suggestions(self) -> list[dict[str, Any]]:
359
+ return self._raw.get("permission_suggestions", [])
360
+
361
+ @property
362
+ def agent_type(self) -> str | None:
363
+ return self._raw.get("agent_type")
364
+
365
+
325
366
  @dataclass
326
367
  class PostToolUseEvent(ToolHookEvent):
327
368
  """Fires after a tool completes successfully, with access to the tool response."""
@@ -0,0 +1,4 @@
1
+ name = "fixes"
2
+ version = "0.1.0"
3
+ description = "Workarounds for upstream Claude Code issues."
4
+ hooks = "."
@@ -0,0 +1,66 @@
1
+ # Workaround for https://github.com/anthropics/claude-code/issues/73176: in-process
2
+ # teammates don't inherit the leader's --dangerously-skip-permissions consent, so their
3
+ # Bash calls (heredocs, redirects) pop permission dialogs in the lead UI. Auto-approve
4
+ # native teammate Bash only — MCP tools named mcp__<srv>__Bash suffix-match Tool("Bash")
5
+ # and are vetoed — and only when the process tree shows the user launched with the flag;
6
+ # the denylist is a courtesy speed bump (raw-text, over-prompting by design), not a
7
+ # security boundary — the session is already bypass-consented.
8
+ from __future__ import annotations
9
+
10
+ from captain_hook import (
11
+ Allow,
12
+ Ask,
13
+ BaseHookEvent,
14
+ CustomCondition,
15
+ FromSubagent,
16
+ Input,
17
+ SkipPermissions,
18
+ Tool,
19
+ ToolInput,
20
+ approve,
21
+ )
22
+
23
+
24
+ class McpTool(CustomCondition):
25
+ """Matches MCP-server tools (``mcp__<server>__<tool>``), which Tool() suffix-matching also accepts."""
26
+
27
+ def check(self, evt: BaseHookEvent) -> bool:
28
+ return bool(evt.tool_name) and evt.tool_name.startswith("mcp__")
29
+
30
+
31
+ approve(
32
+ "teammate bash under skip-permissions",
33
+ only_if=[Tool("Bash"), ToolInput("command", r"[\s\S]"), FromSubagent(), SkipPermissions()],
34
+ skip_if=[
35
+ McpTool(),
36
+ ToolInput(
37
+ "command",
38
+ r"\b(rm|dd|shred|truncate|sudo|mkfs[.\w]*)\b"
39
+ r"|\bgit\s+(-[Cc]\s+\S+\s+|--?\S+\s+)*(reset|clean|restore)\b"
40
+ r"|\bgit\s+(-[Cc]\s+\S+\s+|--?\S+\s+)*push\b[^\n]*(\s--?force(-with-lease)?\b|\s--delete\b)"
41
+ r"|\b(curl|wget)\b[^|\n]*\|\s*((\S*/)?env\s+)?(\S*/)?(ba|z|da)?sh\b",
42
+ ),
43
+ ],
44
+ tests={
45
+ Input(command="python3 - <<'EOF'\nprint(1)\nEOF", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
46
+ Input(command="echo 'x = 1' > /tmp/conf.py", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
47
+ Input(command="git status", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
48
+ Input(command="git -C . log", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
49
+ Input(command="rm -rf build", agent_id="tm1", skip_permissions=True): Ask(),
50
+ Input(command="sudo systemsetup -setremotelogin on", agent_id="tm1", skip_permissions=True): Ask(),
51
+ Input(command="git reset --hard HEAD~1", agent_id="tm1", skip_permissions=True): Ask(),
52
+ Input(command="git -C . reset --hard", agent_id="tm1", skip_permissions=True): Ask(),
53
+ Input(command="git --no-pager clean -fd", agent_id="tm1", skip_permissions=True): Ask(),
54
+ Input(command="curl https://get.x.sh | sh", agent_id="tm1", skip_permissions=True): Ask(),
55
+ Input(command="curl https://get.x.sh | /usr/bin/env bash", agent_id="tm1", skip_permissions=True): Ask(),
56
+ Input(command="git push --force origin main", agent_id="tm1", skip_permissions=True): Ask(),
57
+ Input(command="git -C repo push --force origin main", agent_id="tm1", skip_permissions=True): Ask(),
58
+ Input(tool="mcp__srv__Bash", tool_input={"cmd": "x"}, agent_id="tm1", skip_permissions=True): Ask(),
59
+ Input(
60
+ tool="mcp__srv__Bash", tool_input={"command": "echo hi"}, agent_id="tm1", skip_permissions=True
61
+ ): Ask(), # MCP Bash, benign command — never auto-approved
62
+ Input(tool="mcp__ops__Bash", tool_input={"command": "rm -rf /"}, agent_id="tm1", skip_permissions=True): Ask(),
63
+ Input(command="python3 - <<'EOF'\nprint(1)\nEOF", skip_permissions=True): Ask(), # main thread
64
+ Input(command="python3 - <<'EOF'\nprint(1)\nEOF", agent_id="tm1", skip_permissions=False): Ask(), # no consent
65
+ },
66
+ )
@@ -4,7 +4,6 @@ from captain_hook import (
4
4
  Allow,
5
5
  BaseHookEvent,
6
6
  Block,
7
- Command,
8
7
  CustomCondition,
9
8
  Event,
10
9
  FilePath,
@@ -15,6 +14,7 @@ from captain_hook import (
15
14
  gate,
16
15
  nudge,
17
16
  )
17
+ from captain_hook.types import Command as CommandCondition
18
18
 
19
19
  nudge(
20
20
  """
@@ -50,14 +50,14 @@ class CommitsGo(CustomCondition):
50
50
  """Matches when the git command explicitly names a Go path."""
51
51
 
52
52
  def check(self, evt: BaseHookEvent) -> bool:
53
- return bool(cl := evt.command_line) and ".go" in str(cl.primary)
53
+ return bool(cl := evt.command_line) and (cmd := cl.primary) is not None and ".go" in str(cmd)
54
54
 
55
55
 
56
56
  gate(
57
57
  "No `go test` execution found. Run tests before committing Go changes.",
58
- only_if=[Tool("Bash"), Command(r"git\s+commit"), CommitsGo()],
58
+ only_if=[Tool("Bash"), CommandCondition(r"git\s+commit"), CommitsGo()],
59
59
  skip_if=[
60
- RanCommand(r"go test"),
60
+ RanCommand("go", "test"),
61
61
  UserSaid("commit", "just commit"),
62
62
  AllEditsUnder("docs/", ".claude/", ".github/"),
63
63
  ],
@@ -72,9 +72,9 @@ gate(
72
72
  nudge(
73
73
  "No `go test` execution detected in this session. If you changed Go files, run tests "
74
74
  "before committing. If this is a docs/config-only change, proceed.",
75
- only_if=[Tool("Bash"), Command(r"git\s+commit")],
75
+ only_if=[Tool("Bash"), CommandCondition(r"git\s+commit")],
76
76
  skip_if=[
77
- RanCommand(r"go test"),
77
+ RanCommand("go", "test"),
78
78
  UserSaid("commit", "just commit"),
79
79
  AllEditsUnder("docs/", ".claude/", ".github/"),
80
80
  CommitsGo(),
@@ -4,7 +4,6 @@ from captain_hook import (
4
4
  Allow,
5
5
  BaseHookEvent,
6
6
  Block,
7
- Command,
8
7
  CustomCondition,
9
8
  Event,
10
9
  Input,
@@ -15,6 +14,7 @@ from captain_hook import (
15
14
  gate,
16
15
  nudge,
17
16
  )
17
+ from captain_hook.types import Command as CommandCondition
18
18
 
19
19
  nudge(
20
20
  """
@@ -50,14 +50,15 @@ class CommitsPython(CustomCondition):
50
50
  """Matches when the git command explicitly names a Python path."""
51
51
 
52
52
  def check(self, evt: BaseHookEvent) -> bool:
53
- return bool(cl := evt.command_line) and ".py" in str(cl.primary)
53
+ return bool(cl := evt.command_line) and (cmd := cl.primary) is not None and ".py" in str(cmd)
54
54
 
55
55
 
56
56
  gate(
57
57
  "No `uv run pytest` execution found. Run tests before committing Python changes.",
58
- only_if=[Tool("Bash"), Command(r"git\s+commit"), CommitsPython()],
58
+ only_if=[Tool("Bash"), CommandCondition(r"git\s+commit"), CommitsPython()],
59
59
  skip_if=[
60
- RanCommand(r"uv run pytest"),
60
+ RanCommand("uv", "run", "pytest"),
61
+ RanCommand("pytest"),
61
62
  UserSaid("commit", "just commit"),
62
63
  AllEditsUnder("docs/", ".claude/", ".github/"),
63
64
  ],
@@ -72,9 +73,10 @@ gate(
72
73
  nudge(
73
74
  "No `uv run pytest` execution detected in this session. If you changed Python files, run "
74
75
  "tests before committing. If this is a docs/config-only change, proceed.",
75
- only_if=[Tool("Bash"), Command(r"git\s+commit")],
76
+ only_if=[Tool("Bash"), CommandCondition(r"git\s+commit")],
76
77
  skip_if=[
77
- RanCommand(r"uv run pytest"),
78
+ RanCommand("uv", "run", "pytest"),
79
+ RanCommand("pytest"),
78
80
  UserSaid("commit", "just commit"),
79
81
  AllEditsUnder("docs/", ".claude/", ".github/"),
80
82
  CommitsPython(),
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -180,7 +180,15 @@ nudge(
180
180
  "overriding `property`, minor override mismatches, descriptor protocol). Only fix type "
181
181
  "issues that indicate actual bugs. Don't check git history to see if you introduced "
182
182
  "them — move on.",
183
- skip_if=[RanCommand(r"(?:uv run ty check|uvx ty check|(?:uvx )?prek run (?:ty\b|--all-files)|uvx pyright)")],
183
+ skip_if=[
184
+ RanCommand("uv", "run", "ty", "check"),
185
+ RanCommand("uvx", "ty", "check"),
186
+ RanCommand("prek", "run", "ty"),
187
+ RanCommand("prek", "run", "--all-files"),
188
+ RanCommand("uvx", "prek", "run", "ty"),
189
+ RanCommand("uvx", "prek", "run", "--all-files"),
190
+ RanCommand("uvx", "pyright"),
191
+ ],
184
192
  signals=TRIVIAL_TYPE_SIGNALS,
185
193
  tests={
186
194
  Input(
@@ -12,4 +12,5 @@ from captain_hook.primitives.llm import (
12
12
  prompt_check,
13
13
  )
14
14
  from captain_hook.primitives.nudge import gate, nudge
15
+ from captain_hook.primitives.permissions import SafetyVerdict, approve, deny, llm_approve
15
16
  from captain_hook.primitives.rewrite import rewrite_code, set_tool_input
@@ -138,7 +138,8 @@ def rewrite_command(
138
138
  def structural_handler(evt: PreToolUseEvent) -> HookResponse:
139
139
  if not (cl := evt.command_line):
140
140
  return None
141
- return evt.rewrite_command(new, note=note) if (new := cl.rewrite(pattern, replace)) != cl.raw else None
141
+ new = ast_grep.rewrite(cl.raw, "bash", pattern, replace)
142
+ return evt.rewrite_command(new, note=note) if new != cl.raw else None
142
143
 
143
144
  on(Event.PreToolUse, only_if=[Tool("Bash"), *only_if], skip_if=skip_if, tests=tests)(structural_handler)
144
145
  return