capt-hook 3.1.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.
- {capt_hook-3.1.0 → capt_hook-3.2.0}/PKG-INFO +1 -1
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/cli.py +88 -3
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/loader.py +18 -0
- capt_hook-3.2.0/captain_hook/packs/__init__.py +1 -0
- capt_hook-3.2.0/captain_hook/packs/general/capt-hook.toml +4 -0
- capt_hook-3.2.0/captain_hook/packs/general/commands.py +71 -0
- capt_hook-3.2.0/captain_hook/packs/general/docs.py +23 -0
- capt_hook-3.2.0/captain_hook/packs/general/plans.py +69 -0
- capt_hook-3.2.0/captain_hook/packs/general/prompts.py +33 -0
- capt_hook-3.2.0/captain_hook/packs/general/review.py +44 -0
- capt_hook-3.2.0/captain_hook/packs/general/stewardship.py +269 -0
- capt_hook-3.2.0/captain_hook/packs/general/tasks.py +147 -0
- capt_hook-3.2.0/captain_hook/packs/manager.py +233 -0
- capt_hook-3.2.0/captain_hook/packs/python/capt-hook.toml +4 -0
- capt_hook-3.2.0/captain_hook/packs/python/style.py +147 -0
- capt_hook-3.2.0/captain_hook/packs/python/testing.py +87 -0
- capt_hook-3.2.0/captain_hook/packs/python/toolchain.py +32 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/pyproject.toml +1 -1
- {capt_hook-3.1.0 → capt_hook-3.2.0}/LICENSE +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/README.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/__main__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/app.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/command.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/conditions.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/context.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/decisions.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/events.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/file.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/log.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/prompt.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/py.typed +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/store.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/session.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/settings.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/state.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/style/types.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/tasks.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/tests/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/tests/helpers.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/types.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.2.0}/captain_hook/utils.py +0 -0
|
@@ -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
|
-
|
|
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,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
|
+
)
|