capt-hook 6.0.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 (109) hide show
  1. {capt_hook-6.0.0 → capt_hook-6.2.0}/PKG-INFO +1 -1
  2. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/__init__.py +1 -0
  3. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/cli.py +4 -1
  4. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/conditions.py +6 -0
  5. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/contexts.py +11 -6
  6. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/events.py +22 -8
  7. capt_hook-6.2.0/captain_hook/packs/general/_lib.py +31 -0
  8. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/general/capt-hook.toml +2 -2
  9. capt_hook-6.2.0/captain_hook/packs/general/docs.py +158 -0
  10. capt_hook-6.2.0/captain_hook/packs/general/models.py +378 -0
  11. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/general/review.py +2 -28
  12. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/general/tombstones.py +8 -1
  13. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/primitives/llm.py +7 -2
  14. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/signals/nlp.py +12 -7
  15. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/SKILL.md +3 -1
  16. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +3 -0
  17. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +6 -3
  18. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +1 -1
  19. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/testing/helpers.py +31 -20
  20. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/testing/types.py +4 -0
  21. {capt_hook-6.0.0 → capt_hook-6.2.0}/pyproject.toml +1 -1
  22. capt_hook-6.0.0/captain_hook/packs/general/docs.py +0 -25
  23. capt_hook-6.0.0/captain_hook/packs/general/models.py +0 -221
  24. {capt_hook-6.0.0 → capt_hook-6.2.0}/LICENSE +0 -0
  25. {capt_hook-6.0.0 → capt_hook-6.2.0}/README.md +0 -0
  26. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  27. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/__main__.py +0 -0
  28. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/app.py +0 -0
  29. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/ast_grep.py +0 -0
  30. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/classifiers/__init__.py +0 -0
  31. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/classifiers/conductor.py +0 -0
  32. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/classifiers/droid.py +0 -0
  33. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/classifiers/native.py +0 -0
  34. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/command.py +0 -0
  35. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/context.py +0 -0
  36. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/decisions.py +0 -0
  37. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/dispatch.py +0 -0
  38. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/durable.py +0 -0
  39. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/fields.py +0 -0
  40. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/file.py +0 -0
  41. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/loader.py +0 -0
  42. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/log.py +0 -0
  43. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/__init__.py +0 -0
  44. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/general/__init__.py +0 -0
  45. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/general/commands.py +0 -0
  46. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/general/plans.py +0 -0
  47. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/general/prompts.py +0 -0
  48. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/general/tasks.py +0 -0
  49. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/go/__init__.py +0 -0
  50. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  51. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/go/testing.py +0 -0
  52. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/go/toolchain.py +0 -0
  53. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/manager.py +0 -0
  54. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/python/__init__.py +0 -0
  55. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  56. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/python/style.py +0 -0
  57. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/python/testing.py +0 -0
  58. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/python/toolchain.py +0 -0
  59. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/steering/__init__.py +0 -0
  60. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  61. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/packs/steering/steering.py +0 -0
  62. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/primitives/__init__.py +0 -0
  63. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/primitives/commands.py +0 -0
  64. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/primitives/lint.py +0 -0
  65. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/primitives/nudge.py +0 -0
  66. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/primitives/rewrite.py +0 -0
  67. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/primitives/workflow.py +0 -0
  68. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/prompt.py +0 -0
  69. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/py.typed +0 -0
  70. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/__init__.py +0 -0
  71. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/cli.py +0 -0
  72. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/dashboard.py +0 -0
  73. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/fix.py +0 -0
  74. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/formats.py +0 -0
  75. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/judge.py +0 -0
  76. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/pipeline.py +0 -0
  77. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/repo.py +0 -0
  78. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/scan.py +0 -0
  79. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/settings.py +0 -0
  80. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/store.py +0 -0
  81. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/review/sync.py +0 -0
  82. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/session.py +0 -0
  83. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/settings.py +0 -0
  84. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/signals/__init__.py +0 -0
  85. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  86. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  87. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  88. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  89. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  90. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  91. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  92. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  93. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  94. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/state.py +0 -0
  95. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/style/__init__.py +0 -0
  96. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/style/ast_grep.py +0 -0
  97. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/style/matchers.py +0 -0
  98. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/style/scope.py +0 -0
  99. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/style/types.py +0 -0
  100. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/tasks.py +0 -0
  101. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  102. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/testing/__init__.py +0 -0
  103. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/testing/session_cache.py +0 -0
  104. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/types.py +0 -0
  105. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/util/__init__.py +0 -0
  106. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/util/fs.py +0 -0
  107. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/util/http.py +0 -0
  108. {capt_hook-6.0.0 → capt_hook-6.2.0}/captain_hook/util/model_cache.py +0 -0
  109. {capt_hook-6.0.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.0.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
@@ -227,6 +227,9 @@ def print_hook_summary(label: str, summary: dict[str, str], deferred_to: str) ->
227
227
 
228
228
 
229
229
  def provision_nlp(resolved: Sequence[manager.ResolvedPack]) -> None:
230
+ import httpx
231
+ import wn
232
+
230
233
  from captain_hook.util import http
231
234
  from captain_hook.util.model_cache import ensure_nlp_resources
232
235
 
@@ -235,7 +238,7 @@ def provision_nlp(resolved: Sequence[manager.ResolvedPack]) -> None:
235
238
  click.echo("Provisioning NLP resources (spaCy en_core_web_sm ~13MB + oewn:2025 lexicon ~231MB, cached)...")
236
239
  try:
237
240
  ensure_nlp_resources()
238
- except (http.GitHubFetchError, OSError) as e:
241
+ except (http.GitHubFetchError, OSError, wn.Error, httpx.HTTPError) as e:
239
242
  click.echo(f" deferred (offline?): {e} — the SessionStart hook will retry at session start")
240
243
 
241
244
 
@@ -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:
@@ -43,12 +43,13 @@ class PromptContext(Protocol):
43
43
  class BeforeEdit:
44
44
  """The pending edit's pre-image, as a ``<before_edit>`` block.
45
45
 
46
- Reads :attr:`~captain_hook.events.ToolHookEvent.replaced` — an Edit's old text, a
47
- MultiEdit's joined olds, or a Write's current on-disk content so it carries
48
- content only at ``PreToolUse`` on edit-shaped events. Attached to every LLM
49
- primitive as a default context; ``required=False`` because it is ambient
50
- enrichment, empty (and omitted) on non-edit events. Pass ``BeforeEdit(required=True)``
51
- explicitly to gate a hook on a non-empty pre-image.
46
+ Reads :attr:`~captain_hook.events.ToolHookEvent.replaced` — an Edit's old text or
47
+ a MultiEdit's joined olds at any event, a Write's current on-disk content only at
48
+ ``PreToolUse`` (after the Write lands, disk holds the new text). The block is
49
+ omitted whenever the pre-image is unknowable: non-edit events, Writes off
50
+ ``PreToolUse``, unreadable paths. Attached to every LLM primitive as a default
51
+ context; ``required=False`` because it is ambient enrichment. Pass
52
+ ``BeforeEdit(required=True)`` explicitly to gate a hook on a non-empty pre-image.
52
53
  """
53
54
 
54
55
  tag: str = "before_edit"
@@ -84,6 +85,10 @@ class Introduced:
84
85
  see :data:`~captain_hook.ast_grep.COMMENT_TYPES`), ``pattern`` is an ast-grep
85
86
  pattern. Extraction diffs the event's before/after text, so only constructs
86
87
  absent before the edit appear; files without a supported language yield nothing.
88
+ The pre-image comes from ``evt.replaced``, so hooks covering Writes need
89
+ ``events=Event.PreToolUse`` — at any other event a Write's pre-image is
90
+ unknowable, the context yields ``None``, and (being ``required``) the LLM call
91
+ skips rather than misreporting every construct as introduced.
87
92
 
88
93
  ``required`` defaults to ``True``: a context you attach explicitly IS the
89
94
  evidence — no evidence, no LLM call. ``tag`` auto-derives from the class name in
@@ -36,6 +36,18 @@ if TYPE_CHECKING:
36
36
  T = TypeVar("T", bound=ToolCallBase)
37
37
 
38
38
 
39
+ def disk_pre_image(path: Path) -> str | None:
40
+ if not path.exists():
41
+ return ""
42
+ if not path.is_file():
43
+ return None
44
+ try:
45
+ data = path.read_bytes()
46
+ except OSError:
47
+ return None
48
+ return data.decode(errors="replace")
49
+
50
+
39
51
  @dataclass
40
52
  class BaseHookEvent:
41
53
  """Base class for all hook events, providing access to raw payload, context, and convenience methods."""
@@ -233,18 +245,20 @@ class ToolHookEvent(BaseHookEvent):
233
245
 
234
246
  @property
235
247
  def replaced(self) -> str | None:
236
- """The pre-image of the text this call overwrites.
237
-
238
- An Edit's ``old``, a MultiEdit's newline-joined olds, or for a Write — the
239
- file's current on-disk content (``""`` for a new file). ``None`` for other
240
- tools. The Write arm reads the disk, so it is only meaningful at
241
- ``PreToolUse``: once the Write lands, disk already holds the new text.
248
+ """The pre-image of the text this call overwrites, when knowable.
249
+
250
+ An Edit's ``old`` and a MultiEdit's newline-joined olds carry the pre-image at
251
+ any event. A Write's pre-image is the file's current on-disk content ``""``
252
+ for a new file, lossily decoded for non-UTF-8 bytes, ``None`` for a directory
253
+ or unreadable path — and only at ``PreToolUse``: once the Write lands, disk
254
+ already holds the new text, so any other event yields ``None``. ``None`` for
255
+ other tools.
242
256
  """
243
257
  match self.input:
244
258
  case EditCall() | MultiEditCall():
245
259
  return self.old
246
- case WriteCall(file_path=path):
247
- return p.read_text() if (p := Path(path)).exists() else ""
260
+ case WriteCall(file_path=path) if self.event is Event.PreToolUse:
261
+ return disk_pre_image(Path(path))
248
262
  case _:
249
263
  return None
250
264
 
@@ -0,0 +1,31 @@
1
+ """Conditions shared by the general pack's hook modules; the ``_`` prefix keeps the loader from registering it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from captain_hook import BaseHookEvent, CustomCondition
6
+
7
+ # Prose and config file extensions that shouldn't, on their own, demand a code-review pass.
8
+ # Tailor this (and the excluded dirs below) to scope what counts as "source" for your repo.
9
+ NON_SOURCE_SUFFIXES = (
10
+ ".md",
11
+ ".mdx",
12
+ ".rst",
13
+ ".txt",
14
+ ".json",
15
+ ".toml",
16
+ ".yaml",
17
+ ".yml",
18
+ ".ini",
19
+ ".cfg",
20
+ ".lock",
21
+ )
22
+
23
+
24
+ class EditedSource(CustomCondition):
25
+ """True when the session edited a non-test source file (docs and config excluded)."""
26
+
27
+ def check(self, evt: BaseHookEvent) -> bool:
28
+ return any(
29
+ not f.is_test and f.suffix not in NON_SOURCE_SUFFIXES and not f.under("docs", ".claude", ".github")
30
+ for f in evt.ctx.t.tool_calls.named("Edit|Write").files()
31
+ )
@@ -1,5 +1,5 @@
1
1
  name = "general"
2
- version = "0.5.0"
3
- description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, and a code-review gate."
2
+ version = "0.7.0"
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,158 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from captain_hook import (
6
+ Allow,
7
+ BaseHookEvent,
8
+ Block,
9
+ CustomCondition,
10
+ Event,
11
+ FilePath,
12
+ Input,
13
+ Tool,
14
+ TouchedFile,
15
+ UsedSkill,
16
+ Waiting,
17
+ Warn,
18
+ llm_gate,
19
+ nudge,
20
+ )
21
+ from captain_hook.packs.general._lib import EditedSource
22
+
23
+
24
+ class Headless(CustomCondition):
25
+ """True in a headless ``claude -p`` / SDK run (``CLAUDE_CODE_ENTRYPOINT`` in the ``sdk-*`` family)."""
26
+
27
+ def check(self, evt: BaseHookEvent) -> bool:
28
+ return os.environ.get("CLAUDE_CODE_ENTRYPOINT", "").startswith("sdk")
29
+
30
+
31
+ # Advisory reminder to consult the writing-docs skill (and run slop-cop) before
32
+ # editing documentation. Fires once per session on the first doc edit and stands
33
+ # down once the skill has been used. Advisory only, so it never blocks an edit.
34
+ #
35
+ # The scaffolded .claude/settings.json registers the yasyf/cc-skills marketplace
36
+ # and enables writing-docs@skills, so the skill (and the skip_if check) activates
37
+ # when the folder is trusted — no manual /plugin install.
38
+ nudge(
39
+ "You're editing documentation. Consult the writing-docs skill first for the "
40
+ "Diataxis modes, voice rules, and code-sample rules, then run "
41
+ "`slop-cop check <file> --lang=markdown` to catch prose tells before you finish. "
42
+ "slop-cop is a Go binary — if it's not on PATH, run the `/slop-cop-check` skill "
43
+ "(it installs it), never `uvx slop-cop`.",
44
+ only_if=[Tool("Write|Edit"), FilePath("**/*.md", "**/*.qmd", "docs/**", "README.md")],
45
+ skip_if=[UsedSkill("writing-docs")],
46
+ max_fires=1,
47
+ tests={
48
+ Input(tool="Write", file="docs/guide/x.qmd", content="# X"): Warn(pattern="writing-docs"),
49
+ Input(tool="Edit", file="src/app.py", content="x = 1"): Allow(),
50
+ },
51
+ )
52
+
53
+
54
+ # Docs-freshness gate: after source edits, an LLM reads the uncommitted diff before the
55
+ # agent stops and blocks once when a user-facing change isn't reflected in README.md or
56
+ # docs/. Complements review.py's gate, which reviews correctness — this one reviews
57
+ # documentation. Stands down when the session already touched markdown/docs or used the
58
+ # writing-docs skill, and in headless (cron/CI) runs.
59
+ llm_gate(
60
+ "You are checking documentation freshness before the agent stops. The compact diff of "
61
+ "the uncommitted changes is in <diff>. Decide whether the session changed anything "
62
+ "user-facing — a new flag or option, a renamed command, changed output or behavior, a "
63
+ "new feature — that README.md or the pages under docs/ don't reflect. Set block=true "
64
+ "ONLY for a concrete gap, naming exactly which file and section to update in "
65
+ "`reasoning`. Otherwise block=false. Do not block on internal refactors, test or "
66
+ "tooling changes, or speculative staleness.",
67
+ message=lambda r: (
68
+ f"Docs freshness check found a gap to close before stopping: {r.reasoning} "
69
+ "Update README.md or docs/ via the writing-docs skill, or state that nothing "
70
+ "user-facing changed and finish."
71
+ ),
72
+ diff=True,
73
+ only_if=[EditedSource()],
74
+ skip_if=[
75
+ Waiting(),
76
+ TouchedFile("**/*.md", "**/*.qmd"),
77
+ UsedSkill("writing-docs|writing-docs:writing-docs"),
78
+ Headless(),
79
+ ],
80
+ events=Event.Stop,
81
+ max_fires=1,
82
+ tests={
83
+ Input(
84
+ transcript=[
85
+ {
86
+ "type": "assistant",
87
+ "message": {
88
+ "content": [
89
+ {
90
+ "type": "tool_use",
91
+ "name": "Edit",
92
+ "id": "e1",
93
+ "input": {"file_path": "/repo/src/app.py", "old_string": "a", "new_string": "b"},
94
+ }
95
+ ]
96
+ },
97
+ },
98
+ ]
99
+ ): Block(pattern="writing-docs"),
100
+ Input(
101
+ transcript=[
102
+ {
103
+ "type": "assistant",
104
+ "message": {
105
+ "content": [
106
+ {
107
+ "type": "tool_use",
108
+ "name": "Edit",
109
+ "id": "e1",
110
+ "input": {"file_path": "/repo/src/app.py", "old_string": "a", "new_string": "b"},
111
+ },
112
+ {
113
+ "type": "tool_use",
114
+ "name": "Edit",
115
+ "id": "e2",
116
+ "input": {"file_path": "/repo/README.md", "old_string": "a", "new_string": "b"},
117
+ },
118
+ ]
119
+ },
120
+ },
121
+ ]
122
+ ): Allow(),
123
+ Input(
124
+ transcript=[
125
+ {
126
+ "type": "assistant",
127
+ "message": {
128
+ "content": [
129
+ {
130
+ "type": "tool_use",
131
+ "name": "Edit",
132
+ "id": "e1",
133
+ "input": {"file_path": "/repo/README.md", "old_string": "a", "new_string": "b"},
134
+ }
135
+ ]
136
+ },
137
+ },
138
+ ]
139
+ ): Allow(),
140
+ Input(
141
+ transcript=[
142
+ {
143
+ "type": "assistant",
144
+ "message": {
145
+ "content": [
146
+ {
147
+ "type": "tool_use",
148
+ "name": "Edit",
149
+ "id": "e1",
150
+ "input": {"file_path": "/repo/tests/test_app.py", "old_string": "a", "new_string": "b"},
151
+ }
152
+ ]
153
+ },
154
+ },
155
+ ]
156
+ ): Allow(),
157
+ },
158
+ )