capt-hook 3.0.0__tar.gz → 3.2.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 (90) hide show
  1. {capt_hook-3.0.0 → capt_hook-3.2.0}/PKG-INFO +2 -2
  2. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/cli.py +88 -3
  3. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/loader.py +18 -0
  4. capt_hook-3.2.0/captain_hook/packs/__init__.py +1 -0
  5. capt_hook-3.2.0/captain_hook/packs/general/capt-hook.toml +4 -0
  6. capt_hook-3.2.0/captain_hook/packs/general/commands.py +71 -0
  7. capt_hook-3.2.0/captain_hook/packs/general/docs.py +23 -0
  8. capt_hook-3.2.0/captain_hook/packs/general/plans.py +69 -0
  9. capt_hook-3.2.0/captain_hook/packs/general/prompts.py +33 -0
  10. capt_hook-3.2.0/captain_hook/packs/general/review.py +44 -0
  11. capt_hook-3.2.0/captain_hook/packs/general/stewardship.py +269 -0
  12. capt_hook-3.2.0/captain_hook/packs/general/tasks.py +147 -0
  13. capt_hook-3.2.0/captain_hook/packs/manager.py +233 -0
  14. capt_hook-3.2.0/captain_hook/packs/python/capt-hook.toml +4 -0
  15. capt_hook-3.2.0/captain_hook/packs/python/style.py +147 -0
  16. capt_hook-3.2.0/captain_hook/packs/python/testing.py +87 -0
  17. capt_hook-3.2.0/captain_hook/packs/python/toolchain.py +32 -0
  18. {capt_hook-3.0.0 → capt_hook-3.2.0}/pyproject.toml +2 -2
  19. {capt_hook-3.0.0 → capt_hook-3.2.0}/LICENSE +0 -0
  20. {capt_hook-3.0.0 → capt_hook-3.2.0}/README.md +0 -0
  21. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  22. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/__init__.py +0 -0
  23. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/__main__.py +0 -0
  24. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/app.py +0 -0
  25. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/classifiers/__init__.py +0 -0
  26. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/classifiers/conductor.py +0 -0
  27. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/classifiers/droid.py +0 -0
  28. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/classifiers/native.py +0 -0
  29. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/command.py +0 -0
  30. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/conditions.py +0 -0
  31. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/context.py +0 -0
  32. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/decisions.py +0 -0
  33. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/dispatch.py +0 -0
  34. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/events.py +0 -0
  35. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/file.py +0 -0
  36. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/llm/__init__.py +0 -0
  37. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/log.py +0 -0
  38. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/primitives/__init__.py +0 -0
  39. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/primitives/commands.py +0 -0
  40. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/primitives/lint.py +0 -0
  41. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/primitives/llm.py +0 -0
  42. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/primitives/nudge.py +0 -0
  43. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/primitives/workflow.py +0 -0
  44. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/prompt.py +0 -0
  45. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/py.typed +0 -0
  46. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/__init__.py +0 -0
  47. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/cli.py +0 -0
  48. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/fix.py +0 -0
  49. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/formats.py +0 -0
  50. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/judge.py +0 -0
  51. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/pipeline.py +0 -0
  52. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/repo.py +0 -0
  53. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/scan.py +0 -0
  54. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/settings.py +0 -0
  55. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/store.py +0 -0
  56. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/review/sync.py +0 -0
  57. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/session.py +0 -0
  58. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/settings.py +0 -0
  59. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/signals/__init__.py +0 -0
  60. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/signals/nlp.py +0 -0
  61. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  62. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  63. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  64. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  65. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  66. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  67. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  68. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  69. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  70. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  71. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  72. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  73. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  74. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/state.py +0 -0
  75. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/style/__init__.py +0 -0
  76. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/style/matchers.py +0 -0
  77. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/style/scope.py +0 -0
  78. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/style/types.py +0 -0
  79. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/tasks.py +0 -0
  80. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  81. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/testing/__init__.py +0 -0
  82. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/testing/helpers.py +0 -0
  83. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/testing/session_cache.py +0 -0
  84. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/testing/types.py +0 -0
  85. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/tests/__init__.py +0 -0
  86. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/tests/helpers.py +0 -0
  87. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/types.py +0 -0
  88. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/util/__init__.py +0 -0
  89. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/util/model_cache.py +0 -0
  90. {capt_hook-3.0.0 → capt_hook-3.2.0}/captain_hook/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: Declarative hook framework for Claude Code
5
5
  Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
6
  Author: Yasyf Mohamedali
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
16
16
  Classifier: Topic :: Software Development :: Quality Assurance
17
17
  Classifier: Topic :: Software Development :: Testing
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: cc-transcript>=3.0,<4
19
+ Requires-Dist: cc-transcript>=3.2,<4
20
20
  Requires-Dist: pydantic>=2.0
21
21
  Requires-Dist: pydantic-settings>=2.0
22
22
  Requires-Dist: tree-sitter>=0.24
@@ -17,8 +17,9 @@ from cc_transcript.ids import SessionId
17
17
  from captain_hook.app import _state, load_gitignore, reset
18
18
  from captain_hook.context import HookContext, load_transcript
19
19
  from captain_hook.dispatch import dispatch
20
- from captain_hook.loader import discover_hooks
20
+ from captain_hook.loader import CONF_MODULE, discover_hooks, discover_pack
21
21
  from captain_hook.log import setup_logging
22
+ from captain_hook.packs import manager
22
23
  from captain_hook.review.cli import review
23
24
  from captain_hook.session import SessionStore, ensure_session
24
25
  from captain_hook.types import Event
@@ -38,6 +39,14 @@ class CliState:
38
39
  reset()
39
40
  load_gitignore(self.root)
40
41
  discover_hooks(self.hooks)
42
+ resolved, missing = manager.resolve_enabled_packs(self.root)
43
+ for pack_ in resolved:
44
+ discover_pack(pack_.entry.name, pack_.path)
45
+ if missing:
46
+ print(
47
+ f"capt-hook: packs not cached: {', '.join(missing)} — run `capt-hook pack update`",
48
+ file=sys.stderr,
49
+ )
41
50
 
42
51
 
43
52
  def example_hook_source() -> str:
@@ -200,6 +209,14 @@ def print_hook_summary(label: str, summary: dict[str, str]) -> None:
200
209
  print(f" unchanged: {', '.join(unchanged)} (already present)")
201
210
 
202
211
 
212
+ def regenerate_settings(state: CliState) -> None:
213
+ state.discover()
214
+ settings_path = state.root / ".claude" / "settings.local.json"
215
+ merged, summary = merge_settings(".claude/hooks", settings_path)
216
+ write_settings(settings_path, merged)
217
+ print_hook_summary(str(settings_path.relative_to(state.root)), summary)
218
+
219
+
203
220
  def settings_drift(root: Path) -> set[str]:
204
221
  settings = [p for name in ("settings.json", "settings.local.json") if (p := root / ".claude" / name).exists()]
205
222
  if not settings:
@@ -296,8 +313,7 @@ def init_project(root: Path) -> None:
296
313
  example.write_text(example_hook_source())
297
314
 
298
315
  settings_path = root / ".claude" / "settings.local.json"
299
- reset()
300
- discover_hooks(str(hooks_dir))
316
+ CliState(root=root, hooks=str(hooks_dir)).discover()
301
317
  merged, summary = merge_settings(".claude/hooks", settings_path)
302
318
  write_settings(settings_path, merged)
303
319
 
@@ -505,6 +521,75 @@ def skills_install(state: CliState, force: bool) -> None:
505
521
  click.echo(f" {status} {name}")
506
522
 
507
523
 
524
+ @cli.group()
525
+ def pack() -> None:
526
+ """Manage capt-hook packs — named collections of hooks (builtin or from GitHub)."""
527
+
528
+
529
+ @pack.command(name="add")
530
+ @click.argument("target")
531
+ @click.pass_obj
532
+ def pack_add(state: CliState, target: str) -> None:
533
+ """Enable a builtin pack by name, or an external pack as github:owner/repo[@ref]."""
534
+ try:
535
+ entry = (
536
+ manager.BuiltinPack(name=target)
537
+ if target in manager.builtin_packs()
538
+ else manager.fetch_pack(manager.PackSource.parse(target)).entry
539
+ )
540
+ manager.upsert_entry(manager.packs_toml_path(state.root), entry)
541
+ except manager.PackError as e:
542
+ raise click.ClickException(str(e)) from e
543
+ click.echo(f" added {entry.name}")
544
+ regenerate_settings(state)
545
+
546
+
547
+ @pack.command(name="list")
548
+ @click.pass_obj
549
+ def pack_list(state: CliState) -> None:
550
+ """List the packs enabled in .claude/hooks/packs.toml."""
551
+ resolved, missing = manager.resolve_enabled_packs(state.root)
552
+ for r in resolved:
553
+ match r.entry:
554
+ case manager.BuiltinPack():
555
+ kind, ref = "builtin", "-"
556
+ case manager.ExternalPack(source=source, commit=commit):
557
+ kind, ref = "github", f"{source.ref or 'HEAD'}@{commit[:7]}"
558
+ count = sum(1 for p in r.path.glob("*.py") if not p.stem.startswith("_") and p.stem != CONF_MODULE)
559
+ click.echo(f" {r.entry.name:24} {kind:8} {ref:20} v{r.manifest.version:8} {count} hooks")
560
+ for name in missing:
561
+ click.echo(f" {name:24} github (not cached — run `capt-hook pack update`)")
562
+
563
+
564
+ @pack.command(name="remove")
565
+ @click.argument("name")
566
+ @click.pass_obj
567
+ def pack_remove(state: CliState, name: str) -> None:
568
+ """Disable a pack (leaves its content-addressed cache intact)."""
569
+ try:
570
+ manager.delete_entry(manager.packs_toml_path(state.root), name)
571
+ except manager.PackError as e:
572
+ raise click.ClickException(str(e)) from e
573
+ click.echo(f" removed {name}")
574
+ regenerate_settings(state)
575
+
576
+
577
+ @pack.command(name="update")
578
+ @click.argument("name", required=False)
579
+ @click.pass_obj
580
+ def pack_update(state: CliState, name: str | None) -> None:
581
+ """Re-resolve external packs' refs to fresh commits and re-fetch."""
582
+ path = manager.packs_toml_path(state.root)
583
+ for entry in manager.read_entries(path):
584
+ match entry:
585
+ case manager.ExternalPack(name=n, source=source) if name in (None, n):
586
+ manager.upsert_entry(path, fresh := manager.fetch_pack(source).entry)
587
+ click.echo(f" updated {n} -> {fresh.commit[:7]}")
588
+ case manager.BuiltinPack(name=n) if name == n:
589
+ click.echo(f" {n} is builtin; it tracks the installed capt-hook version")
590
+ regenerate_settings(state)
591
+
592
+
508
593
  cli.add_command(review)
509
594
 
510
595
  main = cli
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import importlib
6
+ import importlib.machinery
6
7
  import importlib.util
7
8
  import pkgutil
9
+ import re
8
10
  import sys
9
11
  from pathlib import Path
10
12
  from types import ModuleType
@@ -14,6 +16,7 @@ from pydantic_settings import BaseSettings
14
16
  from captain_hook.app import State, _state
15
17
 
16
18
  CONF_MODULE = "conf"
19
+ PACK_PACKAGE_PREFIX = "captain_hook._packs"
17
20
 
18
21
 
19
22
  def build_hook_settings(module: ModuleType) -> BaseSettings | ModuleType:
@@ -61,3 +64,18 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
61
64
 
62
65
  for fqn in sorted(all_modules - {f"{pkg}.{CONF_MODULE}"}):
63
66
  import_or_reload(fqn, fresh_this_pass)
67
+
68
+
69
+ def import_pack_module(fqn: str, path: Path) -> ModuleType:
70
+ loader = importlib.machinery.SourceFileLoader(fqn, str(path))
71
+ module = importlib.util.module_from_spec(importlib.machinery.ModuleSpec(fqn, loader, origin=str(path)))
72
+ sys.modules[fqn] = module
73
+ loader.exec_module(module)
74
+ return module
75
+
76
+
77
+ def discover_pack(name: str, pack_dir: Path) -> None:
78
+ pkg = f"{PACK_PACKAGE_PREFIX}.{re.sub(r'\W', '_', name)}"
79
+ for path in sorted(pack_dir.glob("*.py")):
80
+ if not (path.stem.startswith("_") or path.stem == CONF_MODULE):
81
+ import_pack_module(f"{pkg}.{path.stem}", path)
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,4 @@
1
+ name = "general"
2
+ version = "0.1.0"
3
+ description = "Language-agnostic guards: git/command safety, code stewardship, doc & prompt nudges, task & plan discipline, and a code-review gate."
4
+ hooks = "."
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import (
4
+ Allow,
5
+ BaseHookEvent,
6
+ Block,
7
+ CustomCondition,
8
+ Event,
9
+ Input,
10
+ Tool,
11
+ UsedSkill,
12
+ block_command,
13
+ hook,
14
+ nudge,
15
+ )
16
+
17
+ block_command(
18
+ ["git", "stash"],
19
+ reason="git stash is not allowed",
20
+ hint="Commit your changes to a branch instead",
21
+ tests={
22
+ Input(command="git stash"): Block(),
23
+ Input(command="git stash pop"): Block(),
24
+ Input(command="git status"): Allow(),
25
+ },
26
+ )
27
+
28
+
29
+ class UnpipedGrep(CustomCondition):
30
+ """True when a `grep` command does not consume piped input.
31
+
32
+ Allows the stream-filter idiom (`… | grep`) while still blocking grep used
33
+ for file searching, whether standalone, heading a pipe, or in a `&&`/`;` chain.
34
+ """
35
+
36
+ def check(self, evt: BaseHookEvent) -> bool:
37
+ if not (cl := evt.command_line):
38
+ return False
39
+ return any(
40
+ cmd.matches(r"^grep\b") and (i == 0 or cl.parts[i - 1][1] != "|") for i, (cmd, _) in enumerate(cl.parts)
41
+ )
42
+
43
+
44
+ hook(
45
+ Event.PreToolUse,
46
+ only_if=[Tool("Bash"), UnpipedGrep()],
47
+ message="BLOCKED: Use ripgrep (rg) instead of grep. Replace grep with rg, or use the built-in Grep tool.",
48
+ block=True,
49
+ tests={
50
+ Input(command="grep -rn foo src/"): Block(),
51
+ Input(command="ls | grep foo"): Allow(),
52
+ Input(command="cat x | grep foo | sort"): Allow(),
53
+ Input(command="grep foo file.py | wc -l"): Block(),
54
+ Input(command="grep foo a && echo done"): Block(),
55
+ Input(command="git log --grep=fix"): Allow(),
56
+ Input(command='git log --grep "fix bug"'): Allow(),
57
+ },
58
+ )
59
+
60
+ # Requires the codex plugin (/plugin install codex@skills from yasyf/cc-skills).
61
+ # Delete this nudge if you don't use Codex.
62
+ nudge(
63
+ """
64
+ Multiple tool failures detected without a /codex invocation. After 2 failed
65
+ approaches, get a second opinion from `/codex` before attempting a 3rd —
66
+ Codex catches errors that Claude may miss.
67
+ """,
68
+ skip_if=[UsedSkill("codex|codex:codex")],
69
+ events=Event.PostToolUseFailure,
70
+ when=lambda evt: evt.ctx.turn.count_failures() >= 2 and not evt.ctx.t.has_command(r"codex"),
71
+ )
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import Allow, FilePath, Input, Tool, UsedSkill, Warn, nudge
4
+
5
+ # Advisory reminder to consult the writing-docs skill (and run slop-cop) before
6
+ # editing documentation. Fires once per session on the first doc edit and stands
7
+ # down once the skill has been used. Advisory only, so it never blocks an edit.
8
+ #
9
+ # The scaffolded .claude/settings.json registers the yasyf/cc-skills marketplace
10
+ # and enables writing-docs@skills, so the skill (and the skip_if check) activates
11
+ # when the folder is trusted — no manual /plugin install.
12
+ nudge(
13
+ "You're editing documentation. Consult the writing-docs skill first for the "
14
+ "Diataxis modes, voice rules, and code-sample rules, then run "
15
+ "`slop-cop check <file> --lang=markdown` to catch prose tells before you finish.",
16
+ only_if=[Tool("Write|Edit"), FilePath("**/*.md", "**/*.qmd", "docs/**", "README.md")],
17
+ skip_if=[UsedSkill("writing-docs|writing-docs:writing-docs")],
18
+ max_fires=1,
19
+ tests={
20
+ Input(tool="Write", file="docs/guide/x.qmd", content="# X"): Warn(pattern="writing-docs"),
21
+ Input(tool="Edit", file="src/app.py", content="x = 1"): Allow(),
22
+ },
23
+ )
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import Allow, BaseHookEvent, Block, CustomCondition, Event, Input, Tool, hook
4
+
5
+
6
+ class RewritingExistingPlan(CustomCondition):
7
+ """True when a Write targets a plan file (`.md` under `plans/` or `specs/`) that was
8
+ already written earlier this session, with no new plan cycle (EnterPlanMode) since the
9
+ last Write to it.
10
+
11
+ Reads from ``evt.ctx.prior`` (the window before the current turn's last exchange) so the
12
+ pending Write being evaluated is never itself counted as the prior edit. A write to the
13
+ file this session already implies it exists, so no filesystem check is needed.
14
+ """
15
+
16
+ def check(self, evt: BaseHookEvent) -> bool:
17
+ fp = evt.file
18
+ if not fp or fp.suffix != ".md" or not fp.under("plans/", "specs/"):
19
+ return False
20
+ if not evt.ctx.prior.has_edit_to(str(fp)):
21
+ return False
22
+ return not evt.ctx.prior.after(tool="Write", file=str(fp)).has_tool("EnterPlanMode")
23
+
24
+
25
+ hook(
26
+ Event.PreToolUse,
27
+ only_if=[Tool("Write"), RewritingExistingPlan()],
28
+ message=(
29
+ "This plan file was already written in this planning session. Use the Edit tool "
30
+ "to make incremental changes instead of rewriting the entire plan with Write."
31
+ ),
32
+ block=True,
33
+ tests={
34
+ # Rewriting a plan already written this session, no new plan cycle since -> block.
35
+ Input(
36
+ tool="Write",
37
+ file="/x/plans/p.md",
38
+ content="# Plan v2",
39
+ transcript=[
40
+ {"type": "assistant", "message": {"content": [
41
+ {"type": "tool_use", "name": "Write", "id": "w0",
42
+ "input": {"file_path": "/x/plans/p.md", "content": "# Plan v1"}}]}},
43
+ {"type": "assistant", "message": {"content": [
44
+ {"type": "tool_use", "name": "Write", "id": "w1",
45
+ "input": {"file_path": "/x/plans/p.md", "content": "# Plan v2"}}]}},
46
+ ],
47
+ ): Block(),
48
+ # A new plan cycle (EnterPlanMode) started since the last write -> allow the rewrite.
49
+ Input(
50
+ tool="Write",
51
+ file="/x/plans/p.md",
52
+ content="# Plan v2",
53
+ transcript=[
54
+ {"type": "assistant", "message": {"content": [
55
+ {"type": "tool_use", "name": "Write", "id": "w0",
56
+ "input": {"file_path": "/x/plans/p.md", "content": "# Plan v1"}}]}},
57
+ {"type": "assistant", "message": {"content": [
58
+ {"type": "tool_use", "name": "EnterPlanMode", "id": "p1", "input": {}}]}},
59
+ {"type": "assistant", "message": {"content": [
60
+ {"type": "tool_use", "name": "Write", "id": "w1",
61
+ "input": {"file_path": "/x/plans/p.md", "content": "# Plan v2"}}]}},
62
+ ],
63
+ ): Allow(),
64
+ # First write of this plan this session -> allow.
65
+ Input(tool="Write", file="/x/plans/p.md", content="# Plan", transcript=[]): Allow(),
66
+ # Not a plan file -> allow.
67
+ Input(tool="Write", file="/x/src/main.py", content="x = 1"): Allow(),
68
+ },
69
+ )
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import Allow, Content, Input, Tool, UsedSkill, Warn, nudge
4
+
5
+ PROMPT_MARKERS = (
6
+ r"<instruction>|<system>|<examples>|<success_criteria>|<output_format>|"
7
+ r"<key_constraints>|<reasoning_framework>|<action_rules>|<preferred_patterns>|"
8
+ r"<persona>|<role>|<tool_persistence>|<completeness_contract>|<verification_loop>|"
9
+ r"You are an?\b|Your task is to\b|You will be provided with\b|"
10
+ r"def\s+\w*prompt\s*\(|(?i:\b(?:system|developer) prompt\b)|"
11
+ r"messages\s*=\s*\[|"
12
+ r"""["']role["']\s*:\s*["'](?:system|user|assistant|developer)["']"""
13
+ )
14
+
15
+ nudge(
16
+ "You're editing LLM prompt content. The `llm-prompts` skill covers positive framing, "
17
+ "XML structure, the prompting principles, and current per-provider model behaviors "
18
+ "(Claude, GPT-5.x, Gemini) — consult it before writing prompts. After editing, run "
19
+ "`/slop-cop-check` on the file to surface LLM-generated writing tells (overused "
20
+ "intensifiers, hedge stacks, em-dash pivots, throat-clearing) and revise any real hits.",
21
+ only_if=[Tool("Edit|Write"), Content(PROMPT_MARKERS, project_only=False)],
22
+ skip_if=[
23
+ UsedSkill("llm-prompts|llm-prompts:llm-prompts"),
24
+ UsedSkill("slop-cop:slop-cop-check|slop-cop:slop-cop-prose|slop-cop-check|slop-cop-prose"),
25
+ ],
26
+ tests={
27
+ Input(
28
+ file="agent.py", content='messages = [{"role": "system", "content": "You are a helpful assistant."}]\n'
29
+ ): Warn(),
30
+ Input(file="prompt.md", content="<instruction>\nSummarize the document.\n</instruction>\n"): Warn(),
31
+ Input(file="util.py", content="def add(a, b):\n return a + b\n"): Allow(),
32
+ },
33
+ )
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import Allow, BaseHookEvent, Block, CustomCondition, Event, Input, Waiting, gate
4
+
5
+ # Prose and config file extensions that shouldn't, on their own, demand a code-review pass.
6
+ # Tailor this (and the excluded dirs below) to scope what counts as "source" for your repo.
7
+ NON_SOURCE_SUFFIXES = (
8
+ ".md", ".mdx", ".rst", ".txt", ".json", ".toml",
9
+ ".yaml", ".yml", ".ini", ".cfg", ".lock",
10
+ )
11
+
12
+
13
+ class EditedSource(CustomCondition):
14
+ """True when the session edited a non-test source file (docs and config excluded)."""
15
+
16
+ def check(self, evt: BaseHookEvent) -> bool:
17
+ return any(
18
+ not f.is_test
19
+ and f.suffix not in NON_SOURCE_SUFFIXES
20
+ and not f.under("docs", ".claude", ".github")
21
+ for f in evt.ctx.t.tool_calls.named("Edit|Write").files()
22
+ )
23
+
24
+
25
+ gate(
26
+ "You changed source files but haven't done a review pass. Before stopping, review your "
27
+ "changes for correctness and against STYLEGUIDE.md, and fix any issues in the code you "
28
+ "wrote. See: STYLEGUIDE.md.",
29
+ only_if=[EditedSource()],
30
+ skip_if=[Waiting()],
31
+ events=Event.Stop,
32
+ tests={
33
+ Input(transcript=[
34
+ {"type": "assistant", "message": {"content": [
35
+ {"type": "tool_use", "name": "Edit", "id": "e1",
36
+ "input": {"file_path": "src/app.py", "old_string": "a", "new_string": "b"}}]}},
37
+ ]): Block(),
38
+ Input(transcript=[
39
+ {"type": "assistant", "message": {"content": [
40
+ {"type": "tool_use", "name": "Edit", "id": "e1",
41
+ "input": {"file_path": "README.md", "old_string": "a", "new_string": "b"}}]}},
42
+ ]): Allow(),
43
+ },
44
+ )