capt-hook 3.6.0__tar.gz → 3.8.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 (91) hide show
  1. {capt_hook-3.6.0 → capt_hook-3.8.0}/PKG-INFO +3 -3
  2. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/cli.py +16 -12
  3. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/docs.py +3 -1
  4. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/cli.py +41 -9
  5. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/scan.py +35 -1
  6. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/store.py +29 -0
  7. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/SKILL.md +2 -2
  8. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +1 -1
  9. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +1 -1
  10. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +7 -7
  11. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +1 -1
  12. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/translating-styleguides/SKILL.md +1 -1
  13. {capt_hook-3.6.0 → capt_hook-3.8.0}/pyproject.toml +3 -3
  14. {capt_hook-3.6.0 → capt_hook-3.8.0}/LICENSE +0 -0
  15. {capt_hook-3.6.0 → capt_hook-3.8.0}/README.md +0 -0
  16. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  17. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/__init__.py +0 -0
  18. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/__main__.py +0 -0
  19. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/app.py +0 -0
  20. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/classifiers/__init__.py +0 -0
  21. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/classifiers/conductor.py +0 -0
  22. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/classifiers/droid.py +0 -0
  23. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/classifiers/native.py +0 -0
  24. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/command.py +0 -0
  25. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/conditions.py +0 -0
  26. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/context.py +0 -0
  27. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/decisions.py +0 -0
  28. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/dispatch.py +0 -0
  29. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/events.py +0 -0
  30. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/file.py +0 -0
  31. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/llm/__init__.py +0 -0
  32. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/loader.py +0 -0
  33. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/log.py +0 -0
  34. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/__init__.py +0 -0
  35. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  36. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/commands.py +0 -0
  37. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/plans.py +0 -0
  38. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/prompts.py +0 -0
  39. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/review.py +0 -0
  40. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/stewardship.py +0 -0
  41. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/tasks.py +0 -0
  42. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/manager.py +0 -0
  43. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  44. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/python/style.py +0 -0
  45. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/python/testing.py +0 -0
  46. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/python/toolchain.py +0 -0
  47. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/__init__.py +0 -0
  48. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/commands.py +0 -0
  49. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/lint.py +0 -0
  50. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/llm.py +0 -0
  51. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/nudge.py +0 -0
  52. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/workflow.py +0 -0
  53. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/prompt.py +0 -0
  54. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/py.typed +0 -0
  55. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/__init__.py +0 -0
  56. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/dashboard.py +0 -0
  57. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/fix.py +0 -0
  58. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/formats.py +0 -0
  59. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/judge.py +0 -0
  60. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/pipeline.py +0 -0
  61. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/repo.py +0 -0
  62. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/settings.py +0 -0
  63. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/sync.py +0 -0
  64. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/session.py +0 -0
  65. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/settings.py +0 -0
  66. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/signals/__init__.py +0 -0
  67. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/signals/nlp.py +0 -0
  68. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  69. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  70. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  71. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  72. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  73. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  74. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  75. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/state.py +0 -0
  76. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/style/__init__.py +0 -0
  77. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/style/matchers.py +0 -0
  78. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/style/scope.py +0 -0
  79. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/style/types.py +0 -0
  80. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/tasks.py +0 -0
  81. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  82. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/testing/__init__.py +0 -0
  83. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/testing/helpers.py +0 -0
  84. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/testing/session_cache.py +0 -0
  85. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/testing/types.py +0 -0
  86. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/tests/__init__.py +0 -0
  87. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/tests/helpers.py +0 -0
  88. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/types.py +0 -0
  89. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/util/__init__.py +0 -0
  90. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/util/model_cache.py +0 -0
  91. {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 3.6.0
3
+ Version: 3.8.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
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
16
16
  Classifier: Topic :: Software Development :: Quality Assurance
17
17
  Classifier: Topic :: Software Development :: Testing
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: cc-transcript>=3.2,<4
19
+ Requires-Dist: cc-transcript>=4,<5
20
20
  Requires-Dist: pydantic>=2.0
21
21
  Requires-Dist: pydantic-settings>=2.0
22
22
  Requires-Dist: tree-sitter>=0.24
@@ -30,7 +30,7 @@ Requires-Dist: wn>=1.1.0
30
30
  Requires-Dist: lazy-object-proxy>=1.12.0
31
31
  Requires-Dist: filelock>=3
32
32
  Requires-Dist: loguru>=0.7.3
33
- Requires-Dist: spawnllm>=0.1.3
33
+ Requires-Dist: spawnllm>=0.2.0
34
34
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
35
35
  Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
36
36
  Requires-Dist: pyright>=1.1 ; extra == 'dev'
@@ -155,21 +155,25 @@ def capt_hook_events(path: Path) -> set[str]:
155
155
  }
156
156
 
157
157
 
158
+ def sibling_settings(path: Path) -> Path:
159
+ return path.parent / ("settings.json" if path.name == "settings.local.json" else "settings.local.json")
160
+
161
+
158
162
  def merge_settings(
159
163
  hooks_dir: str, settings_path: Path, from_source: str = DIST_NAME
160
164
  ) -> tuple[dict[str, Any], dict[str, str]]:
161
165
  new_hooks: dict[str, list[dict[str, Any]]] = generate_settings(hooks_dir, from_source=from_source)["hooks"]
162
166
  existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
163
167
  existing_hooks: dict[str, list[dict[str, Any]]] = existing.get("hooks") or {}
164
- committed = capt_hook_events(settings_path.parent / "settings.json")
168
+ deferred = capt_hook_events(sibling_settings(settings_path))
165
169
 
166
170
  summary: dict[str, str] = {}
167
171
  merged_hooks: dict[str, list[dict[str, Any]]] = {}
168
172
  for event in sorted(existing_hooks.keys() | new_hooks.keys()):
169
173
  foreign = [g for g in existing_hooks.get(event, []) if not is_captain_hook_group(g)]
170
174
  old_own = [g for g in existing_hooks.get(event, []) if is_captain_hook_group(g)]
171
- fresh_own = [] if event in committed else new_hooks.get(event, [])
172
- if event in committed and (old_own or new_hooks.get(event)):
175
+ fresh_own = [] if event in deferred else new_hooks.get(event, [])
176
+ if event in deferred and (old_own or new_hooks.get(event)):
173
177
  summary[event] = "deferred"
174
178
  elif old_own or fresh_own:
175
179
  summary[event] = (
@@ -193,7 +197,7 @@ def write_settings(settings_path: Path, data: dict[str, Any]) -> None:
193
197
  os.replace(tmp, settings_path)
194
198
 
195
199
 
196
- def print_hook_summary(label: str, summary: dict[str, str]) -> None:
200
+ def print_hook_summary(label: str, summary: dict[str, str], deferred_to: str) -> None:
197
201
  by_status: defaultdict[str, list[str]] = defaultdict(list)
198
202
  for event, status in summary.items():
199
203
  by_status[status].append(event)
@@ -209,15 +213,15 @@ def print_hook_summary(label: str, summary: dict[str, str]) -> None:
209
213
  if unchanged := by_status["unchanged"]:
210
214
  click.echo(f" unchanged: {', '.join(unchanged)} (already present)")
211
215
  if deferred := by_status["deferred"]:
212
- click.echo(f" deferred to settings.json: {', '.join(deferred)}")
216
+ click.echo(f" deferred to {deferred_to}: {', '.join(deferred)}")
213
217
 
214
218
 
215
219
  def regenerate_settings(state: CliState) -> None:
216
220
  state.discover()
217
- settings_path = state.root / ".claude" / "settings.local.json"
221
+ settings_path = state.root / ".claude" / "settings.json"
218
222
  merged, summary = merge_settings(".claude/hooks", settings_path)
219
223
  write_settings(settings_path, merged)
220
- print_hook_summary(str(settings_path.relative_to(state.root)), summary)
224
+ print_hook_summary(str(settings_path.relative_to(state.root)), summary, sibling_settings(settings_path).name)
221
225
 
222
226
 
223
227
  def settings_drift(root: Path) -> set[str]:
@@ -309,7 +313,7 @@ def init_project(root: Path, *, review: bool = True) -> None:
309
313
  if not example.exists():
310
314
  example.write_text(example_hook_source())
311
315
 
312
- settings_path = root / ".claude" / "settings.local.json"
316
+ settings_path = root / ".claude" / "settings.json"
313
317
  CliState(root=root, hooks=str(hooks_dir)).discover()
314
318
  merged, summary = merge_settings(".claude/hooks", settings_path)
315
319
  write_settings(settings_path, merged)
@@ -318,7 +322,7 @@ def init_project(root: Path, *, review: bool = True) -> None:
318
322
 
319
323
  click.echo(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
320
324
  click.echo()
321
- print_hook_summary(str(settings_path.relative_to(root)), summary)
325
+ print_hook_summary(str(settings_path.relative_to(root)), summary, sibling_settings(settings_path).name)
322
326
  click.echo()
323
327
  click.echo("Claude Code plugin:")
324
328
  click.echo(f" + registered {PLUGIN_ID} in .claude/settings.json (skills install on folder-trust)")
@@ -476,15 +480,15 @@ def run(state: CliState, event: str, async_: bool) -> None:
476
480
  )
477
481
  @click.pass_obj
478
482
  def register_hooks_cmd(state: CliState, hooks_dir: str, dry_run: bool, from_source: str) -> None:
479
- """Register captain-hook's event hooks into .claude/settings.local.json."""
483
+ """Register captain-hook's event hooks into .claude/settings.json."""
480
484
  state.discover()
481
- settings_path = state.root / ".claude" / "settings.local.json"
485
+ settings_path = state.root / ".claude" / "settings.json"
482
486
  merged, summary = merge_settings(hooks_dir, settings_path, from_source=from_source)
483
487
  if dry_run:
484
488
  click.echo(json.dumps(merged, indent=2))
485
489
  return
486
490
  write_settings(settings_path, merged)
487
- print_hook_summary(str(settings_path), summary)
491
+ print_hook_summary(str(settings_path), summary, sibling_settings(settings_path).name)
488
492
 
489
493
 
490
494
  @cli.command()
@@ -12,7 +12,9 @@ from captain_hook import Allow, FilePath, Input, Tool, UsedSkill, Warn, nudge
12
12
  nudge(
13
13
  "You're editing documentation. Consult the writing-docs skill first for the "
14
14
  "Diataxis modes, voice rules, and code-sample rules, then run "
15
- "`slop-cop check <file> --lang=markdown` to catch prose tells before you finish.",
15
+ "`slop-cop check <file> --lang=markdown` to catch prose tells before you finish. "
16
+ "slop-cop is a Go binary — if it's not on PATH, run the `/slop-cop-check` skill "
17
+ "(it installs it), never `uvx slop-cop`.",
16
18
  only_if=[Tool("Write|Edit"), FilePath("**/*.md", "**/*.qmd", "docs/**", "README.md")],
17
19
  skip_if=[UsedSkill("writing-docs|writing-docs:writing-docs")],
18
20
  max_fires=1,
@@ -21,6 +21,8 @@ import click
21
21
  if TYPE_CHECKING:
22
22
  from typing import Any
23
23
 
24
+ from cc_transcript.corrections import Correction
25
+
24
26
  from captain_hook.cli import CliState
25
27
  from captain_hook.review.judge import JudgeReport
26
28
  from captain_hook.review.repo import RepoKey
@@ -57,13 +59,13 @@ def review_wired(hooks: dict[str, Any]) -> bool:
57
59
 
58
60
 
59
61
  def ensure_review_wiring(settings_path: Path) -> bool:
60
- from captain_hook.cli import write_settings
62
+ from captain_hook.cli import sibling_settings, write_settings
61
63
 
62
64
  existing: dict[str, Any] = json.loads(settings_path.read_text()) if settings_path.exists() else {}
63
- committed = settings_path.parent / "settings.json"
64
- committed_hooks: dict[str, Any] = (json.loads(committed.read_text()).get("hooks") or {}) if committed.exists() else {}
65
+ sibling = sibling_settings(settings_path)
66
+ sibling_hooks: dict[str, Any] = (json.loads(sibling.read_text()).get("hooks") or {}) if sibling.exists() else {}
65
67
  hooks: dict[str, Any] = existing.get("hooks") or {}
66
- if review_wired(hooks) or review_wired(committed_hooks):
68
+ if review_wired(hooks) or review_wired(sibling_hooks):
67
69
  return False
68
70
  group = {"hooks": [{"type": "command", "command": f"uvx {REVIEW_RUN_COMMAND}"}]}
69
71
  write_settings(
@@ -126,8 +128,8 @@ def enable(state: CliState) -> None:
126
128
  repo = current_repo(state.root)
127
129
  watch_repo(repo)
128
130
  register_marketplace(state.root)
129
- wired = ensure_review_wiring(state.root / ".claude" / "settings.local.json")
130
- click.echo(f"watching {repo}" + (" (SessionEnd hook wired into .claude/settings.local.json)" if wired else ""))
131
+ wired = ensure_review_wiring(state.root / ".claude" / "settings.json")
132
+ click.echo(f"watching {repo}" + (" (SessionEnd hook wired into .claude/settings.json)" if wired else ""))
131
133
 
132
134
 
133
135
  @review.command()
@@ -229,26 +231,52 @@ def list_candidates(state: CliState, repo_: str | None) -> None:
229
231
  click.echo(candidate_line(row))
230
232
 
231
233
 
234
+ def correction_lines(correction: Correction) -> tuple[str, ...]:
235
+ match correction.correction_origin:
236
+ case "session" | "git":
237
+ return (
238
+ f" correction ({correction.correction_origin}):",
239
+ f" - {correction.correction_old}",
240
+ f" + {correction.correction_new}",
241
+ )
242
+ case _ if correction.correction_text:
243
+ return (f" correction note: {correction.correction_text}",)
244
+ case _:
245
+ return ()
246
+
247
+
248
+ def correction_block(correction: Correction) -> str:
249
+ return "\n".join(
250
+ (
251
+ f"- {correction.incorrect_file} (session {correction.session_id}):",
252
+ f" - {correction.incorrect_old}",
253
+ f" + {correction.incorrect_new}",
254
+ *correction_lines(correction),
255
+ )
256
+ )
257
+
258
+
232
259
  @review.command()
233
260
  @click.argument("candidate_id", type=int)
234
261
  def show(candidate_id: int) -> None:
235
- """Show one candidate's row and its threshold status."""
262
+ """Show one candidate's row, its threshold status, and the shared ledger's faulted edits."""
236
263
  from captain_hook.review.judge import REVIEW_PROMPT_VERSION
237
264
  from captain_hook.review.settings import ReviewSettings
238
265
  from captain_hook.review.store import ReviewStore
239
266
 
240
267
  settings = ReviewSettings()
241
268
 
242
- async def go() -> tuple[dict[str, object], ThresholdStatus, bool]:
269
+ async def go() -> tuple[dict[str, object], ThresholdStatus, bool, tuple[Correction, ...]]:
243
270
  async with await ReviewStore.open(settings.db_path) as store:
244
271
  return (
245
272
  await store.candidate(candidate_id),
246
273
  await store.threshold_status(candidate_id, settings=settings, prompt_version=REVIEW_PROMPT_VERSION),
247
274
  await store.eligible(candidate_id, settings=settings, prompt_version=REVIEW_PROMPT_VERSION),
275
+ await store.correction_evidence(candidate_id),
248
276
  )
249
277
 
250
278
  try:
251
- row, status, ok = asyncio.run(go())
279
+ row, status, ok, evidence = asyncio.run(go())
252
280
  except LookupError as exc:
253
281
  raise click.ClickException(str(exc)) from exc
254
282
  for key, value in row.items():
@@ -257,6 +285,10 @@ def show(candidate_id: int) -> None:
257
285
  f"thresholds: sessions={status.sessions} days={status.days} open_prs={status.open_prs} "
258
286
  f"single_observation={status.single_observation} eligible={ok}"
259
287
  )
288
+ if evidence:
289
+ click.echo("correction_evidence:")
290
+ for correction in evidence:
291
+ click.echo(correction_block(correction))
260
292
 
261
293
 
262
294
  @review.command(name="threshold-check")
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
 
31
31
  from dataclasses import dataclass
32
32
  from itertools import chain
33
+ from pathlib import Path
33
34
  from typing import TYPE_CHECKING
34
35
 
35
36
  from cc_transcript.activity import SessionActivity
@@ -76,7 +77,6 @@ from captain_hook.review.store import CandidateKind
76
77
 
77
78
  if TYPE_CHECKING:
78
79
  from collections.abc import Iterable, Iterator, Mapping, Sequence
79
- from pathlib import Path
80
80
  from typing import Any
81
81
 
82
82
  from cc_transcript.backend import ParsedTranscript
@@ -243,6 +243,39 @@ def transcript_repo(events: Sequence[TranscriptEvent]) -> RepoKey | None:
243
243
  )
244
244
 
245
245
 
246
+ def transcript_cwd(events: Sequence[TranscriptEvent]) -> Path | None:
247
+ return next(
248
+ (Path(meta.cwd) for event in events if (meta := event_meta(event)) is not None if meta.cwd is not None),
249
+ None,
250
+ )
251
+
252
+
253
+ async def record_corrections(
254
+ events: Sequence[TranscriptEvent], kept: Sequence[tuple[MiningSignal, FeedbackCandidate]], *, repo: Path | None
255
+ ) -> None:
256
+ """Grounds each user-correction candidate in the shared code-correction ledger.
257
+
258
+ For every kept user-correction signal (the FIX-mode ``hook_complaint`` is a
259
+ local hook misfire, not a code correction, so it is skipped), harvests the
260
+ edit the feedback faults around its anchor and appends one row to the family
261
+ ledger. Idempotent per anchor: a no-op when cc-pushback already wrote it, so
262
+ captain-hook only fills the ledger for sessions nobody else processed.
263
+ """
264
+ from cc_transcript.corrections import CorrectionLog
265
+ from cc_transcript.extract import extract_correction, usable_backend
266
+
267
+ corrections = [(sig, candidate) for sig, candidate in kept if sig.kind != HOOK_COMPLAINT]
268
+ if not corrections:
269
+ return
270
+ activity = SessionActivity.from_events(corrections[0][0].session_id, events)
271
+ backend = usable_backend()
272
+ log = CorrectionLog.open()
273
+ for sig, candidate in corrections:
274
+ await extract_correction(
275
+ log, activity, candidate.ref, source="captain-hook", feedback=sig.text, repo=repo, backend=backend
276
+ )
277
+
278
+
246
279
  async def ingest(
247
280
  store: ReviewStore, parsed: ParsedTranscript, *, settings: ReviewSettings, repo_key: RepoKey | None
248
281
  ) -> ScanReport:
@@ -275,6 +308,7 @@ async def ingest(
275
308
  await store.record_observation(
276
309
  candidate_id, dedup_key=candidate.dedup_key, session_id=sig.session_id, occurred_at=sig.occurred_at
277
310
  )
311
+ await record_corrections(parsed.events, kept, repo=transcript_cwd(parsed.events))
278
312
  return ScanReport(scanned=1, inserted=inserted)
279
313
 
280
314
 
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
28
28
  from pathlib import Path
29
29
  from typing import Any
30
30
 
31
+ from cc_transcript.corrections import Correction
31
32
  from cc_transcript.ids import SessionId
32
33
  from cc_transcript.mining.candidates import DedupKey
33
34
  from cc_transcript.mining.confidence import Confidence
@@ -105,6 +106,14 @@ WHERE o.candidate_id = ? AND l.accepted = 1 AND l.confidence >= ?
105
106
 
106
107
  OPEN_PRS_QUERY = "SELECT COUNT(*) AS n FROM candidates WHERE repo_key = ? AND status = ? AND pr_opened_at > ?"
107
108
 
109
+ OBSERVATION_ANCHORS_QUERY = """
110
+ SELECT DISTINCT e.session_id, e.event_uuid
111
+ FROM candidate_observations o
112
+ JOIN feedback_events e ON e.dedup_key = o.dedup_key
113
+ WHERE o.candidate_id = ? AND e.session_id IS NOT NULL AND e.event_uuid IS NOT NULL
114
+ ORDER BY o.id
115
+ """
116
+
108
117
  CANDIDATES_QUERY = """
109
118
  SELECT c.*,
110
119
  (SELECT e.text FROM candidate_observations o JOIN feedback_events e ON e.dedup_key = o.dedup_key
@@ -502,6 +511,26 @@ class ReviewStore(VerdictStoreMixin, FeedbackStore):
502
511
  )
503
512
  return str(rows[0]["summary"]) if (rows := [dict(row) async for row in cur]) else None
504
513
 
514
+ async def correction_evidence(self, candidate_id: int) -> tuple[Correction, ...]:
515
+ """Returns the shared-ledger code corrections grounding a candidate's observations.
516
+
517
+ Joins each observation back to its feedback anchor ``(session_id,
518
+ event_uuid)`` and pulls the corrections the family ledger holds for that
519
+ anchor — the offending before/after edit the PR-drafting brain needs. The
520
+ reviewer's own per-session pass writes these rows, so a candidate that
521
+ crossed its thresholds carries its faulted edits.
522
+ """
523
+ from cc_transcript.corrections import CorrectionLog
524
+ from cc_transcript.ids import EventUuid, SessionId
525
+
526
+ cur = await self.store.conn.execute(OBSERVATION_ANCHORS_QUERY, (candidate_id,))
527
+ log = CorrectionLog.open()
528
+ return tuple(
529
+ correction
530
+ for row in [dict(row) async for row in cur]
531
+ for correction in log.for_anchor(SessionId(str(row["session_id"])), EventUuid(str(row["event_uuid"])))
532
+ )
533
+
505
534
  async def candidate_view(
506
535
  self, row: dict[str, object], *, settings: ReviewSettings, prompt_version: int
507
536
  ) -> CandidateView:
@@ -9,7 +9,7 @@ allowed-tools: Read, Grep, Glob, Write, Edit, Bash(uvx capt-hook:*, capt-hook:*,
9
9
 
10
10
  capt-hook is a declarative hook framework for Claude Code. Hooks are Python files in
11
11
  `.claude/hooks/`, dispatched by `uvx capt-hook run <Event>` entries in
12
- `.claude/settings.local.json`. Each hook carries inline tests —
12
+ `.claude/settings.json`. Each hook carries inline tests —
13
13
  `tests={Input(...): Block() | Warn() | Allow()}` — run with `uvx capt-hook test`. This
14
14
  skill turns **one durable correction** (the user's verbatim feedback plus the context it
15
15
  fired in) into **one new hook file** `.claude/hooks/<slug>.py`. Full API:
@@ -111,7 +111,7 @@ hook.
111
111
  ### 5. Wire settings
112
112
 
113
113
  Only after Step 4 is green, and only when the hook targets an event no existing
114
- `.claude/settings.local.json` entry dispatches:
114
+ `.claude/settings.json` entry dispatches:
115
115
 
116
116
  ```bash
117
117
  uvx capt-hook register-hooks
@@ -159,7 +159,7 @@ Glob caveat: patterns match the full relative path. `**/*.py` matches `src/main.
159
159
  |---|---|
160
160
  | `uvx capt-hook init` | Scaffold `.claude/hooks/example.py` + merge settings entries |
161
161
  | `uvx capt-hook test [--json]` | Run all inline tests; exit 1 on failure; `--json` = one record per test |
162
- | `uvx capt-hook register-hooks [--hooks-dir D] [--dry-run] [--from SRC]` | Merge captain-hook's hooks into `.claude/settings.local.json` and write it (`--dry-run` prints without writing) |
162
+ | `uvx capt-hook register-hooks [--hooks-dir D] [--dry-run] [--from SRC]` | Merge captain-hook's hooks into `.claude/settings.json` and write it (`--dry-run` prints without writing) |
163
163
  | `uvx capt-hook run <Event> [--async]` | Dispatch one event (Claude Code calls this, not you) |
164
164
  | `uvx capt-hook logs [--session S] [--tail N]` | View a recent capt-hook session log |
165
165
 
@@ -44,7 +44,7 @@ broken hook.
44
44
 
45
45
  Wire only commands proven to run: execute the exact settings command by hand first, and
46
46
  prefer `uvx capt-hook register-hooks` (which writes known-good entries) over editing
47
- `.claude/settings.local.json` manually.
47
+ `.claude/settings.json` manually.
48
48
 
49
49
  ## 4. `uvx capt-hook test` green BEFORE wiring — always
50
50
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bootstrapping-hooks
3
- description: Surveys a repository and sets up captain-hook (capt-hook) guardrails for Claude Code — blocking gates, advisory nudges, command blocks, and test-integrity checks mined from the repo's own docs, CI workflows, lint configs, and git history. Scaffolds the framework and enables the session reviewer up front (Step 1), then proposes categorized candidates for user approval before writing anything, then writes .claude/hooks/*.py with inline tests, verifies with capt-hook test, and wires .claude/settings.local.json. Use when the user asks to "set up captain hook", "set up capt-hook", "set up hooks", "bootstrap capt-hook", "add guardrails", "enforce our conventions with hooks", "protect this repo", or "make Claude follow CONTRIBUTING.md".
3
+ description: Surveys a repository and sets up captain-hook (capt-hook) guardrails for Claude Code — blocking gates, advisory nudges, command blocks, and test-integrity checks mined from the repo's own docs, CI workflows, lint configs, and git history. Scaffolds the framework and enables the session reviewer up front (Step 1), then proposes categorized candidates for user approval before writing anything, then writes .claude/hooks/*.py with inline tests, verifies with capt-hook test, and wires .claude/settings.json. Use when the user asks to "set up captain hook", "set up capt-hook", "set up hooks", "bootstrap capt-hook", "add guardrails", "enforce our conventions with hooks", "protect this repo", or "make Claude follow CONTRIBUTING.md".
4
4
  argument-hint: "[repo path] (defaults to current project)"
5
5
  allowed-tools: Read, Grep, Glob, AskUserQuestion, Write, Edit, Bash(uvx capt-hook:*, capt-hook:*, git log:*, git diff:*, ls:*, find:*)
6
6
  ---
@@ -9,7 +9,7 @@ allowed-tools: Read, Grep, Glob, AskUserQuestion, Write, Edit, Bash(uvx capt-hoo
9
9
 
10
10
  capt-hook is a declarative hook framework for Claude Code. Hooks are Python files in
11
11
  `.claude/hooks/`, dispatched by `uvx capt-hook run <Event>` entries in
12
- `.claude/settings.local.json`. Each hook carries inline tests —
12
+ `.claude/settings.json`. Each hook carries inline tests —
13
13
  `tests={Input(...): Block() | Warn() | Allow()}` — run with `uvx capt-hook test`. Hooks are
14
14
  always Python regardless of the target repo's language: conditions like `Command` and
15
15
  `FilePath` are language-agnostic; only AST `lint` rules are Python-specific. The full
@@ -51,11 +51,11 @@ grep -lq 'capt-hook' .claude/settings.json 2>/dev/null && echo COMMITTED || echo
51
51
 
52
52
  Then scaffold up front, so the framework and the session reviewer are live before you propose
53
53
  anything. Run `uvx capt-hook init` in every repo. It scaffolds `.claude/hooks/`,
54
- wires `.claude/settings.local.json`, installs the skills, and **enables the session reviewer**
54
+ wires `.claude/settings.json`, installs the skills, and **enables the session reviewer**
55
55
  (watching this repo; it mines ended sessions and opens hook PRs — `uvx capt-hook review disable`
56
- to stop). In a **COMMITTED** repo (a checked-in `.claude/settings.json` already runs
57
- `uvx capt-hook run …`), `init` defers those events to the committed file instead of re-wiring
58
- them locally. It prints `deferred to settings.json: …` and never double-fires.
56
+ to stop). When `.claude/settings.local.json` already runs `uvx capt-hook run …` for some events
57
+ (a per-machine setup), `init` defers those events to the local file instead of duplicating them
58
+ into the committed settings. It prints `deferred to settings.local.json: …` and never double-fires.
59
59
 
60
60
  Read `.claude/settings.local.json` and `.claude/settings.json`. If capt-hook hooks already exist,
61
61
  switch to **additive mode**: never overwrite existing hook files; new categories go in new files,
@@ -154,7 +154,7 @@ after scaffolding). Run:
154
154
  uvx capt-hook register-hooks
155
155
  ```
156
156
 
157
- `register-hooks` writes `.claude/settings.local.json` directly, merging non-destructively: it
157
+ `register-hooks` writes `.claude/settings.json` directly, merging non-destructively: it
158
158
  preserves every non-captain-hook entry, refreshes captain-hook's own, and drops entries for
159
159
  events you no longer subscribe to. Add `--dry-run` to print the merged JSON without writing.
160
160
 
@@ -25,7 +25,7 @@ defaults to the process cwd. This is what spawned you; do not recurse into it.
25
25
  ### `review enable` / `review disable`
26
26
 
27
27
  `enable` marks the current repo watched and wires the SessionEnd hook into
28
- `.claude/settings.local.json` (idempotent). `disable` stops watching; candidates stay
28
+ `.claude/settings.json` (idempotent). `disable` stops watching; candidates stay
29
29
  recorded but never become eligible.
30
30
 
31
31
  ### `review scan [--transcript <file>]... [--dir <dir>]...`
@@ -166,7 +166,7 @@ and every `Input` runs through the *whole* styleguide. A failing test usually me
166
166
  input trips a sibling rule — shrink it to a single construct that trips exactly one rule.
167
167
 
168
168
  If `style_llm.py` added hooks on new events (e.g. a `Stop`-targeted `llm_nudge`), run
169
- `uvx capt-hook register-hooks` (it merges non-destructively into `.claude/settings.local.json`
169
+ `uvx capt-hook register-hooks` (it merges non-destructively into `.claude/settings.json`
170
170
  and writes it).
171
171
 
172
172
  ### 8. Enforcement report
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "capt-hook"
3
- version = "3.6.0"
3
+ version = "3.8.0"
4
4
  description = "Declarative hook framework for Claude Code"
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
@@ -20,7 +20,7 @@ classifiers = [
20
20
  ]
21
21
  requires-python = ">=3.13"
22
22
  dependencies = [
23
- "cc-transcript>=3.2,<4",
23
+ "cc-transcript>=4,<5",
24
24
  "pydantic>=2.0",
25
25
  "pydantic-settings>=2.0",
26
26
  "tree-sitter>=0.24",
@@ -34,7 +34,7 @@ dependencies = [
34
34
  "lazy-object-proxy>=1.12.0",
35
35
  "filelock>=3",
36
36
  "loguru>=0.7.3",
37
- "spawnllm>=0.1.3",
37
+ "spawnllm>=0.2.0",
38
38
  ]
39
39
 
40
40
  [project.optional-dependencies]
File without changes
File without changes
File without changes
File without changes