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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {capt_hook-7.0.0 → capt_hook-8.1.0}/PKG-INFO +1 -1
  2. capt_hook-8.1.0/captain_hook/packs/general/capt-hook.toml +5 -0
  3. capt_hook-8.1.0/captain_hook/packs/general/comments.py +160 -0
  4. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/models.py +145 -0
  5. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/prompts.py +10 -0
  6. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/tasks.py +15 -1
  7. capt_hook-8.1.0/captain_hook/packs/steering/capt-hook.toml +5 -0
  8. capt_hook-8.1.0/captain_hook/packs/steering/steering.py +731 -0
  9. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/primitives/commands.py +1 -1
  10. {capt_hook-7.0.0 → capt_hook-8.1.0}/pyproject.toml +1 -1
  11. capt_hook-7.0.0/captain_hook/packs/general/capt-hook.toml +0 -5
  12. capt_hook-7.0.0/captain_hook/packs/steering/capt-hook.toml +0 -5
  13. capt_hook-7.0.0/captain_hook/packs/steering/steering.py +0 -350
  14. {capt_hook-7.0.0 → capt_hook-8.1.0}/LICENSE +0 -0
  15. {capt_hook-7.0.0 → capt_hook-8.1.0}/README.md +0 -0
  16. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  17. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/__init__.py +0 -0
  18. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/__main__.py +0 -0
  19. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/app.py +0 -0
  20. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/ast_grep.py +0 -0
  21. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/classifiers/__init__.py +0 -0
  22. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/classifiers/conductor.py +0 -0
  23. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/classifiers/droid.py +0 -0
  24. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/classifiers/native.py +0 -0
  25. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/cli.py +0 -0
  26. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/conditions.py +0 -0
  27. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/context.py +0 -0
  28. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/contexts.py +0 -0
  29. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/decisions.py +0 -0
  30. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/dispatch.py +0 -0
  31. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/durable.py +0 -0
  32. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/events.py +0 -0
  33. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/fields.py +0 -0
  34. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/file.py +0 -0
  35. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/loader.py +0 -0
  36. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/log.py +0 -0
  37. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/__init__.py +0 -0
  38. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/fixes/__init__.py +0 -0
  39. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/fixes/capt-hook.toml +0 -0
  40. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/fixes/teammate_permissions.py +0 -0
  41. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/__init__.py +0 -0
  42. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/_lib.py +0 -0
  43. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/commands.py +0 -0
  44. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/detours.py +0 -0
  45. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/docs.py +0 -0
  46. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/plans.py +0 -0
  47. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/review.py +0 -0
  48. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/general/tombstones.py +0 -0
  49. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/go/__init__.py +0 -0
  50. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  51. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/go/testing.py +0 -0
  52. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/go/toolchain.py +0 -0
  53. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/manager.py +0 -0
  54. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/python/__init__.py +0 -0
  55. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  56. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/python/style.py +0 -0
  57. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/python/testing.py +0 -0
  58. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/python/toolchain.py +0 -0
  59. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/packs/steering/__init__.py +0 -0
  60. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/primitives/__init__.py +0 -0
  61. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/primitives/lint.py +0 -0
  62. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/primitives/llm.py +0 -0
  63. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/primitives/nudge.py +0 -0
  64. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/primitives/permissions.py +0 -0
  65. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/primitives/rewrite.py +0 -0
  66. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/primitives/workflow.py +0 -0
  67. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/prompt.py +0 -0
  68. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/py.typed +0 -0
  69. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/__init__.py +0 -0
  70. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/cli.py +0 -0
  71. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/dashboard.py +0 -0
  72. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/fix.py +0 -0
  73. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/formats.py +0 -0
  74. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/judge.py +0 -0
  75. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/pipeline.py +0 -0
  76. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/repo.py +0 -0
  77. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/scan.py +0 -0
  78. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/settings.py +0 -0
  79. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/store.py +0 -0
  80. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/review/sync.py +0 -0
  81. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/session.py +0 -0
  82. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/settings.py +0 -0
  83. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/signals/__init__.py +0 -0
  84. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/signals/nlp.py +0 -0
  85. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  86. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  87. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  88. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  89. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  90. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  91. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  92. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  93. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  94. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  95. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  96. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  97. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  98. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/state.py +0 -0
  99. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/style/__init__.py +0 -0
  100. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/style/ast_grep.py +0 -0
  101. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/style/matchers.py +0 -0
  102. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/style/scope.py +0 -0
  103. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/style/types.py +0 -0
  104. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/tasks.py +0 -0
  105. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  106. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/testing/__init__.py +0 -0
  107. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/testing/helpers.py +0 -0
  108. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/testing/session_cache.py +0 -0
  109. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/testing/types.py +0 -0
  110. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/types.py +0 -0
  111. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/util/__init__.py +0 -0
  112. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/util/automode.py +0 -0
  113. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/util/fs.py +0 -0
  114. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/util/http.py +0 -0
  115. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/util/model_cache.py +0 -0
  116. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/util/proc.py +0 -0
  117. {capt_hook-7.0.0 → capt_hook-8.1.0}/captain_hook/util/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 7.0.0
3
+ Version: 8.1.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
@@ -0,0 +1,5 @@
1
+ name = "general"
2
+ version = "0.12.0"
3
+ description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, code-review + docs-freshness gates, a detour check-in nudge, a verbose-comment nudge, and delegated-prose nudges that catch doc writing handed to a subagent or workflow without the writing-docs skill."
4
+ hooks = "."
5
+ nlp = true
@@ -0,0 +1,160 @@
1
+ """Discourage verbose comments: warn when an edit introduces a long comment or line-comment run.
2
+
3
+ Comments should be terse and used sparingly — names, types, and organization carry the
4
+ meaning. The one legitimate exception is documentation-generation comments (godoc, rustdoc,
5
+ docstrings), and even a long doc run trips this warn by design: the threshold is deliberately
6
+ strict so verbosity of any kind gets a nudge. Language-agnostic via the tree-sitter comment
7
+ kinds in :data:`~captain_hook.ast_grep.COMMENT_TYPES`; diff-based, so only comments the edit
8
+ *introduces* count — a pre-existing long comment re-saved unchanged never fires.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ from captain_hook import (
16
+ Allow,
17
+ BaseHookEvent,
18
+ CustomCondition,
19
+ Event,
20
+ FileFixture,
21
+ Input,
22
+ Tool,
23
+ Warn,
24
+ nudge,
25
+ )
26
+ from captain_hook.ast_grep import introduced_comments, lang_for_path
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Iterable
30
+
31
+ from captain_hook.ast_grep import Match
32
+
33
+ MAX_COMMENT_LINES = 4
34
+ MAX_COMMENT_CHARS = 300
35
+
36
+ # Fixtures for the inline tests — kept as module constants so each physical line stays short.
37
+ GO_NO_COMMENT = "package p\n\nfunc F() {}\n"
38
+ GO_ONE_LINE_DOC = "package p\n\n// F does a thing.\nfunc F() {}\n"
39
+ GO_THREE_LINE_DOC = (
40
+ "package p\n\n// F does a thing.\n// It handles the empty case.\n// Returns an error otherwise.\nfunc F() {}\n"
41
+ )
42
+ GO_LONG_RUN = (
43
+ "package p\n\nfunc F() {\n"
44
+ "\t// explanation line one goes here\n"
45
+ "\t// explanation line two goes here\n"
46
+ "\t// explanation line three is here\n"
47
+ "\t// explanation line four is here\n"
48
+ "\t// explanation line five is here\n"
49
+ "\t// explanation line six is here ok\n"
50
+ "\tx := 1\n}\n"
51
+ )
52
+ GO_EXISTING_OLD = "func F() {\n\t// aaa\n\t// bbb\n\t// ccc\n\t// ddd\n\t// eee\n\t// fff\n\tx := 1\n}\n"
53
+ GO_EXISTING_NEW = "func F() {\n\t// aaa\n\t// bbb\n\t// ccc\n\t// ddd\n\t// eee\n\t// fff\n\tx := 2\n}\n"
54
+ GO_WRITE_PLAIN = "package p\n\nvar x = 1\n"
55
+ GO_WRITE_RUN = (
56
+ "package p\n\n"
57
+ "// note one here\n// note two here\n// note three here\n"
58
+ "// note four here\n// note five here\n// note six here\n"
59
+ "var x = 1\n"
60
+ )
61
+ GO_WRITE_EXISTING_OLD = "package p\n\n// aaa\n// bbb\n// ccc\n// ddd\n// eee\n// fff\nvar x = 1\n"
62
+ GO_WRITE_EXISTING_NEW = "package p\n\n// aaa\n// bbb\n// ccc\n// ddd\n// eee\n// fff\nvar x = 2\n"
63
+ RS_LONG_BLOCK = "/*\n one\n two\n three\n four\n five\n six\n seven\n eight\n*/\nfn f() {}\n"
64
+ RS_LONG_DOC = (
65
+ "/// line one of the rustdoc here\n"
66
+ "/// line two of the rustdoc here\n"
67
+ "/// line three of the rustdoc\n"
68
+ "/// line four of the rustdoc\n"
69
+ "/// line five of the rustdoc\n"
70
+ "/// line six of the rustdoc ok\n"
71
+ "pub fn f() {}\n"
72
+ )
73
+ PY_LONG_RUN = (
74
+ "# note one here\n# note two here\n# note three here\n# note four here\n# note five here\n# note six here\nx = 1\n"
75
+ )
76
+ PY_LONG_DOCSTRING = (
77
+ 'def f():\n """\n'
78
+ " line one\n line two\n line three\n"
79
+ " line four\n line five\n line six\n"
80
+ ' """\n return 1\n'
81
+ )
82
+
83
+
84
+ def runs(matches: Iterable[Match]) -> list[list[Match]]:
85
+ """Group comments into runs of adjacent lines — a block comment, or consecutive line
86
+ comments with no code between — each a list of :class:`Match` in document order."""
87
+ grouped: list[list[Match]] = []
88
+ for m in sorted(matches, key=lambda c: c.line):
89
+ if grouped and m.line <= grouped[-1][-1].end_line + 1:
90
+ grouped[-1].append(m)
91
+ else:
92
+ grouped.append([m])
93
+ return grouped
94
+
95
+
96
+ def too_long(run: list[Match]) -> bool:
97
+ """Whether a comment run exceeds the line or character budget."""
98
+ lines = run[-1].end_line - run[0].line + 1
99
+ chars = sum(len(m.text) for m in run)
100
+ return lines > MAX_COMMENT_LINES or chars > MAX_COMMENT_CHARS
101
+
102
+
103
+ class LongCommentIntroduced(CustomCondition):
104
+ """True when the pending edit introduces a comment (or adjacent line-comment run) past
105
+ the length budget. Diffs the edit's pre-image against its new text, so a comment already
106
+ present before the edit never counts; files whose language ast-grep can't parse yield False."""
107
+
108
+ def check(self, evt: BaseHookEvent) -> bool:
109
+ if (
110
+ not (file := evt.file)
111
+ or not (lang := lang_for_path(file.path))
112
+ or (old := evt.replaced) is None
113
+ or (new := evt.content) is None
114
+ ):
115
+ return False
116
+ return any(too_long(run) for run in runs(introduced_comments(old, new, lang)))
117
+
118
+
119
+ nudge(
120
+ "Verbose comment introduced. Comments should be terse and used sparingly — let names, "
121
+ "types, and organization document the code. Keep documentation-generation comments "
122
+ "(godoc / rustdoc / docstrings) to a real description, and drop long inline commentary "
123
+ "or anything that restates the code. See: STYLEGUIDE.md § Comments.",
124
+ only_if=[Tool("Edit", "Write", "MultiEdit"), LongCommentIntroduced()],
125
+ events=Event.PreToolUse,
126
+ tests={
127
+ # Short / within-budget comments — allowed.
128
+ Input(file="doc.go", old=GO_NO_COMMENT, content=GO_ONE_LINE_DOC): Allow(),
129
+ Input(file="doc.go", old=GO_NO_COMMENT, content=GO_THREE_LINE_DOC): Allow(),
130
+ Input(file="m.py", old="x = 1\n", content="# set x\nx = 1\n"): Allow(),
131
+ Input(file="lib.rs", old="pub fn f() {}\n", content="/// Builds a widget.\npub fn f() {}\n"): Allow(),
132
+ # Long introduced comment runs — warned.
133
+ Input(file="svc.go", old="package p\n\nfunc F() {\n\tx := 1\n}\n", content=GO_LONG_RUN): Warn(
134
+ pattern="Verbose comment"
135
+ ), # 6-line // run > 4 lines
136
+ Input(file="big.go", old=GO_NO_COMMENT, content="package p\n\n// " + "x" * 320 + "\nfunc F() {}\n"): Warn(
137
+ pattern="Verbose comment"
138
+ ), # single line, > 300 chars
139
+ Input(file="lib.rs", old="fn f() {}\n", content=RS_LONG_BLOCK): Warn(pattern="Verbose comment"),
140
+ Input(file="lib.rs", old="pub fn f() {}\n", content=RS_LONG_DOC): Warn(
141
+ pattern="Verbose comment"
142
+ ), # long rustdoc run also warns (strict threshold, by design)
143
+ Input(file="m.py", old="x = 1\n", content=PY_LONG_RUN): Warn(pattern="Verbose comment"),
144
+ # Diff-gating: a pre-existing long run re-saved unchanged does not fire.
145
+ Input(file="svc.go", old=GO_EXISTING_OLD, content=GO_EXISTING_NEW): Allow(),
146
+ # Python docstrings are string nodes, not comments — never trip this.
147
+ Input(file="m.py", old="def f():\n return 1\n", content=PY_LONG_DOCSTRING): Allow(),
148
+ # Write tool: pre-image comes from disk at PreToolUse.
149
+ Input(
150
+ tool="Write",
151
+ file=FileFixture(name="cmt_new.go", content=GO_WRITE_PLAIN),
152
+ content=GO_WRITE_RUN,
153
+ ): Warn(pattern="Verbose comment"), # long run added to existing file
154
+ Input(
155
+ tool="Write",
156
+ file=FileFixture(name="cmt_pre.go", content=GO_WRITE_EXISTING_OLD),
157
+ content=GO_WRITE_EXISTING_NEW,
158
+ ): Allow(), # run already on disk: not introduced
159
+ },
160
+ )
@@ -732,3 +732,148 @@ Synthesis/accept-reject stays on fable.
732
732
  ): Allow(),
733
733
  },
734
734
  )
735
+
736
+ llm_nudge(
737
+ """Decide whether this delegated subagent call delegates documentation or prose
738
+ writing without directing the subagent to read the writing-docs skill.
739
+
740
+ <delegated_spawn> holds the pending Agent/Task call: its model pin, agent type, and
741
+ prompt, ending with the sentences a clause prefilter matched — each asks a writing verb
742
+ of a prose artifact, with negated asks ("do NOT edit the docs") already screened out.
743
+
744
+ You are watching an orchestrating agent spawn a subagent. Decide whether the pending
745
+ prompt delegates documentation or prose writing — a README, docs page, CHANGELOG,
746
+ tutorial, release notes, or similar deliverable — without directing the subagent to
747
+ read the writing-docs skill. Restated style rules ('technical-builder voice', 'no hype
748
+ adjectives', 'first person, confident') do not count as reading the skill; that
749
+ paraphrase is exactly the failure to catch. Fire only when the prompt's deliverable is
750
+ prose the writing-docs skill governs. Do not fire for code work that incidentally
751
+ mentions a doc file, for reading or reviewing docs without writing them, or for a prompt
752
+ that already tells the agent to read the skill or its references.
753
+
754
+ When uncertain, fire=false — a false alarm teaches the agent to ignore this nudge. Keep
755
+ reasoning under 40 words.
756
+
757
+ <examples>
758
+ <example fire="true">
759
+ Rewrite the README for this repo. You are fable; technical-builder voice, no hype adjectives.
760
+ README prose with the style rules paraphrased in place of the skill — the drift this catches.
761
+ </example>
762
+ <example fire="true">
763
+ Draft the release notes for v2. Keep it first-person and confident, no marketing fluff.
764
+ Release-notes prose; restated voice rules, no pointer to the writing-docs skill.
765
+ </example>
766
+ <example fire="false">
767
+ Fix the failing test in cli.py; the README already documents the new flag.
768
+ Code work that only mentions the README — no prose is produced.
769
+ </example>
770
+ <example fire="false">
771
+ Rewrite the README, but read the doc-writing skill and its references first.
772
+ Already directs the subagent to the skill — nothing to nudge.
773
+ </example>
774
+ </examples>""",
775
+ message=lambda r: (
776
+ "This prompt delegates prose but paraphrases the writing rules instead of pointing at them. "
777
+ f"{r.reasoning} A paraphrase drifts and silently overrides the skill — rewrite the prompt to "
778
+ "direct the agent to READ the writing-docs skill and its references (the installed plugin under "
779
+ "~/.claude/plugins/cache/skills/writing-docs, or plugins/writing-docs in the cc-skills repo) "
780
+ "before it writes."
781
+ ),
782
+ contexts=[ProseSpawn()],
783
+ events=Event.PreToolUse,
784
+ only_if=[Tool("Agent|Task")],
785
+ skip_if=[
786
+ ToolInput("prompt", r"(?i)writing-docs"),
787
+ Agent("Explore|claude-code-guide"),
788
+ ],
789
+ max_context=16_000,
790
+ agent=False,
791
+ transcript=False,
792
+ tests={
793
+ Input(
794
+ prompt="Rewrite the README of /repo. You are fable; technical-builder voice, no hype "
795
+ "adjectives. Verify commands against the binary."
796
+ ): Warn(pattern="writing-docs"),
797
+ Input(
798
+ prompt="Rewrite the README of /repo; technical-builder voice, no hype adjectives. Read "
799
+ "the writing-docs skill at ~/.claude/plugins/cache/skills/writing-docs first."
800
+ ): Allow(),
801
+ Input(prompt="Fix the race in daemon.go; update the failing test"): Allow(),
802
+ Input(
803
+ prompt="Rewrite the README, but read the doc-writing skill and its references first",
804
+ llm={"fire": False},
805
+ ): Allow(),
806
+ },
807
+ )
808
+
809
+ llm_nudge(
810
+ """Decide whether this workflow script delegates documentation or prose writing to an
811
+ agent() stage without directing that subagent to read the writing-docs skill.
812
+
813
+ <workflow_script> holds the pending Workflow call's script source, headed by every line
814
+ that pins a model, followed by the sentences a clause prefilter matched — each asks a
815
+ writing verb of a prose artifact, with negated asks ("do NOT edit CHANGELOG.md") already
816
+ screened out.
817
+
818
+ Decide whether an agent() prompt delegates documentation or prose writing — a README,
819
+ docs page, CHANGELOG, tutorial, release notes, or similar deliverable — without
820
+ directing that subagent to read the writing-docs skill. Restated style rules
821
+ ('technical-builder voice', 'no hype adjectives', 'first person, confident') do not
822
+ count as reading the skill; that paraphrase is exactly the failure to catch. Fire only
823
+ when a stage's deliverable is prose the writing-docs skill governs. Do not fire for code
824
+ work that incidentally mentions a doc file, for reading or reviewing docs without writing
825
+ them, or for a stage that already tells its subagent to read the skill or its references.
826
+
827
+ When uncertain, fire=false — a false alarm teaches the agent to ignore this nudge. Keep
828
+ reasoning under 40 words and name the offending stage.
829
+
830
+ <examples>
831
+ <example fire="true">
832
+ agent('Rewrite the README for the new CLI. Technical-builder voice, no hype adjectives', {model: 'opus'})
833
+ The stage's deliverable is README prose with the style rules paraphrased in place of the skill.
834
+ </example>
835
+ <example fire="true">
836
+ agent(`Draft the docs-site page for ${feature}. First-person, confident, no marketing fluff`)
837
+ A docs page delegated with restated voice rules and no pointer to the writing-docs skill.
838
+ </example>
839
+ <example fire="false">
840
+ agent('Fix the failing import in cli.py; the README already documents the flag', {model: 'opus'})
841
+ Code work that only mentions the README — no prose is produced.
842
+ </example>
843
+ <example fire="false">
844
+ agent('Rewrite the troubleshooting guide, but read the doc-writing skill and its references first')
845
+ The stage already directs its subagent to the skill — nothing to nudge.
846
+ </example>
847
+ </examples>""",
848
+ message=lambda r: (
849
+ "This workflow script delegates prose but paraphrases the writing rules instead of pointing at "
850
+ f"them. {r.reasoning} A paraphrase drifts and silently overrides the skill — rewrite the offending "
851
+ "agent() prompt to direct its subagent to READ the writing-docs skill and its references (the "
852
+ "installed plugin under ~/.claude/plugins/cache/skills/writing-docs, or plugins/writing-docs in "
853
+ "the cc-skills repo) before it writes."
854
+ ),
855
+ contexts=[ProseWorkflowScript()],
856
+ events=Event.PreToolUse,
857
+ only_if=[Tool("Workflow")],
858
+ skip_if=[WorkflowScript(pattern=r"(?i)writing-docs")],
859
+ max_fires=2,
860
+ max_context=16_000,
861
+ agent=False,
862
+ transcript=False,
863
+ tests={
864
+ Input(
865
+ script="agent('Rewrite the README. You are fable; technical-builder voice, no hype "
866
+ "adjectives', {model: 'opus'})"
867
+ ): Warn(pattern="writing-docs"),
868
+ Input(
869
+ script="agent('Rewrite the README per the writing-docs skill at "
870
+ "~/.claude/plugins/cache/skills/writing-docs', {model: 'opus'})"
871
+ ): Allow(),
872
+ Input(script="agent('Fix the race in daemon.go; update the failing test')"): Allow(),
873
+ Input(
874
+ script="agent('Rewrite the README, but read the doc-writing skill and its references "
875
+ "first', {model: 'opus'})",
876
+ llm={"fire": False},
877
+ ): Allow(),
878
+ },
879
+ )
@@ -24,6 +24,7 @@ nudge(
24
24
  skip_if=[
25
25
  UsedSkill("llm-prompts"),
26
26
  UsedSkill("slop-cop-check", "slop-cop-prose"),
27
+ Content(r"\.count_tokens\(", project_only=False),
27
28
  ],
28
29
  tests={
29
30
  Input(
@@ -31,5 +32,14 @@ nudge(
31
32
  ): Warn(),
32
33
  Input(file="prompt.md", content="<instruction>\nSummarize the document.\n</instruction>\n"): Warn(),
33
34
  Input(file="util.py", content="def add(a, b):\n return a + b\n"): Allow(),
35
+ Input(
36
+ file="tokens.py",
37
+ content=(
38
+ "def api_count(text: str, model: str) -> int:\n"
39
+ " resp = client.messages.count_tokens(\n"
40
+ ' model=model,\n messages=[{"role": "user", "content": text or " "}],\n'
41
+ " )\n return int(resp.input_tokens)\n"
42
+ ),
43
+ ): Allow(),
34
44
  },
35
45
  )
@@ -55,6 +55,13 @@ class DriftedFromTasks(CustomCondition):
55
55
  return since.tool_calls.named("Bash|Grep|Glob|WebSearch|WebFetch|LSP|Skill").count() >= TASK_DRIFT_THRESHOLD
56
56
 
57
57
 
58
+ class TaskNotification(CustomCondition):
59
+ """Matches when the prompt is a system-injected background-task report, not free-form user text."""
60
+
61
+ def check(self, evt: BaseHookEvent) -> bool:
62
+ return (evt.user_prompt or "").strip().startswith("<task-notification>")
63
+
64
+
58
65
  gate(
59
66
  "Open tasks remain. Before stopping, mark each finished task status='completed' via the "
60
67
  "TaskUpdate tool (add a note if you're deliberately deferring one), or output "
@@ -120,7 +127,7 @@ nudge(
120
127
  nudge(
121
128
  "This message has several distinct requests. Use the TaskCreate tool for each item "
122
129
  "before starting work, so none gets dropped. See: CLAUDE.md § Task Tracking.",
123
- skip_if=[InPlanMode()],
130
+ skip_if=[InPlanMode(), TaskNotification()],
124
131
  events=Event.UserPromptSubmit,
125
132
  signals=Signals(
126
133
  [
@@ -141,5 +148,12 @@ nudge(
141
148
  Input(prompt="1. add foo\n2. fix bar\n3. update baz"): Warn(),
142
149
  Input(prompt="just fix the typo"): Allow(),
143
150
  Input(prompt="1. add foo\n2. fix bar\n3. update baz", permission_mode="plan"): Allow(),
151
+ Input(
152
+ prompt=(
153
+ "<task-notification>\n<task-id>abc</task-id>\n<status>completed</status>\n"
154
+ "<summary>Agent finished</summary>\n<result>\n- `cc-transcript` -> version 7.0.1\n"
155
+ "- `spawnllm` -> version 0.5.2\n</result>\n</task-notification>"
156
+ )
157
+ ): Allow(),
144
158
  },
145
159
  )
@@ -0,0 +1,5 @@
1
+ name = "steering"
2
+ version = "0.4.0"
3
+ description = "Steer the agent's judgment on what's worth fixing: prefer fundamental, first-principles fixes over band-aids, don't dismiss a real pre-existing issue, don't chase trivial type noise, and don't defer the requested fix by swapping in docs or a follow-up nobody asked for."
4
+ hooks = "."
5
+ nlp = true