capt-hook 6.3.0__tar.gz → 6.4.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 (107) hide show
  1. {capt_hook-6.3.0 → capt_hook-6.4.0}/PKG-INFO +1 -1
  2. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/cli.py +3 -1
  3. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/context.py +4 -3
  4. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/durable.py +1 -8
  5. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/loader.py +5 -6
  6. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/capt-hook.toml +1 -1
  7. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/models.py +303 -16
  8. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/manager.py +2 -2
  9. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/llm.py +4 -4
  10. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/prompt.py +5 -6
  11. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/session.py +0 -6
  12. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/settings.py +14 -9
  13. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/state.py +0 -2
  14. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/testing/helpers.py +60 -44
  15. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/testing/session_cache.py +3 -1
  16. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/model_cache.py +2 -3
  17. {capt_hook-6.3.0 → capt_hook-6.4.0}/pyproject.toml +1 -1
  18. {capt_hook-6.3.0 → capt_hook-6.4.0}/LICENSE +0 -0
  19. {capt_hook-6.3.0 → capt_hook-6.4.0}/README.md +0 -0
  20. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  21. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/__init__.py +0 -0
  22. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/__main__.py +0 -0
  23. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/app.py +0 -0
  24. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/ast_grep.py +0 -0
  25. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/classifiers/__init__.py +0 -0
  26. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/classifiers/conductor.py +0 -0
  27. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/classifiers/droid.py +0 -0
  28. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/classifiers/native.py +0 -0
  29. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/command.py +0 -0
  30. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/conditions.py +0 -0
  31. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/contexts.py +0 -0
  32. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/decisions.py +0 -0
  33. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/dispatch.py +0 -0
  34. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/events.py +0 -0
  35. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/fields.py +0 -0
  36. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/file.py +0 -0
  37. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/log.py +0 -0
  38. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/__init__.py +0 -0
  39. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/__init__.py +0 -0
  40. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/_lib.py +0 -0
  41. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/commands.py +0 -0
  42. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/docs.py +0 -0
  43. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/plans.py +0 -0
  44. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/prompts.py +0 -0
  45. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/review.py +0 -0
  46. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/tasks.py +0 -0
  47. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/general/tombstones.py +0 -0
  48. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/go/__init__.py +0 -0
  49. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  50. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/go/testing.py +0 -0
  51. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/go/toolchain.py +0 -0
  52. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/__init__.py +0 -0
  53. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  54. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/style.py +0 -0
  55. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/testing.py +0 -0
  56. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/python/toolchain.py +0 -0
  57. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/steering/__init__.py +0 -0
  58. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  59. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/packs/steering/steering.py +0 -0
  60. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/__init__.py +0 -0
  61. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/commands.py +0 -0
  62. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/lint.py +0 -0
  63. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/nudge.py +0 -0
  64. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/rewrite.py +0 -0
  65. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/primitives/workflow.py +0 -0
  66. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/py.typed +0 -0
  67. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/__init__.py +0 -0
  68. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/cli.py +0 -0
  69. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/dashboard.py +0 -0
  70. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/fix.py +0 -0
  71. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/formats.py +0 -0
  72. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/judge.py +0 -0
  73. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/pipeline.py +0 -0
  74. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/repo.py +0 -0
  75. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/scan.py +0 -0
  76. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/settings.py +0 -0
  77. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/store.py +0 -0
  78. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/review/sync.py +0 -0
  79. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/signals/__init__.py +0 -0
  80. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/signals/nlp.py +0 -0
  81. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  82. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  83. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  84. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  85. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  86. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  87. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  88. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  89. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  90. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  91. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  92. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  93. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  94. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/__init__.py +0 -0
  95. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/ast_grep.py +0 -0
  96. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/matchers.py +0 -0
  97. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/scope.py +0 -0
  98. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/style/types.py +0 -0
  99. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/tasks.py +0 -0
  100. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  101. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/testing/__init__.py +0 -0
  102. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/testing/types.py +0 -0
  103. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/types.py +0 -0
  104. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/__init__.py +0 -0
  105. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/fs.py +0 -0
  106. {capt_hook-6.3.0 → capt_hook-6.4.0}/captain_hook/util/http.py +0 -0
  107. {capt_hook-6.3.0 → capt_hook-6.4.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.3.0
3
+ Version: 6.4.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
@@ -501,7 +501,9 @@ def run_tests(json_output: bool = False) -> None:
501
501
  @click.pass_context
502
502
  def cli(ctx: click.Context, hooks: str | None, root_path: str | None) -> None:
503
503
  """Captain Hook — declarative hook framework for Claude Code lifecycle events."""
504
- root = Path(root_path) if root_path else Path(env) if (env := os.environ.get("CLAUDE_PROJECT_DIR")) else Path.cwd()
504
+ from captain_hook.settings import resolve_project_dir
505
+
506
+ root = Path(root_path) if root_path else Path(p) if (p := resolve_project_dir()) else Path.cwd()
505
507
  ctx.obj = CliState(root=root, hooks=hooks or str(root / ".claude" / "hooks"))
506
508
 
507
509
 
@@ -21,6 +21,7 @@ from spawnllm.proc import run_cli
21
21
  from captain_hook.classifiers import detect
22
22
  from captain_hook.prompt import Prompt
23
23
  from captain_hook.session import SessionStore
24
+ from captain_hook.settings import resolve_project_dir
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from collections.abc import Sequence
@@ -78,7 +79,7 @@ def lift_session(events: Sequence[TranscriptEvent], *, path: Path | None = None)
78
79
  from captain_hook.app import _state
79
80
 
80
81
  classifier = _state.classifier or detect(
81
- cwd=os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR"),
82
+ cwd=resolve_project_dir(),
82
83
  transcript_path=str(path) if path else None,
83
84
  events=events,
84
85
  )
@@ -185,7 +186,7 @@ class HookContext:
185
186
  input=input,
186
187
  timeout=timeout,
187
188
  env=os.environ | (env or {}),
188
- cwd=os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR"),
189
+ cwd=resolve_project_dir(),
189
190
  )
190
191
  except (OSError, subprocess.SubprocessError):
191
192
  if throw:
@@ -309,7 +310,7 @@ class HookContext:
309
310
  ) -> str | BaseModel:
310
311
  diff_text = self.diff("uncommitted" if diff is True else diff) if diff else None
311
312
  prompt = self.assemble_prompt(template, args, kwargs, transcript=transcript, diff_text=diff_text)
312
- cwd = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR")
313
+ cwd = resolve_project_dir()
313
314
  if response_model is not None:
314
315
  return extract_sync(
315
316
  prompt, response_model, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout
@@ -26,13 +26,11 @@ if TYPE_CHECKING:
26
26
 
27
27
  Scope = Literal["project", "global"]
28
28
 
29
- DURABLE_ROOT_OVERRIDE: list[Path] = []
30
-
31
29
 
32
30
  def durable_root() -> Path:
33
31
  from captain_hook.settings import resolve_state_dir
34
32
 
35
- return DURABLE_ROOT_OVERRIDE[-1] if DURABLE_ROOT_OVERRIDE else resolve_state_dir() / "hooks" / "durable"
33
+ return resolve_state_dir() / "hooks" / "durable"
36
34
 
37
35
 
38
36
  def project_key(repo_root: Path) -> str:
@@ -98,11 +96,6 @@ class DurableStore:
98
96
  if model not in cls.TRACKED:
99
97
  cls.TRACKED.append(model)
100
98
 
101
- @classmethod
102
- def untrack(cls, model: type[BaseModel]) -> None:
103
- if model in cls.TRACKED:
104
- cls.TRACKED.remove(model)
105
-
106
99
  @classmethod
107
100
  def tracked_models(cls) -> Sequence[type[BaseModel]]:
108
101
  return tuple(cls.TRACKED)
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING
13
13
 
14
14
  from loguru import logger
15
15
 
16
- from captain_hook.app import State, _state, on
16
+ from captain_hook.app import _state, on
17
17
  from captain_hook.settings import build_settings
18
18
  from captain_hook.types import Event
19
19
 
@@ -47,8 +47,7 @@ def import_or_reload(fqn: str, fresh_this_pass: set[str]) -> ModuleType:
47
47
  return mod
48
48
 
49
49
 
50
- def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
51
- target = state or _state
50
+ def discover_hooks(hooks_dir: str | Path) -> None:
52
51
  hooks_path = Path(hooks_dir).resolve()
53
52
  if str(hooks_path.parent) not in sys.path:
54
53
  sys.path.insert(0, str(hooks_path.parent))
@@ -60,9 +59,9 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
60
59
 
61
60
  if CONF_MODULE in top_level:
62
61
  conf_module = import_or_reload(f"{pkg}.{CONF_MODULE}", fresh_this_pass)
63
- target.settings = build_settings(conf_module)
62
+ _state.settings = build_settings(conf_module)
64
63
  if classifier := getattr(conf_module, "classifier", None):
65
- target.classifier = classifier
64
+ _state.classifier = classifier
66
65
 
67
66
  all_modules = {
68
67
  info.name
@@ -80,7 +79,7 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
80
79
  import_or_reload(fqn, fresh_this_pass)
81
80
  except Exception as exc:
82
81
  logger.bind(module=fqn).opt(exception=True).warning("skipped unloadable hook module")
83
- target.load_errors.append((fqn, exc))
82
+ _state.load_errors.append((fqn, exc))
84
83
 
85
84
 
86
85
  def ensure_pack_package(fqn: str, search_paths: list[str]) -> ModuleType:
@@ -1,5 +1,5 @@
1
1
  name = "general"
2
- version = "0.8.0"
2
+ version = "0.9.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
@@ -10,6 +10,7 @@ from captain_hook import (
10
10
  Block,
11
11
  Clause,
12
12
  Event,
13
+ FilePath,
13
14
  Input,
14
15
  Phrase,
15
16
  Rewrite,
@@ -43,6 +44,19 @@ class DelegatedSpawn:
43
44
  return f"model: {model}\nagent_type: {call.agent_type or '(default)'}\nprompt:\n{call.prompt}"
44
45
 
45
46
 
47
+ @dataclass(frozen=True, slots=True)
48
+ class InlineEdit:
49
+ """Gating context: the file the main agent is about to edit inline on the main loop."""
50
+
51
+ tag: str = "edit_target"
52
+ required: bool = True
53
+
54
+ def content(self, evt: BaseHookEvent) -> str | None:
55
+ if not evt.file or evt.content is None:
56
+ return None
57
+ return f"file: {evt.file.path}\nincoming text: {len(evt.content):,} chars"
58
+
59
+
46
60
  WORKFLOW_SCRIPT_CAP = 14_000 # below the prose hooks' max_context=16_000, so truncation stays ours
47
61
 
48
62
 
@@ -120,12 +134,13 @@ class ProseSpawn(DelegatedSpawn):
120
134
 
121
135
  @dataclass(frozen=True, slots=True)
122
136
  class WorkflowScriptSource:
123
- """Gating context: the pending Workflow call's script source, headed by its model pins and prose asks."""
137
+ """Gating context: the pending Workflow call's script source, headed by its model pins."""
124
138
 
125
139
  tag: str = "workflow_script"
126
140
  required: bool = True
127
141
 
128
- def content(self, evt: BaseHookEvent) -> str | None:
142
+ @staticmethod
143
+ def pins_and_source(evt: BaseHookEvent) -> tuple[str, str] | None:
129
144
  if (source := workflow_script_source(evt)) is None:
130
145
  return None
131
146
  pin_lines = "\n".join(
@@ -138,14 +153,31 @@ class WorkflowScriptSource:
138
153
  f"… [script truncated: {len(source):,} chars total; every model pin is quoted above] …\n"
139
154
  f"{source[-tail:]}"
140
155
  )
156
+ header = (
157
+ "lines that pin a model in this script (a stage not quoted here inherits the "
158
+ f"session model, fable):\n{pin_lines or ' (none)'}"
159
+ )
160
+ return header, source
161
+
162
+ def content(self, evt: BaseHookEvent) -> str | None:
163
+ if (parts := self.pins_and_source(evt)) is None:
164
+ return None
165
+ header, source = parts
166
+ return f"{header}\n\n{source}"
167
+
168
+
169
+ @dataclass(frozen=True, slots=True)
170
+ class ProseWorkflowScript(WorkflowScriptSource):
171
+ """Gating context: the script source, present only when its prose asks survive the prefilter."""
172
+
173
+ def content(self, evt: BaseHookEvent) -> str | None:
174
+ if (parts := self.pins_and_source(evt)) is None:
175
+ return None
176
+ header, source = parts
141
177
  if not (sentences := prose_deliverable_sentences(source)):
142
178
  return None
143
179
  matched = "\n".join(f" {s[:300]}" for s in sentences)
144
- return (
145
- "lines that pin a model in this script (a stage not quoted here inherits the "
146
- f"session model, fable):\n{pin_lines or ' (none)'}\n\n"
147
- f"sentences the prose prefilter matched:\n{matched}\n\n{source}"
148
- )
180
+ return f"{header}\n\nsentences the prose prefilter matched:\n{matched}\n\n{source}"
149
181
 
150
182
 
151
183
  hook(
@@ -286,16 +318,17 @@ llm_nudge(
286
318
  the session model, fable), agent type, and prompt.
287
319
 
288
320
  The Models rubric: implementation delegates to opus-4.8 at xhigh — opus is ~2x cheaper
289
- than fable and nearly as capable. Fable's lanes are orchestration, review, hard
290
- planning/design/diagnosis, all prose/writing, and implementation that is very sensitive
291
- or error-prone (auth, migrations, concurrency, data loss, crypto, subtle algorithms).
321
+ than fable and nearly as capable. Fable's lanes are orchestration, design/architecture
322
+ review, hard planning, all prose/writing, and implementation that is very sensitive or
323
+ error-prone (auth, migrations, concurrency, data loss, crypto, subtle algorithms).
324
+ Code/diff review and bug diagnosis have their own gpt-5.5 lane with a separate nudge.
292
325
 
293
326
  Set fire=true only when the prompt is clearly routine implementation — building, fixing,
294
327
  wiring, or refactoring code — with no fable-lane signal. A prompt that reviews, plans,
295
- designs, diagnoses a hard bug, writes prose, or touches a sensitive surface stays on
296
- fable: fire=false. When uncertain, fire=false — the agent may have chosen fable
297
- deliberately, and a false alarm teaches it to ignore this nudge. Keep reasoning under
298
- 40 words.
328
+ designs, diagnoses a bug, writes prose, or touches a sensitive surface is not an
329
+ implementation prompt: fire=false. When uncertain, fire=false — the agent may have
330
+ chosen fable deliberately, and a false alarm teaches it to ignore this nudge. Keep
331
+ reasoning under 40 words.
299
332
 
300
333
  <examples>
301
334
  <example fire="true">
@@ -308,7 +341,7 @@ Well-scoped feature wiring; the default implementation lane.
308
341
  </example>
309
342
  <example fire="false">
310
343
  Review the diff for correctness and concurrency issues.
311
- Review is fable's lane.
344
+ Not implementation — review routes via its own nudge (gpt-5.5's lane), not to opus.
312
345
  </example>
313
346
  <example fire="false">
314
347
  Design the migration strategy for the sharded session store.
@@ -344,6 +377,184 @@ Auth plus concurrency: sensitive, error-prone implementation stays on fable.
344
377
  },
345
378
  )
346
379
 
380
+ llm_nudge(
381
+ """Decide whether the main agent should delegate this inline edit instead of making
382
+ it itself.
383
+
384
+ The main loop runs on fable-5; this pending edit is fable implementing directly.
385
+ <edit_target> names the file; <before_edit>/<after_edit> hold the text being replaced
386
+ and written.
387
+
388
+ The Models rubric: implementation belongs to a delegated opus-4.8 subagent at xhigh —
389
+ ~2x cheaper than fable and nearly as capable — or, for a well-scoped edit to existing
390
+ code, to gpt-5.5 via the codex skill. Fable edits inline when the change is small or
391
+ judgment-bound: a fix-up finishing work it just reasoned through, a subtle algorithm,
392
+ or a sensitive surface (auth, migrations, concurrency, data loss, crypto).
393
+
394
+ Set fire=true only when this edit is clearly substantial routine implementation —
395
+ building out a feature, wiring components, refactoring — that a subagent could own end
396
+ to end. A small fix-up, a sensitive surface, or a change entangled with judgment the
397
+ main agent just exercised stays inline: fire=false. When uncertain, fire=false — the
398
+ agent may be editing inline deliberately, and a false alarm teaches it to ignore this
399
+ nudge. Keep reasoning under 40 words.
400
+
401
+ <examples>
402
+ <example fire="true">
403
+ after_edit: a new 180-line pagination module written to src/api/pagination.py.
404
+ Substantial net-new feature code — a delegated opus xhigh subagent's lane.
405
+ </example>
406
+ <example fire="true">
407
+ after_edit: rewiring three call sites and adding a formatter class in export.py.
408
+ Routine multi-part refactor a subagent could own end to end.
409
+ </example>
410
+ <example fire="false">
411
+ after_edit: a two-line fix to the retry counter the agent just diagnosed.
412
+ Small fix-up entangled with judgment already exercised — inline is right.
413
+ </example>
414
+ <example fire="false">
415
+ after_edit: reworking the token-refresh lock in auth/middleware.py.
416
+ Auth plus concurrency is a sensitive surface — fable's inline lane.
417
+ </example>
418
+ </examples>""",
419
+ message=lambda r: (
420
+ f"This inline edit reads as routine implementation on fable. {r.reasoning} "
421
+ "Implementation delegates: spawn a model='opus', effort='xhigh' subagent to own the change, "
422
+ "or route a well-scoped edit to gpt-5.5 via the codex skill. Keep editing inline only when "
423
+ "the change is small, sensitive, or bound to judgment you just exercised. "
424
+ "See CLAUDE.md § Plan Execution & Orchestration (Models)."
425
+ ),
426
+ contexts=[InlineEdit()],
427
+ events=Event.PreToolUse,
428
+ only_if=[
429
+ Tool("Edit|Write|MultiEdit"),
430
+ FilePath(
431
+ "**/*.py",
432
+ "**/*.go",
433
+ "**/*.swift",
434
+ "**/*.rs",
435
+ "**/*.ts",
436
+ "**/*.tsx",
437
+ "**/*.js",
438
+ "**/*.jsx",
439
+ "**/*.rb",
440
+ "**/*.java",
441
+ "**/*.kt",
442
+ "**/*.c",
443
+ "**/*.cc",
444
+ "**/*.cpp",
445
+ "**/*.h",
446
+ "**/*.zig",
447
+ ),
448
+ ],
449
+ skip_if=[
450
+ FilePath("**/test_*.py", "**/*_test.py", "**/*_test.go", "**/tests/**", "**/*.test.*", "**/*.spec.*"),
451
+ ],
452
+ when=lambda evt: not evt.is_subagent and len(evt.content or "") >= 400,
453
+ max_fires=1,
454
+ agent=False,
455
+ transcript=False,
456
+ tests={
457
+ Input(
458
+ file="src/api/users.py",
459
+ content="def list_users(page: int):\n return paginate(page)\n" * 12,
460
+ ): Warn(pattern="opus"),
461
+ Input(
462
+ file="README.md",
463
+ content="Pagination lands in the users API.\n" * 20,
464
+ ): Allow(),
465
+ Input(file="src/api/users.py", old="page = 1", content="page = 2"): Allow(),
466
+ Input(
467
+ file="tests/test_users.py",
468
+ content="def test_list_users(page: int):\n assert paginate(page)\n" * 12,
469
+ ): Allow(),
470
+ Input(
471
+ file="src/auth/middleware.py",
472
+ content="def refresh_token(lock: Lock):\n with lock:\n rotate()\n" * 12,
473
+ llm={"fire": False},
474
+ ): Allow(),
475
+ },
476
+ )
477
+
478
+ llm_nudge(
479
+ """Decide whether this delegated subagent runs code review or bug diagnosis that
480
+ should route to gpt-5.5 instead of fable.
481
+
482
+ <delegated_spawn> holds the pending Agent/Task call: its model pin (or that it inherits
483
+ the session model, fable), agent type, and prompt.
484
+
485
+ The Models rubric: code/diff review — sweeping a diff or codebase for bugs,
486
+ correctness, or cleanups; finder and refuter passes over findings — and bug diagnosis
487
+ route to gpt-5.5 via the codex skill; fable is the escalation target when gpt-5.5's
488
+ output misses. Fable keeps design/architecture review, "is this the right approach"
489
+ judgment, prose review, and the synthesis/accept-reject pass over review findings.
490
+
491
+ Set fire=true only when the prompt clearly reviews code or diffs for defects, or
492
+ diagnoses a bug, and the spawn would run on fable. Design review, approach judgment,
493
+ synthesis over findings, and prose review are fable's lanes: fire=false. When
494
+ uncertain, fire=false — the agent may have chosen fable deliberately, and a false
495
+ alarm teaches it to ignore this nudge. Keep reasoning under 40 words.
496
+
497
+ <examples>
498
+ <example fire="true">
499
+ Review the diff for correctness and concurrency issues; report findings as JSON.
500
+ Diff review for defects — gpt-5.5's lane via codex.
501
+ </example>
502
+ <example fire="true">
503
+ Adversarially refute this finding: the retry loop double-increments the counter.
504
+ A refuter pass over a code finding is review work.
505
+ </example>
506
+ <example fire="true">
507
+ Diagnose why the exporter hangs when two workers flush concurrently.
508
+ Bug diagnosis starts on gpt-5.5; fable is the escalation target.
509
+ </example>
510
+ <example fire="false">
511
+ Judge these three sharding designs and recommend one.
512
+ Design/architecture judgment is fable's lane.
513
+ </example>
514
+ <example fire="false">
515
+ Synthesize the confirmed findings and decide which to fix before release.
516
+ The accept-reject pass over findings stays on fable.
517
+ </example>
518
+ <example fire="false">
519
+ Review the README draft for factual errors.
520
+ Prose review stays on fable.
521
+ </example>
522
+ </examples>""",
523
+ message=lambda r: (
524
+ f"This review/diagnosis delegation would run on fable. {r.reasoning} "
525
+ "Code/diff review and bug diagnosis route to gpt-5.5: run the codex skill (from a "
526
+ "workflow or subagent, spawn a model='sonnet', effort='low' wrapper that writes a "
527
+ "self-contained codex prompt), and escalate to fable only when gpt-5.5's output misses. "
528
+ "Design review and findings synthesis stay on fable. "
529
+ "See CLAUDE.md § Plan Execution & Orchestration (Models)."
530
+ ),
531
+ contexts=[DelegatedSpawn()],
532
+ events=Event.PreToolUse,
533
+ only_if=[
534
+ Tool("Agent|Task"),
535
+ ToolInput("prompt", r"(?i)\b(review|refut|adversari|audit|correctness|diagnos|root.?caus)"),
536
+ ],
537
+ skip_if=[
538
+ ToolInput("model", r"(?i)\b(opus|sonnet|haiku)\b"),
539
+ Agent("Explore|claude-code-guide"),
540
+ ],
541
+ agent=False,
542
+ transcript=False,
543
+ tests={
544
+ Input(prompt="Review the diff for correctness and concurrency issues"): Warn(pattern="gpt-5.5"),
545
+ Input(model="fable", prompt="Adversarially refute this finding: the retry loop is wrong"): Warn(
546
+ pattern="codex"
547
+ ),
548
+ Input(model="sonnet", prompt="Review the diff for correctness via the codex skill"): Allow(),
549
+ Input(prompt="fix the failing import in cli.py"): Allow(),
550
+ Input(agent_type="Explore", prompt="find where the review pipeline lives"): Allow(),
551
+ Input(
552
+ prompt="Synthesize the confirmed review findings and decide which to fix",
553
+ llm={"fire": False},
554
+ ): Allow(),
555
+ },
556
+ )
557
+
347
558
  nudge(
348
559
  """
349
560
  This workflow script pins agent() steps to haiku. Reserve haiku for mechanical single-fact
@@ -422,7 +633,7 @@ Reading and classifying docs is analysis, not a prose deliverable.
422
633
  "session model or pin model: 'fable' on that stage. "
423
634
  "See CLAUDE.md § Plan Execution & Orchestration (Models)."
424
635
  ),
425
- contexts=[WorkflowScriptSource()],
636
+ contexts=[ProseWorkflowScript()],
426
637
  events=Event.PreToolUse,
427
638
  only_if=[
428
639
  Tool("Workflow"),
@@ -445,3 +656,79 @@ Reading and classifying docs is analysis, not a prose deliverable.
445
656
  ): Allow(),
446
657
  },
447
658
  )
659
+
660
+ llm_nudge(
661
+ """Decide whether this workflow script runs code review or bug diagnosis stages on
662
+ fable that should route to gpt-5.5.
663
+
664
+ <workflow_script> holds the pending Workflow call's script source, headed by every
665
+ line that pins a model — a stage not quoted there carries no pin and inherits the
666
+ session model, fable.
667
+
668
+ The Models rubric: code/diff review stages — finder sweeps over a diff or codebase,
669
+ adversarial refuters over findings — and bug diagnosis route to gpt-5.5 via the codex
670
+ skill. A stage does that correctly when it pins model 'sonnet' at low effort and its
671
+ prompt writes a self-contained codex prompt and runs the codex skill. Fable keeps the
672
+ synthesis/accept-reject stage over findings and design/architecture judgment.
673
+
674
+ Set fire=true only when at least one review or diagnosis stage would run on fable —
675
+ unpinned, or pinned 'fable'. Stages already wrapped for codex, synthesis stages, and
676
+ design judgment are routed right: fire=false. When uncertain, fire=false — a false
677
+ alarm teaches the agent to ignore this nudge. Keep reasoning under 40 words and name
678
+ the offending stage.
679
+
680
+ <examples>
681
+ <example fire="true">
682
+ agent(`Sweep the diff for go-correctness issues; return findings as JSON`)
683
+ An unpinned finder inherits fable; finder sweeps are the codex-wrapper lane.
684
+ </example>
685
+ <example fire="true">
686
+ findings.map(f => agent(`Adversarially refute: ${f.title}`, {effort: 'max'}))
687
+ Refuters over code findings inherit fable — route them through codex wrappers.
688
+ </example>
689
+ <example fire="false">
690
+ agent(`Write a self-contained codex prompt reviewing this diff for correctness,
691
+ then run the codex skill`, {model: 'sonnet', effort: 'low'})
692
+ Already the codex wrapper — correctly routed.
693
+ </example>
694
+ <example fire="false">
695
+ agent(`Synthesize the confirmed findings and decide which to fix`)
696
+ Synthesis/accept-reject stays on fable.
697
+ </example>
698
+ </examples>""",
699
+ message=lambda r: (
700
+ f"This workflow runs review/diagnosis stages on fable. {r.reasoning} "
701
+ "Route finder, refuter, and diagnosis stages to gpt-5.5: make each a model: 'sonnet', "
702
+ "effort: 'low' stage that writes a self-contained codex prompt and runs the codex skill; "
703
+ "keep the synthesis/accept-reject stage on fable (inherit the session model). "
704
+ "See CLAUDE.md § Plan Execution & Orchestration (Models)."
705
+ ),
706
+ contexts=[WorkflowScriptSource()],
707
+ events=Event.PreToolUse,
708
+ only_if=[
709
+ Tool("Workflow"),
710
+ WorkflowScript(pattern=r"(?i)\b(review|refut|adversari|audit|correctness|diagnos)"),
711
+ ],
712
+ max_fires=2,
713
+ max_context=16_000,
714
+ agent=False,
715
+ transcript=False,
716
+ tests={
717
+ Input(script="const findings = await agent(`Sweep the diff for correctness issues; return JSON`)"): Warn(
718
+ pattern="codex"
719
+ ),
720
+ Input(script="agent(`Adversarially refute: ${f.title}`, {model: 'fable', effort: 'max'})"): Warn(
721
+ pattern="gpt-5.5"
722
+ ),
723
+ Input(
724
+ script="agent('Write a self-contained codex prompt reviewing this diff, "
725
+ "then run the codex skill', {model: 'sonnet', effort: 'low'})",
726
+ llm={"fire": False},
727
+ ): Allow(),
728
+ Input(script="agent('fix the failing import in cli.py')"): Allow(),
729
+ Input(
730
+ script="agent(`Synthesize the confirmed review findings and decide which to fix`)",
731
+ llm={"fire": False},
732
+ ): Allow(),
733
+ },
734
+ )
@@ -32,7 +32,7 @@ from typing import Any
32
32
 
33
33
  from filelock import FileLock
34
34
 
35
- from captain_hook import state
35
+ from captain_hook.settings import resolve_cache_dir
36
36
  from captain_hook.util import http
37
37
 
38
38
  PACK_MANIFEST = "capt-hook.toml"
@@ -209,7 +209,7 @@ def delete_entry(path: Path, name: str) -> None:
209
209
 
210
210
 
211
211
  def packs_cache_root() -> Path:
212
- return state.CACHE_ROOT / "packs"
212
+ return resolve_cache_dir() / "packs"
213
213
 
214
214
 
215
215
  def meta_path(name: str) -> Path:
@@ -9,11 +9,11 @@ from typing import TYPE_CHECKING, Any, Literal
9
9
  from loguru import logger
10
10
  from pydantic import BaseModel
11
11
 
12
- from captain_hook import state
13
12
  from captain_hook.app import on
14
13
  from captain_hook.contexts import apply_contexts, with_defaults
15
14
  from captain_hook.primitives.nudge import DEFAULT_FIRES
16
15
  from captain_hook.prompt import Prompt
16
+ from captain_hook.settings import resolve_cache_dir
17
17
  from captain_hook.signals import extract_signal_context, resolve_signals, transcript_texts
18
18
  from captain_hook.state import PrimitiveState, fired_this_turn, hook_name, record_fire
19
19
  from captain_hook.types import (
@@ -27,8 +27,6 @@ from captain_hook.types import (
27
27
  Waiting,
28
28
  )
29
29
 
30
- FAILURE_ROOT = state.CACHE_ROOT / "failures"
31
-
32
30
  if TYPE_CHECKING:
33
31
  from spawnllm import TModel, TSpecialty
34
32
 
@@ -362,7 +360,9 @@ def record_prompt_check_failure(
362
360
  case _:
363
361
  argv, exit_code, stdout, stderr = None, None, "", ""
364
362
 
365
- failure_path = FAILURE_ROOT / (p.stem if (p := evt.ctx.t.path) else "unknown") / f"{timestamp}.json"
363
+ failure_path = (
364
+ resolve_cache_dir() / "failures" / (p.stem if (p := evt.ctx.t.path) else "unknown") / f"{timestamp}.json"
365
+ )
366
366
  failure_path.parent.mkdir(parents=True, exist_ok=True)
367
367
  failure_path.write_text(
368
368
  json.dumps(
@@ -5,17 +5,16 @@ import textwrap
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
 
8
+ from captain_hook.state import FRAMEWORK_DIR
9
+
8
10
 
9
11
  def dedent_text(text: str) -> str:
10
12
  return textwrap.dedent(text).strip()
11
13
 
12
14
 
13
- _FRAMEWORK_DIR = Path(__file__).resolve().parent
14
-
15
-
16
- def _caller_dir() -> Path:
15
+ def caller_dir() -> Path:
17
16
  frame = inspect.currentframe()
18
- while frame and Path(frame.f_code.co_filename).resolve().is_relative_to(_FRAMEWORK_DIR):
17
+ while frame and Path(frame.f_code.co_filename).resolve().is_relative_to(FRAMEWORK_DIR):
19
18
  frame = frame.f_back
20
19
  return Path(frame.f_code.co_filename).resolve().parent if frame else Path.cwd()
21
20
 
@@ -83,7 +82,7 @@ class Prompt:
83
82
  FileNotFoundError: If no matching file exists in any searched directory.
84
83
  KeyError: If the file references a placeholder not supplied in ``**vars``.
85
84
  """
86
- dirs = [Path(base) if base else _caller_dir() / "prompts", _FRAMEWORK_DIR / "prompts"]
85
+ dirs = [Path(base) if base else caller_dir() / "prompts", Path(FRAMEWORK_DIR) / "prompts"]
87
86
  for path in (d / f"{name}.md" for d in dirs):
88
87
  if path.is_file():
89
88
  return cls.from_template(path.read_text(), **vars)
@@ -149,12 +149,6 @@ class SessionStore:
149
149
  if model not in cls.TRACKED:
150
150
  cls.TRACKED.append(model)
151
151
 
152
- @classmethod
153
- def untrack(cls, model: type[BaseModel]) -> None:
154
- """Reverse ``track`` — primarily for test isolation."""
155
- if model in cls.TRACKED:
156
- cls.TRACKED.remove(model)
157
-
158
152
  @classmethod
159
153
  def tracked_models(cls) -> Sequence[type[BaseModel]]:
160
154
  """Return the registered tracked-state models as an immutable tuple."""
@@ -32,18 +32,23 @@ DEFAULT_WAITING_TOOLS = [
32
32
 
33
33
 
34
34
  def resolve_state_dir() -> Path:
35
- return Path(
36
- os.environ.get("CAPTAIN_HOOK_STATE_DIR")
37
- or os.environ.get("CLAUDE_HOOKS_STATE_DIR")
38
- or Path.home() / ".claude" / "state"
39
- )
35
+ return Path(os.environ.get("CAPTAIN_HOOK_STATE_DIR") or Path.home() / ".claude" / "state")
36
+
37
+
38
+ def resolve_project_dir() -> str | None:
39
+ return os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR")
40
+
41
+
42
+ def resolve_cache_home() -> Path:
43
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
44
+
45
+
46
+ def resolve_cache_dir() -> Path:
47
+ return resolve_cache_home() / "captain-hook"
40
48
 
41
49
 
42
50
  def resolve_log_dir() -> Path:
43
- return Path(
44
- os.environ.get("CAPTAIN_HOOK_LOG_DIR")
45
- or Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "captain-hook" / "logs"
46
- )
51
+ return Path(os.environ.get("CAPTAIN_HOOK_LOG_DIR") or resolve_cache_dir() / "logs")
47
52
 
48
53
 
49
54
  class HooksSettings(BaseSettings):
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import inspect
6
- import os
7
6
  import re
8
7
  from collections.abc import Callable, Iterable
9
8
  from dataclasses import dataclass
@@ -24,7 +23,6 @@ if TYPE_CHECKING:
24
23
 
25
24
  FRAMEWORK_DIR = str(Path(__file__).resolve().parent)
26
25
  PACKS_DIR = str(Path(FRAMEWORK_DIR) / "packs")
27
- CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "captain-hook"
28
26
  SPACY_MODEL = "en_core_web_sm"
29
27
 
30
28
 
@@ -1,14 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import atexit
4
+ import functools
5
+ import os
4
6
  import re
5
7
  import shutil
6
8
  import tempfile
7
- from collections.abc import Callable, Iterator
9
+ from collections.abc import Iterator
8
10
  from contextlib import contextmanager
11
+ from dataclasses import dataclass, field
9
12
  from itertools import count
10
13
  from pathlib import Path
11
- from typing import Any, cast
14
+ from typing import Any, cast, overload
12
15
 
13
16
  from cc_transcript.parser import parse_event
14
17
  from cc_transcript.query import Session
@@ -27,6 +30,7 @@ from captain_hook.events import (
27
30
  ToolHookEvent,
28
31
  UserPromptSubmitEvent,
29
32
  )
33
+ from captain_hook.prompt import Prompt
30
34
  from captain_hook.session import SessionStore
31
35
  from captain_hook.testing.session_cache import SessionCache
32
36
  from captain_hook.testing.types import Allow, Block, FileFixture, Input, Rewrite, TranscriptFixture, Warn
@@ -40,16 +44,52 @@ STUB_FIELD_VALUES: dict[str, Any] = {
40
44
  "reason": "inline test stub",
41
45
  }
42
46
 
43
- FIXTURE_FILE_DIR: list[Path] = []
44
47
  FIXTURE_FILE_COUNTER = count()
45
48
 
46
49
 
50
+ @dataclass
51
+ class StubbedContext(HookContext):
52
+ """HookContext for inline tests: ``call_llm`` fabricates a deterministic verdict from
53
+ ``STUB_FIELD_VALUES`` merged with the per-test ``Input.llm`` overrides."""
54
+
55
+ llm: dict[str, Any] = field(default_factory=dict)
56
+
57
+ @overload
58
+ def call_llm[M: BaseModel](
59
+ self, template: str | Prompt, *args: Any, response_model: type[M], **kwargs: Any
60
+ ) -> M: ...
61
+ @overload
62
+ def call_llm(self, template: str | Prompt, *args: Any, response_model: None = None, **kwargs: Any) -> str: ...
63
+ def call_llm(
64
+ self, template: str | Prompt, *args: Any, response_model: type[BaseModel] | None = None, **kwargs: Any
65
+ ) -> str | BaseModel:
66
+ if response_model is None:
67
+ return "stubbed"
68
+ values = STUB_FIELD_VALUES | self.llm
69
+ return response_model(
70
+ **{
71
+ name: values.get(name, "")
72
+ for name, info in response_model.model_fields.items()
73
+ if name in values or info.default is None
74
+ }
75
+ )
76
+
77
+ @classmethod
78
+ def wrapping(cls, ctx: HookContext, llm: dict[str, Any] | None = None) -> StubbedContext:
79
+ return cls(
80
+ session=ctx.session,
81
+ transcript=ctx.transcript,
82
+ settings=ctx.settings,
83
+ project_root=ctx.project_root,
84
+ llm=llm or {},
85
+ )
86
+
87
+
88
+ @functools.cache
47
89
  def fixture_file_dir() -> Path:
48
- if not FIXTURE_FILE_DIR:
49
- root = Path(tempfile.mkdtemp(prefix="capt-hook-fixture-"))
50
- FIXTURE_FILE_DIR.append(root)
51
- atexit.register(shutil.rmtree, root, ignore_errors=True)
52
- return FIXTURE_FILE_DIR[0]
90
+ root = Path(tempfile.mkdtemp(prefix="capt-hook-fixture-"))
91
+ atexit.register(shutil.rmtree, root, ignore_errors=True)
92
+ return root
53
93
 
54
94
 
55
95
  def materialize_file(fixture: FileFixture) -> str:
@@ -403,13 +443,13 @@ def input_to_event(
403
443
  # injected list in inline tests — no native task store / session_id required.
404
444
  evt.__dict__["tasks"] = Tasks(tuple(Task.from_raw(t) for t in inp.tasks))
405
445
 
446
+ evt.ctx = StubbedContext.wrapping(evt.ctx, inp.llm)
406
447
  return evt
407
448
 
408
449
 
409
450
  def replay_session(entry: Any, jsonl: Path) -> Iterator[HookResult | None]:
410
451
  transcript = load_transcript(jsonl)
411
- ctx = HookContext(session=SessionStore(None), transcript=transcript, settings=None)
412
- ctx.call_llm = stub_call_llm # type: ignore[method-assign]
452
+ ctx = StubbedContext(session=SessionStore(None), transcript=transcript, settings=None)
413
453
  transcript_path = str(jsonl)
414
454
 
415
455
  for ev_type in entry.spec.events:
@@ -508,42 +548,19 @@ def assert_result(
508
548
  assert val in actual, f"{prefix}Rewrite {key}={actual!r} doesn't contain '{val}'"
509
549
 
510
550
 
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()
534
-
535
-
536
551
  @contextmanager
537
- def isolated_durable_root() -> Iterator[Path]:
538
- """Point ``DurableState`` at a throwaway root so tests never read or write the real store."""
539
- from captain_hook.durable import DURABLE_ROOT_OVERRIDE
540
-
541
- root = Path(tempfile.mkdtemp(prefix="capt-hook-durable-"))
542
- DURABLE_ROOT_OVERRIDE.append(root)
552
+ def isolated_state_root() -> Iterator[Path]:
553
+ """Point the whole capt-hook state root at a throwaway dir so inline tests never touch real state."""
554
+ root = Path(tempfile.mkdtemp(prefix="capt-hook-state-"))
555
+ saved = os.environ.get("CAPTAIN_HOOK_STATE_DIR")
556
+ os.environ["CAPTAIN_HOOK_STATE_DIR"] = str(root)
543
557
  try:
544
558
  yield root
545
559
  finally:
546
- DURABLE_ROOT_OVERRIDE.remove(root)
560
+ if saved is None:
561
+ del os.environ["CAPTAIN_HOOK_STATE_DIR"]
562
+ else:
563
+ os.environ["CAPTAIN_HOOK_STATE_DIR"] = saved
547
564
  shutil.rmtree(root, ignore_errors=True)
548
565
 
549
566
 
@@ -552,7 +569,7 @@ def run_inline_tests() -> list[tuple[str, str, bool, str]]:
552
569
 
553
570
  results: list[tuple[str, str, bool, str]] = []
554
571
 
555
- with isolated_durable_root():
572
+ with isolated_state_root():
556
573
  for entry in _state.hooks:
557
574
  if not entry.spec.tests:
558
575
  continue
@@ -571,7 +588,6 @@ def run_inline_tests() -> list[tuple[str, str, bool, str]]:
571
588
  if spec_tools
572
589
  else None,
573
590
  )
574
- evt.ctx.call_llm = make_stub_call_llm(key.llm) # type: ignore[assignment]
575
591
  hook_result = (
576
592
  execute_hook(entry, evt)
577
593
  if matches_conditions(entry.spec, evt) and not is_planning_agent_skip(entry.spec, evt)
@@ -6,6 +6,8 @@ import shutil
6
6
  from pathlib import Path
7
7
  from typing import ClassVar
8
8
 
9
+ from captain_hook.settings import resolve_project_dir
10
+
9
11
 
10
12
  class SessionCache:
11
13
  """Resolves UUID-keyed inline-test fixtures to a cached transcript jsonl.
@@ -24,7 +26,7 @@ class SessionCache:
24
26
 
25
27
  @classmethod
26
28
  def for_root(cls, root: Path | str | None = None) -> SessionCache:
27
- return cls(Path(root or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()))
29
+ return cls(Path(root or resolve_project_dir() or os.getcwd()))
28
30
 
29
31
  def path(self, uuid: str) -> Path:
30
32
  return self.dir / f"{uuid}.jsonl"
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import functools
4
4
  import hashlib
5
- import os
6
5
  import re
7
6
  import shutil
8
7
  import zipfile
@@ -12,6 +11,7 @@ from typing import Any
12
11
 
13
12
  from filelock import FileLock
14
13
 
14
+ from captain_hook.settings import resolve_cache_home
15
15
  from captain_hook.util import http
16
16
 
17
17
  MODEL_NAME = "en_core_web_sm"
@@ -19,9 +19,8 @@ WN_LEXICON = "oewn:2025"
19
19
  WHEEL_CHECKSUM = re.compile(r"Checksum \.whl:\*\*\s*`([0-9a-f]{64})`")
20
20
 
21
21
 
22
- @functools.cache
23
22
  def cache_root() -> Path:
24
- return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "spacy" / "models"
23
+ return resolve_cache_home() / "spacy" / "models"
25
24
 
26
25
 
27
26
  @functools.cache
@@ -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.3.0"
4
+ version = "6.4.0"
5
5
  description = "Declarative hook framework for Claude Code"
6
6
  readme = "README.md"
7
7
  license = "PolyForm-Noncommercial-1.0.0"
File without changes
File without changes
File without changes
File without changes