capt-hook 6.1.0__tar.gz → 6.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 (108) hide show
  1. {capt_hook-6.1.0 → capt_hook-6.2.0}/PKG-INFO +1 -1
  2. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/__init__.py +1 -0
  3. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/conditions.py +6 -0
  4. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/capt-hook.toml +1 -1
  5. capt_hook-6.2.0/captain_hook/packs/general/models.py +378 -0
  6. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/SKILL.md +3 -1
  7. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +6 -3
  8. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +1 -1
  9. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/testing/helpers.py +25 -17
  10. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/testing/types.py +4 -0
  11. {capt_hook-6.1.0 → capt_hook-6.2.0}/pyproject.toml +1 -1
  12. capt_hook-6.1.0/captain_hook/packs/general/models.py +0 -221
  13. {capt_hook-6.1.0 → capt_hook-6.2.0}/LICENSE +0 -0
  14. {capt_hook-6.1.0 → capt_hook-6.2.0}/README.md +0 -0
  15. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  16. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/__main__.py +0 -0
  17. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/app.py +0 -0
  18. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/ast_grep.py +0 -0
  19. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/classifiers/__init__.py +0 -0
  20. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/classifiers/conductor.py +0 -0
  21. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/classifiers/droid.py +0 -0
  22. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/classifiers/native.py +0 -0
  23. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/cli.py +0 -0
  24. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/command.py +0 -0
  25. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/context.py +0 -0
  26. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/contexts.py +0 -0
  27. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/decisions.py +0 -0
  28. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/dispatch.py +0 -0
  29. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/durable.py +0 -0
  30. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/events.py +0 -0
  31. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/fields.py +0 -0
  32. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/file.py +0 -0
  33. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/loader.py +0 -0
  34. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/log.py +0 -0
  35. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/__init__.py +0 -0
  36. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/__init__.py +0 -0
  37. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/_lib.py +0 -0
  38. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/commands.py +0 -0
  39. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/docs.py +0 -0
  40. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/plans.py +0 -0
  41. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/prompts.py +0 -0
  42. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/review.py +0 -0
  43. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/tasks.py +0 -0
  44. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/general/tombstones.py +0 -0
  45. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/go/__init__.py +0 -0
  46. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  47. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/go/testing.py +0 -0
  48. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/go/toolchain.py +0 -0
  49. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/manager.py +0 -0
  50. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/python/__init__.py +0 -0
  51. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  52. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/python/style.py +0 -0
  53. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/python/testing.py +0 -0
  54. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/python/toolchain.py +0 -0
  55. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/steering/__init__.py +0 -0
  56. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  57. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/packs/steering/steering.py +0 -0
  58. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/primitives/__init__.py +0 -0
  59. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/primitives/commands.py +0 -0
  60. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/primitives/lint.py +0 -0
  61. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/primitives/llm.py +0 -0
  62. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/primitives/nudge.py +0 -0
  63. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/primitives/rewrite.py +0 -0
  64. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/primitives/workflow.py +0 -0
  65. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/prompt.py +0 -0
  66. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/py.typed +0 -0
  67. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/__init__.py +0 -0
  68. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/cli.py +0 -0
  69. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/dashboard.py +0 -0
  70. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/fix.py +0 -0
  71. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/formats.py +0 -0
  72. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/judge.py +0 -0
  73. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/pipeline.py +0 -0
  74. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/repo.py +0 -0
  75. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/scan.py +0 -0
  76. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/settings.py +0 -0
  77. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/store.py +0 -0
  78. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/review/sync.py +0 -0
  79. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/session.py +0 -0
  80. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/settings.py +0 -0
  81. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/signals/__init__.py +0 -0
  82. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/signals/nlp.py +0 -0
  83. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  84. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  85. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  86. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  87. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  88. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  89. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  90. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  91. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  92. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  93. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/state.py +0 -0
  94. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/style/__init__.py +0 -0
  95. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/style/ast_grep.py +0 -0
  96. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/style/matchers.py +0 -0
  97. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/style/scope.py +0 -0
  98. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/style/types.py +0 -0
  99. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/tasks.py +0 -0
  100. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  101. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/testing/__init__.py +0 -0
  102. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/testing/session_cache.py +0 -0
  103. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/types.py +0 -0
  104. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/util/__init__.py +0 -0
  105. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/util/fs.py +0 -0
  106. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/util/http.py +0 -0
  107. {capt_hook-6.1.0 → capt_hook-6.2.0}/captain_hook/util/model_cache.py +0 -0
  108. {capt_hook-6.1.0 → capt_hook-6.2.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.1.0
3
+ Version: 6.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
@@ -23,6 +23,7 @@ from captain_hook import file, style, util
23
23
  from captain_hook.app import hook, on
24
24
  from captain_hook.ast_grep import COMMENT_TYPES
25
25
  from captain_hook.command import CommandLine, ParsedCommand, Redirect
26
+ from captain_hook.conditions import workflow_opt_values, workflow_script_source
26
27
  from captain_hook.context import HookContext
27
28
  from captain_hook.contexts import AfterEdit, BeforeEdit, Introduced, PromptContext, apply_contexts
28
29
  from captain_hook.durable import DurableSlot, DurableState, DurableStore
@@ -87,6 +87,12 @@ def is_waiting(evt: BaseHookEvent) -> bool:
87
87
 
88
88
 
89
89
  def workflow_script_source(evt: BaseHookEvent) -> str | None:
90
+ """The pending ``Workflow`` call's script source, or ``None``.
91
+
92
+ Returns the inline ``script`` when present, else reads the file at
93
+ ``scriptPath``. A non-Workflow event, a missing/unreadable path, or a file
94
+ over ~1 MiB yields ``None``; never raises.
95
+ """
90
96
  if not (call := evt.as_input(WorkflowCall)):
91
97
  return None
92
98
  if call.script is not None:
@@ -1,5 +1,5 @@
1
1
  name = "general"
2
- version = "0.6.0"
2
+ version = "0.7.0"
3
3
  description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, and code-review + docs-freshness gates."
4
4
  hooks = "."
5
5
  nlp = true
@@ -0,0 +1,378 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from captain_hook import (
6
+ Agent,
7
+ Allow,
8
+ BaseHookEvent,
9
+ Block,
10
+ Event,
11
+ Input,
12
+ Rewrite,
13
+ TaskCall,
14
+ Tool,
15
+ ToolInput,
16
+ Warn,
17
+ WorkflowScript,
18
+ hook,
19
+ llm_gate,
20
+ llm_nudge,
21
+ nudge,
22
+ set_tool_input,
23
+ workflow_opt_values,
24
+ workflow_script_source,
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class DelegatedSpawn:
30
+ """Gating context: the pending Agent/Task call's model pin, agent type, and prompt."""
31
+
32
+ tag: str = "delegated_spawn"
33
+ required: bool = True
34
+
35
+ def content(self, evt: BaseHookEvent) -> str | None:
36
+ if (call := evt.as_input(TaskCall)) is None or not call.prompt:
37
+ return None
38
+ model = call.model or "(none — inherits the session model, fable)"
39
+ return f"model: {model}\nagent_type: {call.agent_type or '(default)'}\nprompt:\n{call.prompt}"
40
+
41
+
42
+ WORKFLOW_SCRIPT_CAP = 14_000 # below the workflow prose nudge's max_context=16_000, so truncation stays ours
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class WorkflowScriptSource:
47
+ """Gating context: the pending Workflow call's script source, headed by its model pins."""
48
+
49
+ tag: str = "workflow_script"
50
+ required: bool = True
51
+
52
+ def content(self, evt: BaseHookEvent) -> str | None:
53
+ if (source := workflow_script_source(evt)) is None:
54
+ return None
55
+ pin_lines = "\n".join(
56
+ f" {line.strip()[:200]}" for line in source.splitlines() if workflow_opt_values(line, "model")
57
+ )
58
+ if len(source) > WORKFLOW_SCRIPT_CAP:
59
+ head, tail = WORKFLOW_SCRIPT_CAP * 3 // 4, WORKFLOW_SCRIPT_CAP // 4
60
+ source = (
61
+ f"{source[:head]}\n"
62
+ f"… [script truncated: {len(source):,} chars total; every model pin is quoted above] …\n"
63
+ f"{source[-tail:]}"
64
+ )
65
+ return (
66
+ "lines that pin a model in this script (a stage not quoted here inherits the "
67
+ f"session model, fable):\n{pin_lines or ' (none)'}\n\n{source}"
68
+ )
69
+
70
+
71
+ hook(
72
+ Event.PreToolUse,
73
+ only_if=[Tool("Agent|Task"), ToolInput("model", r"(?i)\bhaiku\b")],
74
+ skip_if=[
75
+ ToolInput(
76
+ "prompt",
77
+ r"(?i)\b(classif|label|tag|categoriz|per.file|one (fact|thing)|mechanical|probe|ping"
78
+ r"|echo|smoke|count|capacit|extract)",
79
+ )
80
+ ],
81
+ message=(
82
+ "This subagent is pinned to haiku. Route a subagent to haiku only for a single-fact "
83
+ "mechanical step — classifying, labeling, tagging, counting, or probing one thing per "
84
+ "item. Anything that carries judgment should run on sonnet; use model='sonnet', or drop "
85
+ "model entirely to inherit the session model. If this genuinely is a mechanical "
86
+ "single-fact step, say 'mechanical' in the prompt and retry — the haiku pin will be "
87
+ "allowed. See CLAUDE.md § Plan Execution & Orchestration (Models)."
88
+ ),
89
+ block=True,
90
+ tests={
91
+ Input(model="haiku", prompt="implement the retry backoff in the client"): Block(),
92
+ Input(model="haiku", prompt="classify each file's language"): Allow(),
93
+ Input(model="haiku", prompt="Probe subagent capacity: spawn and return the word ok"): Allow(),
94
+ Input(model="haiku", prompt="mechanical step: return the repo's default branch name"): Allow(),
95
+ Input(model="haiku", prompt="count the TODO markers in src/"): Allow(),
96
+ Input(prompt="implement the retry backoff in the client"): Allow(),
97
+ Input(model="sonnet", prompt="implement the retry backoff in the client"): Allow(),
98
+ },
99
+ )
100
+
101
+ llm_gate(
102
+ """Decide whether this delegated subagent call pins a non-fable model on work whose
103
+ deliverable is prose.
104
+
105
+ <delegated_spawn> holds the pending Agent/Task call: its model pin, agent type, and
106
+ prompt. A regex prefilter already found a haiku/sonnet/opus pin plus a writing verb
107
+ near a prose noun in the prompt; your job is precision: is a prose artifact what this
108
+ subagent is asked to PRODUCE?
109
+
110
+ The Models rubric: all writing a user reads — READMEs, docs, changelogs, release
111
+ notes, blog posts, PR descriptions, any user-facing text — routes to fable. Work
112
+ that merely mentions a prose file — as a constraint ("do NOT touch the docs"), as
113
+ reading material, as the subject of recon or review — is not prose work.
114
+
115
+ Set block=true only when the prompt clearly asks the subagent to write, draft,
116
+ revise, or polish a prose artifact. Recon, review, classification, and code work
117
+ that references docs stay allowed: block=false. When uncertain, block=false — a
118
+ wrong block stops legitimate work cold. Keep reasoning under 40 words.
119
+
120
+ <examples>
121
+ <example block="true">
122
+ model: sonnet — Write the README quickstart for this repo.
123
+ The deliverable is README prose on a non-fable pin.
124
+ </example>
125
+ <example block="true">
126
+ model: opus — Update CHANGELOG.md with an entry for the retry fix.
127
+ The subagent itself writes the changelog prose.
128
+ </example>
129
+ <example block="false">
130
+ model: opus — Fix the failing test in cli.py. Do NOT edit CHANGELOG.md — a sibling owns it.
131
+ CHANGELOG is a constraint, not the deliverable; this is code work.
132
+ </example>
133
+ <example block="false">
134
+ model: sonnet — Explore the cc-interact building blocks and report where the docs pipeline is written.
135
+ Read-only recon that mentions docs; nothing user-facing is produced.
136
+ </example>
137
+ <example block="false">
138
+ model: sonnet — Review the README draft for factual errors and list them.
139
+ Review findings go to the orchestrator; the subagent writes no user-facing prose.
140
+ </example>
141
+ </examples>""",
142
+ message=lambda r: (
143
+ "This subagent is pinned to a non-fable model but its deliverable is prose/writing work. "
144
+ f"{r.reasoning} All writing — docs, READMEs, release notes, any user-facing text — routes "
145
+ "to fable: drop model to inherit the session model, or pass model='fable'. "
146
+ "See CLAUDE.md § Plan Execution & Orchestration (Models)."
147
+ ),
148
+ contexts=[DelegatedSpawn()],
149
+ events=Event.PreToolUse,
150
+ only_if=[
151
+ Tool("Agent|Task"),
152
+ ToolInput("model", r"(?i)\b(haiku|sonnet|opus)\b"),
153
+ ToolInput(
154
+ "prompt",
155
+ r"(?i)\b(?:writ(?:e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)\w*"
156
+ r"\W+(?:\w+\W+){0,4}(?:readme|docs?|documentation|blog|changelog|release notes|announcement"
157
+ r"|tutorial|guide|prose|copywrit|marketing copy|article|newsletter|email"
158
+ r"|pr description|commit message)\b"
159
+ r"|\b(?:readme|docs?|documentation|blog|changelog|release notes|announcement"
160
+ r"|tutorial|guide|prose|copywrit|marketing copy|article|newsletter|email"
161
+ r"|pr description|commit message)\b"
162
+ r"\W+(?:\w+\W+){0,4}(?:writ(?:e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)",
163
+ ),
164
+ ],
165
+ skip_if=[
166
+ ToolInput("prompt", r"(?i)\b(classif|label|tag|categoriz|count|extract|mechanical)"),
167
+ Agent("Explore|claude-code-guide"),
168
+ ],
169
+ agent=False,
170
+ transcript=False,
171
+ tests={
172
+ Input(model="sonnet", prompt="Write the README quickstart for this repo"): Block(),
173
+ Input(model="opus", prompt="draft the release notes for v2"): Block(),
174
+ Input(model="haiku", prompt="update the CHANGELOG entry for the fix"): Block(),
175
+ Input(model="fable", prompt="write the README quickstart"): Allow(),
176
+ Input(prompt="write the README quickstart"): Allow(),
177
+ Input(model="sonnet", prompt="review the README for factual errors"): Allow(),
178
+ Input(model="sonnet", prompt="update the retry backoff config"): Allow(),
179
+ Input(model="haiku", prompt="label each README section with its Diataxis mode"): Allow(),
180
+ Input(agent_type="Explore", model="sonnet", prompt="find where the README quickstart is written"): Allow(),
181
+ Input(agent_type="claude-code-guide", model="sonnet", prompt="explain how the docs get updated"): Allow(),
182
+ Input(model="opus", prompt="Update the retry backoff config per the spec in docs/plan.md"): Allow(),
183
+ Input(
184
+ model="opus",
185
+ prompt="Fix the failing test in cli.py. Do NOT edit the CHANGELOG — a sibling owns updating it",
186
+ llm={"block": False},
187
+ ): Allow(),
188
+ },
189
+ )
190
+
191
+ set_tool_input(
192
+ "model",
193
+ "sonnet",
194
+ tool="Agent|Task",
195
+ only_if=[Agent("Explore|claude-code-guide")],
196
+ note=(
197
+ "Upgraded this recon subagent from the silent haiku default to sonnet, per the Models "
198
+ "table in CLAUDE.md § Plan Execution & Orchestration (Models)."
199
+ ),
200
+ tests={
201
+ Input(agent_type="Explore"): Rewrite(model="sonnet"),
202
+ Input(agent_type="Explore", model="haiku"): Allow(),
203
+ Input(agent_type="general-purpose"): Allow(),
204
+ },
205
+ )
206
+
207
+ llm_nudge(
208
+ """Decide whether this delegated subagent should run on opus-4.8 instead of fable-5.
209
+
210
+ <delegated_spawn> holds the pending Agent/Task call: its model pin (or that it inherits
211
+ the session model, fable), agent type, and prompt.
212
+
213
+ The Models rubric: implementation delegates to opus-4.8 at xhigh — opus is ~2x cheaper
214
+ than fable and nearly as capable. Fable's lanes are orchestration, review, hard
215
+ planning/design/diagnosis, all prose/writing, and implementation that is very sensitive
216
+ or error-prone (auth, migrations, concurrency, data loss, crypto, subtle algorithms).
217
+
218
+ Set fire=true only when the prompt is clearly routine implementation — building, fixing,
219
+ wiring, or refactoring code — with no fable-lane signal. A prompt that reviews, plans,
220
+ designs, diagnoses a hard bug, writes prose, or touches a sensitive surface stays on
221
+ fable: fire=false. When uncertain, fire=false — the agent may have chosen fable
222
+ deliberately, and a false alarm teaches it to ignore this nudge. Keep reasoning under
223
+ 40 words.
224
+
225
+ <examples>
226
+ <example fire="true">
227
+ Implement the pagination endpoint in api/users.py per the spec in the plan.
228
+ Routine implementation with no sensitivity signal — the opus xhigh lane.
229
+ </example>
230
+ <example fire="true">
231
+ Add a --json flag to the export command and thread it through the formatter.
232
+ Well-scoped feature wiring; the default implementation lane.
233
+ </example>
234
+ <example fire="false">
235
+ Review the diff for correctness and concurrency issues.
236
+ Review is fable's lane.
237
+ </example>
238
+ <example fire="false">
239
+ Design the migration strategy for the sharded session store.
240
+ Hard planning/design stays on fable.
241
+ </example>
242
+ <example fire="false">
243
+ Implement the token-refresh race fix in the auth middleware.
244
+ Auth plus concurrency: sensitive, error-prone implementation stays on fable.
245
+ </example>
246
+ </examples>""",
247
+ message=lambda r: (
248
+ f"This delegation would run on fable, but it reads as routine implementation. {r.reasoning} "
249
+ "Implementation defaults to model='opus' + effort='xhigh' (~2x cheaper, nearly as capable); "
250
+ "a well-scoped edit to existing code can also go to gpt-5.5 via the codex skill behind a "
251
+ "model='sonnet' low-effort wrapper. Keep fable if this genuinely is sensitive or error-prone. "
252
+ "See CLAUDE.md § Plan Execution & Orchestration (Models)."
253
+ ),
254
+ contexts=[DelegatedSpawn()],
255
+ events=Event.PreToolUse,
256
+ only_if=[Tool("Agent|Task")],
257
+ skip_if=[
258
+ ToolInput("model", r"(?i)\b(opus|sonnet|haiku)\b"),
259
+ Agent("Explore|claude-code-guide"),
260
+ ],
261
+ agent=False,
262
+ transcript=False,
263
+ tests={
264
+ Input(prompt="implement the pagination endpoint in api/users.py"): Warn(pattern="opus"),
265
+ Input(model="fable", prompt="add a --json flag to the export command"): Warn(pattern="opus"),
266
+ Input(model="opus", prompt="implement the pagination endpoint in api/users.py"): Allow(),
267
+ Input(model="sonnet", prompt="scan the repo for TODO markers"): Allow(),
268
+ Input(agent_type="Explore", prompt="find where the config loader lives"): Allow(),
269
+ },
270
+ )
271
+
272
+ nudge(
273
+ """
274
+ This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
275
+ map steps; a judgment-bearing stage should inherit the session model or route up. See
276
+ CLAUDE.md § Plan Execution & Orchestration (Models).
277
+ """,
278
+ only_if=[Tool("Workflow"), WorkflowScript(model="haiku")],
279
+ events=Event.PreToolUse,
280
+ max_fires=2,
281
+ tests={
282
+ Input(script="steps:\n - agent: reviewer\n model: 'haiku'\n"): Warn(),
283
+ Input(script="steps:\n - agent: reviewer\n model: 'sonnet'\n"): Allow(),
284
+ },
285
+ )
286
+
287
+ llm_nudge(
288
+ """Decide whether this workflow script pins a non-fable model on a stage whose
289
+ deliverable is prose.
290
+
291
+ <workflow_script> holds the pending Workflow call's script source. Its header quotes
292
+ every line of the script that pins a model; a stage that is not quoted there carries
293
+ no pin and inherits the session model, fable — already correctly routed, whatever it
294
+ writes. A regex prefilter already found both a haiku/sonnet/opus pin and a writing
295
+ verb near a prose noun somewhere in the script; your job is precision: does a
296
+ PINNED stage have prose as its own deliverable?
297
+
298
+ The Models rubric: all writing a user reads — READMEs, docs, changelogs, release
299
+ notes, blog posts, announcements, any user-facing text — routes to fable. A stage's
300
+ deliverable is prose when its agent() prompt asks it to write, draft, revise, or
301
+ polish such an artifact.
302
+
303
+ Set fire=true only when at least one agent() call both pins haiku/sonnet/opus and
304
+ has prose as its deliverable. A prose stage with no pin of its own is fine, even
305
+ when other stages pin opus. A prose keyword that appears as a constraint ("do NOT
306
+ edit CHANGELOG.md"), an ownership note, a file the stage merely reads, a
307
+ meta.description, or prose the orchestrator script assembles itself outside any
308
+ pinned agent() call is not that stage's deliverable: fire=false. When uncertain,
309
+ fire=false — a false alarm teaches the agent to ignore this nudge. Keep reasoning
310
+ under 40 words and name the offending stage.
311
+
312
+ <examples>
313
+ <example fire="true">
314
+ agent('Write the README quickstart section for the new CLI', {model: 'opus'})
315
+ The stage's deliverable is README prose, pinned to opus.
316
+ </example>
317
+ <example fire="true">
318
+ stages.push(agent(`Draft the docs-site page for ${feature}`, {model: 'sonnet'}))
319
+ A docs page is user-facing prose; the pin is non-fable.
320
+ </example>
321
+ <example fire="false">
322
+ agent('Fix the failing import in cli.py. Do NOT edit CHANGELOG.md — a sibling owns it', {model: 'opus'})
323
+ CHANGELOG appears only as a constraint; the deliverable is a code fix.
324
+ </example>
325
+ <example fire="false">
326
+ agent('Fix the CLI error handling', {label: 'fix:cli', model: 'opus'}) alongside
327
+ agent('Reword the troubleshooting guide and CHANGELOG bullet', {label: 'fix:docs'})
328
+ The prose stage carries no pin — it inherits fable; the opus pin is on code.
329
+ </example>
330
+ <example fire="false">
331
+ meta: {description: 'verify the doc claims against actual behavior'}, then agent('run the test matrix', {model: 'opus'})
332
+ "doc claims" lives in the description, not in any pinned stage's deliverable.
333
+ </example>
334
+ <example fire="false">
335
+ agent('fix the three failing tests', {model: 'opus'}), then the script itself assembles CHANGELOG.md from the results
336
+ The orchestrator writes the prose; the pinned stage only fixes tests.
337
+ </example>
338
+ <example fire="false">
339
+ agent('Read docs/architecture.md and list the sections that are stale', {model: 'sonnet'})
340
+ Reading and classifying docs is analysis, not a prose deliverable.
341
+ </example>
342
+ </examples>""",
343
+ message=lambda r: (
344
+ f"This workflow script pins a non-fable model on a stage whose deliverable is prose. {r.reasoning} "
345
+ "All writing — docs, READMEs, release notes, any user-facing text — routes to fable: inherit the "
346
+ "session model or pin model: 'fable' on that stage. "
347
+ "See CLAUDE.md § Plan Execution & Orchestration (Models)."
348
+ ),
349
+ contexts=[WorkflowScriptSource()],
350
+ events=Event.PreToolUse,
351
+ only_if=[
352
+ Tool("Workflow"),
353
+ WorkflowScript(
354
+ pattern=r"(?i)\b(?:writ(?:e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)\w*"
355
+ r"\W+(?:\w+\W+){0,4}(?:readme|docs?|documentation|blog|changelog|release notes|prose)\b"
356
+ r"|\b(?:readme|docs?|documentation|blog|changelog|release notes|prose)\b"
357
+ r"\W+(?:\w+\W+){0,4}(?:writ(?:e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)",
358
+ model="haiku|sonnet|opus",
359
+ ),
360
+ ],
361
+ max_fires=2,
362
+ max_context=16_000,
363
+ agent=False,
364
+ transcript=False,
365
+ tests={
366
+ Input(script="steps:\n - agent: write the README intro\n model: 'sonnet'\n"): Warn(pattern="fable"),
367
+ Input(script="steps:\n - agent: write the README intro\n model: 'fable'\n"): Allow(),
368
+ Input(script="steps:\n - agent: fix the retry backoff\n model: 'sonnet'\n"): Allow(),
369
+ Input(script="agent('Audit docs/architecture.md for stale claims', {model: 'opus'})"): Allow(),
370
+ Input(
371
+ script="agent('recon the module map', {model: 'sonnet'})\n// all prose stays with the main agent on fable\n"
372
+ ): Allow(),
373
+ Input(
374
+ script="agent('Fix the import in cli.py. Do NOT edit CHANGELOG.md — a sibling owns it', {model: 'opus'})",
375
+ llm={"fire": False},
376
+ ): Allow(),
377
+ },
378
+ )
@@ -94,7 +94,9 @@ registration gets:
94
94
  - Inline `tests = {...}` from Step 1: the offending shape expecting `Block(...)` or
95
95
  `Warn(...)` (match the chosen severity), the benign neighbor expecting `Allow()`.
96
96
  LLM hooks (`llm_gate`, `llm_nudge`) ship without `tests=` — their inline tests would
97
- only exercise a stubbed model.
97
+ only exercise a stubbed model. Exception: with a required `contexts=` provider, ship
98
+ `tests=`, and use `Input(llm={"fire": False})` (or `{"block": False}`) to wire-test the
99
+ judge-declines path; the default stub still always fires.
98
100
 
99
101
  ### 4. Verify
100
102
 
@@ -13,8 +13,11 @@ hook's conditions and handler, and asserts the outcome. Exit code 1 on any failu
13
13
  (first alternative of the pattern: `Tool("Edit|Write")` infers `Edit`), else `Bash`.
14
14
  3. `skip_if`/`only_if` are evaluated; a non-matching input yields `Allow()`.
15
15
  4. LLM calls are stubbed: `GateVerdict(block=True)`, `NudgeVerdict(fire=True)`,
16
- `PromptCheckVerdict(action="block")`. The stub always fires, so inline tests on LLM hooks
17
- assert nothing real — ship LLM hooks without `tests=`.
16
+ `PromptCheckVerdict(action="block")`. The default stub always fires, so inline tests on
17
+ LLM hooks assert only conditions and wiring, never judgment — ship LLM hooks without
18
+ `tests=` unless a required `contexts=` provider makes them meaningful.
19
+ `Input(llm={"fire": False})` / `Input(llm={"block": False})` overrides the stub verdict
20
+ per test, wire-testing the judge-declines path.
18
21
 
19
22
  ## Input fields
20
23
 
@@ -89,4 +92,4 @@ Statuses: `pass`, `fail` (wrong outcome), `error` (exception in hook or test), `
89
92
  | `@on` handler on a piped command never fires | `evt.command_line.q.runs(...)` checks the pipeline's *last* command. Use `.any_command(lambda c: c.program == "...")`. |
90
93
  | Edit/Write hook gets `None` content | The `Input` set `command=` but the hook's first event/tool is Edit — set `file=` and `content=` (and `tool=` if the hook matches several). |
91
94
  | `Input(command=...)` ignored | The hook's first event is `Stop` — the runner builds a Stop event without tool input. Encode the command as a Bash `tool_use` in `transcript=` instead. |
92
- | LLM hook test always blocks | The stub verdict always fires. Remove `tests=` from LLM hooks; test their static `only_if` narrowing on a deterministic sibling hook instead. |
95
+ | LLM hook test always blocks | The default stub verdict always fires. Pass `Input(llm={"fire": False})` / `{"block": False}` to exercise the declines path, or remove `tests=` and test the static `only_if` narrowing on a deterministic sibling hook. |
@@ -19,7 +19,7 @@ skill, which owns hook drafting (Step 6 delegates to it).
19
19
  ## Hard Rules
20
20
 
21
21
  - **Never write a hook the user has not approved in Step 4.** Survey and propose first; write only what was selected.
22
- - **Every deterministic hook ships inline tests** — at least one firing `Input` and one `Allow()` — and `uvx capt-hook test` must be green before Step 8. LLM hooks (`llm_gate`, `llm_nudge`, `prompt_check`) and signal-scored `nudge`s ship without `tests=` — their inline tests would only exercise a stubbed model — with one exception: an LLM hook gated by a **required `contexts=` provider** should ship `tests=`, because the context extraction runs for real under the stub. The stub always confirms, so every firing case exercises the real gate and every `Allow()` case must yield no evidence (fail the gate), never rely on LLM judgment.
22
+ - **Every deterministic hook ships inline tests** — at least one firing `Input` and one `Allow()` — and `uvx capt-hook test` must be green before Step 8. LLM hooks (`llm_gate`, `llm_nudge`, `prompt_check`) and signal-scored `nudge`s ship without `tests=` — their inline tests would only exercise a stubbed model — with one exception: an LLM hook gated by a **required `contexts=` provider** should ship `tests=`, because the context extraction runs for real under the stub. The default stub always confirms, so every firing case exercises the real gate and every `Allow()` case must yield no evidence (fail the gate) or override the verdict per test with `Input(llm={"fire": False})` / `{"block": False}` to wire-test the judge-declines path. Never treat a stubbed verdict as proof the model judges correctly.
23
23
  - **Propose `block` only for irreversible or destructive actions** (history rewrites, data deletion, deploys, secret leaks). Default everything else to warn. The user picks final severity in Step 4.
24
24
  - **Never write style rules here.** A style guide found during the survey is delegated whole to the `translating-styleguides` skill (category E below).
25
25
 
@@ -4,7 +4,7 @@ import atexit
4
4
  import re
5
5
  import shutil
6
6
  import tempfile
7
- from collections.abc import Iterator
7
+ from collections.abc import Callable, Iterator
8
8
  from contextlib import contextmanager
9
9
  from itertools import count
10
10
  from pathlib import Path
@@ -508,21 +508,29 @@ def assert_result(
508
508
  assert val in actual, f"{prefix}Rewrite {key}={actual!r} doesn't contain '{val}'"
509
509
 
510
510
 
511
- def stub_call_llm(
512
- _template: Any,
513
- *args: Any,
514
- response_model: type[BaseModel] | None = None,
515
- **kwargs: Any,
516
- ) -> str | BaseModel:
517
- if response_model is None:
518
- return "stubbed"
519
- return response_model(
520
- **{
521
- name: STUB_FIELD_VALUES.get(name, "")
522
- for name, info in response_model.model_fields.items()
523
- if name in STUB_FIELD_VALUES or info.default is None
524
- }
525
- )
511
+ def make_stub_call_llm(overrides: dict[str, Any] | None = None) -> Callable[..., str | BaseModel]:
512
+ values = STUB_FIELD_VALUES | (overrides or {})
513
+
514
+ def stub_call_llm(
515
+ _template: Any,
516
+ *args: Any,
517
+ response_model: type[BaseModel] | None = None,
518
+ **kwargs: Any,
519
+ ) -> str | BaseModel:
520
+ if response_model is None:
521
+ return "stubbed"
522
+ return response_model(
523
+ **{
524
+ name: values.get(name, "")
525
+ for name, info in response_model.model_fields.items()
526
+ if name in values or info.default is None
527
+ }
528
+ )
529
+
530
+ return stub_call_llm
531
+
532
+
533
+ stub_call_llm = make_stub_call_llm()
526
534
 
527
535
 
528
536
  @contextmanager
@@ -563,7 +571,7 @@ def run_inline_tests() -> list[tuple[str, str, bool, str]]:
563
571
  if spec_tools
564
572
  else None,
565
573
  )
566
- evt.ctx.call_llm = stub_call_llm # type: ignore[assignment]
574
+ evt.ctx.call_llm = make_stub_call_llm(key.llm) # type: ignore[assignment]
567
575
  hook_result = (
568
576
  execute_hook(entry, evt)
569
577
  if matches_conditions(entry.spec, evt) and not is_planning_agent_skip(entry.spec, evt)
@@ -110,6 +110,9 @@ class Input:
110
110
  transcript: Session history for transcript conditions — a path, a
111
111
  ``TranscriptFixture``, or a raw list of transcript-line dicts.
112
112
  tasks: The native task list read via ``evt.tasks``.
113
+ llm: Per-test LLM stub overrides merged over the default stub verdict
114
+ (``fire``/``block``/``action``/``reasoning``), e.g. ``llm={"fire": False}``
115
+ to exercise an LLM hook's judge-declines path.
113
116
  """
114
117
 
115
118
  command: str | None = None
@@ -131,6 +134,7 @@ class Input:
131
134
  limit: int | None = None
132
135
  transcript: Path | TranscriptFixture | list[dict[str, Any]] | None = None
133
136
  tasks: list[dict[str, Any]] | None = None
137
+ llm: dict[str, Any] | None = None
134
138
 
135
139
  @field_validator("transcript", mode="before")
136
140
  @classmethod
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "capt-hook"
3
3
  # Inert sentinel — the real version is stamped from the release tag (uv version --frozen).
4
- version = "6.1.0"
4
+ version = "6.2.0"
5
5
  description = "Declarative hook framework for Claude Code"
6
6
  readme = "README.md"
7
7
  license = "PolyForm-Noncommercial-1.0.0"
@@ -1,221 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
-
5
- from captain_hook import (
6
- Agent,
7
- Allow,
8
- BaseHookEvent,
9
- Block,
10
- Event,
11
- Input,
12
- Rewrite,
13
- TaskCall,
14
- Tool,
15
- ToolInput,
16
- Warn,
17
- WorkflowScript,
18
- hook,
19
- llm_nudge,
20
- nudge,
21
- set_tool_input,
22
- )
23
-
24
-
25
- @dataclass(frozen=True, slots=True)
26
- class DelegatedSpawn:
27
- """Gating context: the pending Agent/Task call's model pin, agent type, and prompt."""
28
-
29
- tag: str = "delegated_spawn"
30
- required: bool = True
31
-
32
- def content(self, evt: BaseHookEvent) -> str | None:
33
- if (call := evt.as_input(TaskCall)) is None or not call.prompt:
34
- return None
35
- model = call.model or "(none — inherits the session model, fable)"
36
- return f"model: {model}\nagent_type: {call.agent_type or '(default)'}\nprompt:\n{call.prompt}"
37
-
38
-
39
- hook(
40
- Event.PreToolUse,
41
- only_if=[Tool("Agent|Task"), ToolInput("model", r"(?i)\bhaiku\b")],
42
- skip_if=[
43
- ToolInput(
44
- "prompt",
45
- r"(?i)\b(classif|label|tag|categoriz|per.file|one (fact|thing)|mechanical|probe|ping"
46
- r"|echo|smoke|count|capacit|extract)",
47
- )
48
- ],
49
- message=(
50
- "This subagent is pinned to haiku. Route a subagent to haiku only for a single-fact "
51
- "mechanical step — classifying, labeling, tagging, counting, or probing one thing per "
52
- "item. Anything that carries judgment should run on sonnet; use model='sonnet', or drop "
53
- "model entirely to inherit the session model. If this genuinely is a mechanical "
54
- "single-fact step, say 'mechanical' in the prompt and retry — the haiku pin will be "
55
- "allowed. See CLAUDE.md § Plan Execution & Orchestration (Models)."
56
- ),
57
- block=True,
58
- tests={
59
- Input(model="haiku", prompt="implement the retry backoff in the client"): Block(),
60
- Input(model="haiku", prompt="classify each file's language"): Allow(),
61
- Input(model="haiku", prompt="Probe subagent capacity: spawn and return the word ok"): Allow(),
62
- Input(model="haiku", prompt="mechanical step: return the repo's default branch name"): Allow(),
63
- Input(model="haiku", prompt="count the TODO markers in src/"): Allow(),
64
- Input(prompt="implement the retry backoff in the client"): Allow(),
65
- Input(model="sonnet", prompt="implement the retry backoff in the client"): Allow(),
66
- },
67
- )
68
-
69
- hook(
70
- Event.PreToolUse,
71
- only_if=[
72
- Tool("Agent|Task"),
73
- ToolInput("model", r"(?i)\b(haiku|sonnet|opus)\b"),
74
- ToolInput(
75
- "prompt",
76
- r"(?i)\b(writ(e|es|ing|ten)|draft|redraft|rewrit|revis|polish|copyedit|compose|author|update|edit)",
77
- ),
78
- ToolInput(
79
- "prompt",
80
- r"(?i)\b(readme|docs?|documentation|blog|changelog|release notes|announcement"
81
- r"|tutorial|guide|prose|copywrit|marketing copy|article|newsletter|email"
82
- r"|pr description|commit message)\b",
83
- ),
84
- ],
85
- skip_if=[ToolInput("prompt", r"(?i)\b(classif|label|tag|categoriz|count|extract|mechanical)")],
86
- message=(
87
- "This subagent is pinned to a non-fable model but its prompt is prose/writing work. "
88
- "All writing — docs, READMEs, release notes, any user-facing text — routes to fable: "
89
- "drop model to inherit the session model, or pass model='fable'. "
90
- "See CLAUDE.md § Plan Execution & Orchestration (Models)."
91
- ),
92
- block=True,
93
- tests={
94
- Input(model="sonnet", prompt="Write the README quickstart for this repo"): Block(),
95
- Input(model="opus", prompt="draft the release notes for v2"): Block(),
96
- Input(model="haiku", prompt="update the CHANGELOG entry for the fix"): Block(),
97
- Input(model="fable", prompt="write the README quickstart"): Allow(),
98
- Input(prompt="write the README quickstart"): Allow(),
99
- Input(model="sonnet", prompt="review the README for factual errors"): Allow(),
100
- Input(model="sonnet", prompt="update the retry backoff config"): Allow(),
101
- Input(model="haiku", prompt="label each README section with its Diataxis mode"): Allow(),
102
- },
103
- )
104
-
105
- set_tool_input(
106
- "model",
107
- "sonnet",
108
- tool="Agent|Task",
109
- only_if=[Agent("Explore|claude-code-guide")],
110
- note=(
111
- "Upgraded this recon subagent from the silent haiku default to sonnet, per the Models "
112
- "table in CLAUDE.md § Plan Execution & Orchestration (Models)."
113
- ),
114
- tests={
115
- Input(agent_type="Explore"): Rewrite(model="sonnet"),
116
- Input(agent_type="Explore", model="haiku"): Allow(),
117
- Input(agent_type="general-purpose"): Allow(),
118
- },
119
- )
120
-
121
- llm_nudge(
122
- """Decide whether this delegated subagent should run on opus-4.8 instead of fable-5.
123
-
124
- <delegated_spawn> holds the pending Agent/Task call: its model pin (or that it inherits
125
- the session model, fable), agent type, and prompt.
126
-
127
- The Models rubric: implementation delegates to opus-4.8 at xhigh — opus is ~2x cheaper
128
- than fable and nearly as capable. Fable's lanes are orchestration, review, hard
129
- planning/design/diagnosis, all prose/writing, and implementation that is very sensitive
130
- or error-prone (auth, migrations, concurrency, data loss, crypto, subtle algorithms).
131
-
132
- Set fire=true only when the prompt is clearly routine implementation — building, fixing,
133
- wiring, or refactoring code — with no fable-lane signal. A prompt that reviews, plans,
134
- designs, diagnoses a hard bug, writes prose, or touches a sensitive surface stays on
135
- fable: fire=false. When uncertain, fire=false — the agent may have chosen fable
136
- deliberately, and a false alarm teaches it to ignore this nudge. Keep reasoning under
137
- 40 words.
138
-
139
- <examples>
140
- <example fire="true">
141
- Implement the pagination endpoint in api/users.py per the spec in the plan.
142
- Routine implementation with no sensitivity signal — the opus xhigh lane.
143
- </example>
144
- <example fire="true">
145
- Add a --json flag to the export command and thread it through the formatter.
146
- Well-scoped feature wiring; the default implementation lane.
147
- </example>
148
- <example fire="false">
149
- Review the diff for correctness and concurrency issues.
150
- Review is fable's lane.
151
- </example>
152
- <example fire="false">
153
- Design the migration strategy for the sharded session store.
154
- Hard planning/design stays on fable.
155
- </example>
156
- <example fire="false">
157
- Implement the token-refresh race fix in the auth middleware.
158
- Auth plus concurrency: sensitive, error-prone implementation stays on fable.
159
- </example>
160
- </examples>""",
161
- message=lambda r: (
162
- f"This delegation would run on fable, but it reads as routine implementation. {r.reasoning} "
163
- "Implementation defaults to model='opus' + effort='xhigh' (~2x cheaper, nearly as capable); "
164
- "a well-scoped edit to existing code can also go to gpt-5.5 via the codex skill behind a "
165
- "model='sonnet' low-effort wrapper. Keep fable if this genuinely is sensitive or error-prone. "
166
- "See CLAUDE.md § Plan Execution & Orchestration (Models)."
167
- ),
168
- contexts=[DelegatedSpawn()],
169
- events=Event.PreToolUse,
170
- only_if=[Tool("Agent|Task")],
171
- skip_if=[
172
- ToolInput("model", r"(?i)\b(opus|sonnet|haiku)\b"),
173
- Agent("Explore|claude-code-guide"),
174
- ],
175
- agent=False,
176
- transcript=False,
177
- tests={
178
- Input(prompt="implement the pagination endpoint in api/users.py"): Warn(pattern="opus"),
179
- Input(model="fable", prompt="add a --json flag to the export command"): Warn(pattern="opus"),
180
- Input(model="opus", prompt="implement the pagination endpoint in api/users.py"): Allow(),
181
- Input(model="sonnet", prompt="scan the repo for TODO markers"): Allow(),
182
- Input(agent_type="Explore", prompt="find where the config loader lives"): Allow(),
183
- },
184
- )
185
-
186
- nudge(
187
- """
188
- This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
189
- map steps; a judgment-bearing stage should inherit the session model or route up. See
190
- CLAUDE.md § Plan Execution & Orchestration (Models).
191
- """,
192
- only_if=[Tool("Workflow"), WorkflowScript(model="haiku")],
193
- events=Event.PreToolUse,
194
- max_fires=2,
195
- tests={
196
- Input(script="steps:\n - agent: reviewer\n model: 'haiku'\n"): Warn(),
197
- Input(script="steps:\n - agent: reviewer\n model: 'sonnet'\n"): Allow(),
198
- },
199
- )
200
-
201
- nudge(
202
- """
203
- This workflow script pins a non-fable model on what looks like prose/writing stages. All
204
- writing — docs, READMEs, release notes, any user-facing text — routes to fable: inherit the
205
- session model or pin model: 'fable'. See CLAUDE.md § Plan Execution & Orchestration (Models).
206
- """,
207
- only_if=[
208
- Tool("Workflow"),
209
- WorkflowScript(
210
- pattern=r"(?i)\b(readme|docs?|documentation|blog|changelog|release notes|prose)\b",
211
- model="haiku|sonnet|opus",
212
- ),
213
- ],
214
- events=Event.PreToolUse,
215
- max_fires=2,
216
- tests={
217
- Input(script="steps:\n - agent: write the README intro\n model: 'sonnet'\n"): Warn(),
218
- Input(script="steps:\n - agent: write the README intro\n model: 'fable'\n"): Allow(),
219
- Input(script="steps:\n - agent: fix the retry backoff\n model: 'sonnet'\n"): Allow(),
220
- },
221
- )
File without changes
File without changes
File without changes
File without changes
File without changes