capt-hook 8.1.0__tar.gz → 8.3.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 (115) hide show
  1. {capt_hook-8.1.0 → capt_hook-8.3.0}/PKG-INFO +1 -1
  2. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/app.py +4 -0
  3. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/cli.py +78 -14
  4. capt_hook-8.3.0/captain_hook/packs/general/capt-hook.toml +5 -0
  5. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/models.py +62 -18
  6. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/manager.py +108 -5
  7. {capt_hook-8.1.0 → capt_hook-8.3.0}/pyproject.toml +1 -1
  8. capt_hook-8.1.0/captain_hook/packs/general/capt-hook.toml +0 -5
  9. {capt_hook-8.1.0 → capt_hook-8.3.0}/LICENSE +0 -0
  10. {capt_hook-8.1.0 → capt_hook-8.3.0}/README.md +0 -0
  11. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  12. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/__init__.py +0 -0
  13. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/__main__.py +0 -0
  14. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/ast_grep.py +0 -0
  15. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/classifiers/__init__.py +0 -0
  16. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/classifiers/conductor.py +0 -0
  17. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/classifiers/droid.py +0 -0
  18. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/classifiers/native.py +0 -0
  19. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/conditions.py +0 -0
  20. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/context.py +0 -0
  21. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/contexts.py +0 -0
  22. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/decisions.py +0 -0
  23. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/dispatch.py +0 -0
  24. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/durable.py +0 -0
  25. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/events.py +0 -0
  26. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/fields.py +0 -0
  27. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/file.py +0 -0
  28. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/loader.py +0 -0
  29. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/log.py +0 -0
  30. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/__init__.py +0 -0
  31. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/fixes/__init__.py +0 -0
  32. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/fixes/capt-hook.toml +0 -0
  33. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/fixes/teammate_permissions.py +0 -0
  34. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/__init__.py +0 -0
  35. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/_lib.py +0 -0
  36. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/commands.py +0 -0
  37. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/comments.py +0 -0
  38. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/detours.py +0 -0
  39. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/docs.py +0 -0
  40. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/plans.py +0 -0
  41. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/prompts.py +0 -0
  42. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/review.py +0 -0
  43. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/tasks.py +0 -0
  44. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/general/tombstones.py +0 -0
  45. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/go/__init__.py +0 -0
  46. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  47. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/go/testing.py +0 -0
  48. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/go/toolchain.py +0 -0
  49. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/python/__init__.py +0 -0
  50. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  51. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/python/style.py +0 -0
  52. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/python/testing.py +0 -0
  53. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/python/toolchain.py +0 -0
  54. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/steering/__init__.py +0 -0
  55. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  56. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/packs/steering/steering.py +0 -0
  57. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/primitives/__init__.py +0 -0
  58. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/primitives/commands.py +0 -0
  59. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/primitives/lint.py +0 -0
  60. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/primitives/llm.py +0 -0
  61. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/primitives/nudge.py +0 -0
  62. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/primitives/permissions.py +0 -0
  63. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/primitives/rewrite.py +0 -0
  64. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/primitives/workflow.py +0 -0
  65. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/prompt.py +0 -0
  66. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/py.typed +0 -0
  67. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/__init__.py +0 -0
  68. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/cli.py +0 -0
  69. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/dashboard.py +0 -0
  70. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/fix.py +0 -0
  71. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/formats.py +0 -0
  72. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/judge.py +0 -0
  73. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/pipeline.py +0 -0
  74. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/repo.py +0 -0
  75. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/scan.py +0 -0
  76. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/settings.py +0 -0
  77. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/store.py +0 -0
  78. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/review/sync.py +0 -0
  79. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/session.py +0 -0
  80. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/settings.py +0 -0
  81. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/signals/__init__.py +0 -0
  82. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/signals/nlp.py +0 -0
  83. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  84. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  85. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  86. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  87. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  88. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  89. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  90. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  91. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  92. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  93. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  94. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  95. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  96. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/state.py +0 -0
  97. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/style/__init__.py +0 -0
  98. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/style/ast_grep.py +0 -0
  99. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/style/matchers.py +0 -0
  100. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/style/scope.py +0 -0
  101. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/style/types.py +0 -0
  102. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/tasks.py +0 -0
  103. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  104. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/testing/__init__.py +0 -0
  105. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/testing/helpers.py +0 -0
  106. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/testing/session_cache.py +0 -0
  107. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/testing/types.py +0 -0
  108. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/types.py +0 -0
  109. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/util/__init__.py +0 -0
  110. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/util/automode.py +0 -0
  111. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/util/fs.py +0 -0
  112. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/util/http.py +0 -0
  113. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/util/model_cache.py +0 -0
  114. {capt_hook-8.1.0 → capt_hook-8.3.0}/captain_hook/util/proc.py +0 -0
  115. {capt_hook-8.1.0 → capt_hook-8.3.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: 8.1.0
3
+ Version: 8.3.0
4
4
  Summary: Stop repeating yourself to Claude.
5
5
  Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
6
  Author: Yasyf Mohamedali
@@ -106,6 +106,9 @@ class State:
106
106
  settings: HooksSettings | None = None
107
107
  classifier: UserClassifier | None = None
108
108
  load_errors: list[tuple[str, BaseException]] = field(default_factory=list)
109
+ # Events subscribed only by session-attached packs — excluded from settings-drift
110
+ # warnings, since a plugin wires them in its own hooks.json, not the project's settings.
111
+ attach_only_events: set[str] = field(default_factory=set)
109
112
  counter: int = field(default=0, repr=False)
110
113
 
111
114
 
@@ -116,6 +119,7 @@ def reset() -> None:
116
119
  _state.hooks.clear()
117
120
  _state.gitignore_patterns.clear()
118
121
  _state.load_errors.clear()
122
+ _state.attach_only_events.clear()
119
123
  _state.counter = 0
120
124
  _state.settings = None
121
125
  _state.classifier = None
@@ -15,6 +15,7 @@ from typing import Any
15
15
 
16
16
  import click
17
17
  from cc_transcript.ids import SessionId
18
+ from loguru import logger
18
19
 
19
20
  from captain_hook.app import _state, load_gitignore, reset
20
21
  from captain_hook.context import HookContext, load_transcript
@@ -36,22 +37,62 @@ class CliState:
36
37
  root: Path
37
38
  hooks: str
38
39
 
39
- def discover(self) -> list[manager.ResolvedPack]:
40
+ def discover(self, session_dir: Path | None = None) -> list[manager.ResolvedPack]:
40
41
  reset()
41
42
  load_gitignore(self.root)
42
43
  discover_hooks(self.hooks)
43
44
  resolved, missing = manager.resolve_enabled_packs(self.root)
44
45
  for pack_ in resolved:
45
46
  discover_pack(pack_.entry.name, pack_.path)
46
- if any(pack_.manifest.nlp for pack_ in resolved):
47
+ # NLP provisioning is one shared SessionStart hook. Register it before the attached
48
+ # span when a project (packs.toml) pack needs it, so it counts as project-owned; only
49
+ # when solely an attached pack needs it does it register inside the span, so an
50
+ # attach-only NLP hook is attributed to the attach and never flagged as settings drift.
51
+ project_nlp = any(pack_.manifest.nlp for pack_ in resolved)
52
+ if project_nlp:
47
53
  register_nlp_provisioning()
54
+ attached = self.attached_packs(resolved, session_dir)
55
+ attached_start = len(_state.hooks)
56
+ for pack_ in attached:
57
+ discover_pack(pack_.entry.name, pack_.path)
58
+ if not project_nlp and any(pack_.manifest.nlp for pack_ in attached):
59
+ register_nlp_provisioning()
60
+ attached_hooks = _state.hooks[attached_start:]
61
+ all_resolved = [*resolved, *attached]
62
+ attached_ids = {id(h) for h in attached_hooks}
63
+ _state.attach_only_events = {n for h in attached_hooks for n in event_names(h.spec.events)} - {
64
+ n for h in _state.hooks if id(h) not in attached_ids for n in event_names(h.spec.events)
65
+ }
48
66
  if missing:
49
67
  print(
50
68
  f"capt-hook: packs unavailable (offline and not cached): {', '.join(missing)} "
51
69
  "— run `capt-hook pack update` when online",
52
70
  file=sys.stderr,
53
71
  )
54
- return resolved
72
+ return all_resolved
73
+
74
+ def attached_packs(
75
+ self, resolved: Sequence[manager.ResolvedPack], session_dir: Path | None
76
+ ) -> list[manager.ResolvedPack]:
77
+ """Resolve session-attached packs a builtin or packs.toml pack doesn't already own.
78
+
79
+ A packs.toml entry (builtin, source, or ``disabled = true``) of the same name wins
80
+ over an ambient plugin attach; the shadowed attach is dropped with a debug log.
81
+ """
82
+ if session_dir is None:
83
+ return []
84
+ owned = {pack_.entry.name for pack_ in resolved} | {
85
+ e.name
86
+ for e in manager.read_entries(manager.packs_toml_path(self.root))
87
+ if isinstance(e, manager.DisabledPack)
88
+ }
89
+ kept: list[manager.ResolvedPack] = []
90
+ for pack_ in manager.resolve_attached(session_dir):
91
+ if pack_.entry.name in owned:
92
+ logger.bind(pack=pack_.entry.name).debug("attached pack shadowed by a packs.toml or disabled entry")
93
+ continue
94
+ kept.append(pack_)
95
+ return kept
55
96
 
56
97
 
57
98
  def example_hook_source() -> str:
@@ -253,7 +294,10 @@ def regenerate_settings(state: CliState) -> None:
253
294
 
254
295
  def settings_drift(root: Path) -> set[str]:
255
296
  paths = [p for name in ("settings.json", "settings.local.json") if (p := root / ".claude" / name).exists()]
256
- return subscribed_events() - {event for p in paths for event in capt_hook_events(p)} if paths else set()
297
+ if not paths:
298
+ return set()
299
+ wired = {event for p in paths for event in capt_hook_events(p)}
300
+ return subscribed_events() - _state.attach_only_events - wired
257
301
 
258
302
 
259
303
  def warn_settings_drift(
@@ -285,12 +329,7 @@ def warn_settings_drift(
285
329
  return base
286
330
 
287
331
 
288
- def run_event(
289
- event_name: str,
290
- *,
291
- async_: bool = False,
292
- root: Path | None = None,
293
- ) -> None:
332
+ def run_event(state: CliState, event_name: str, *, async_: bool = False) -> None:
294
333
  try:
295
334
  event = Event[event_name]
296
335
  except KeyError:
@@ -315,19 +354,22 @@ def run_event(
315
354
  setup_logging(session_id)
316
355
  resolved_path = raw.get("agent_transcript_path") or raw.get("transcript_path")
317
356
 
357
+ # stdin is parsed first so the session dir is known before discovery: CliState.discover
358
+ # loads this session's attached packs (see attached_packs) on top of packs.toml.
318
359
  session_dir = ensure_session(SessionId(session_id)) if session_id else None
360
+ state.discover(session_dir=session_dir)
319
361
  ctx = HookContext(
320
362
  session=SessionStore(session_dir),
321
363
  transcript=load_transcript(resolved_path),
322
364
  settings=_state.settings,
323
- project_root=root,
365
+ project_root=state.root,
324
366
  )
325
367
  evt = event.event_class(_raw=raw, ctx=ctx)
326
368
 
327
369
  if output := warn_settings_drift(
328
370
  dispatch(event, evt, session_dir=session_dir, async_=async_),
329
371
  event,
330
- root,
372
+ state.root,
331
373
  session_dir,
332
374
  async_=async_,
333
375
  ):
@@ -520,8 +562,7 @@ def cli(ctx: click.Context, hooks: str | None, root_path: str | None) -> None:
520
562
  @click.option("--async", "async_", is_flag=True, default=False, help="Run async hooks only")
521
563
  @click.pass_obj
522
564
  def run(state: CliState, event: str, async_: bool) -> None:
523
- state.discover()
524
- run_event(event, async_=async_, root=state.root)
565
+ run_event(state, event, async_=async_)
525
566
 
526
567
 
527
568
  @cli.command(name="register-hooks")
@@ -622,6 +663,29 @@ def pack_add(state: CliState, target: str) -> None:
622
663
  regenerate_settings(state)
623
664
 
624
665
 
666
+ @pack.command(name="attach")
667
+ @click.argument("directory")
668
+ def pack_attach(directory: str) -> None:
669
+ """Register a plugin's pack for the current session (reads the SessionStart JSON on stdin).
670
+
671
+ A Claude plugin wires this to a SessionStart hook so its pack loads under the byte-identical
672
+ canonical ``uvx capt-hook run <Event>`` commands, letting Claude Code's exact-command dedup
673
+ collapse plugin and project wiring into one process per event. Writes nothing to stdout on
674
+ success (SessionStart stdout is injected into the model context); a missing or invalid
675
+ manifest exits 1 with a stderr message.
676
+ """
677
+ raw = json.loads(sys.stdin.read())
678
+ session_dir = ensure_session(SessionId(raw["session_id"]))
679
+ root = Path(directory).resolve()
680
+ try:
681
+ manifest = manager.PackManifest.load(manager.manifest_in(root))
682
+ except manager.PackError as e:
683
+ raise click.ClickException(str(e)) from e
684
+ manager.upsert_attached(
685
+ session_dir, manager.AttachedPack(name=manifest.name, dir=str(root), version=manifest.version)
686
+ )
687
+
688
+
625
689
  @pack.command(name="list")
626
690
  @click.pass_obj
627
691
  def pack_list(state: CliState) -> None:
@@ -0,0 +1,5 @@
1
+ name = "general"
2
+ version = "0.13.0"
3
+ description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, model-routing nudges (review, security audit, and diagnosis route to gpt-5.5), code-review + docs-freshness gates, a detour check-in nudge, a verbose-comment nudge, and delegated-prose nudges that catch doc writing handed to a subagent or workflow without the writing-docs skill."
4
+ hooks = "."
5
+ nlp = true
@@ -321,7 +321,8 @@ The Models rubric: implementation delegates to opus-4.8 at xhigh — opus is ~2x
321
321
  than fable and nearly as capable. Fable's lanes are orchestration, design/architecture
322
322
  review, hard planning, all prose/writing, and implementation that is very sensitive or
323
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.
324
+ Code/diff review, security review/audit, and bug diagnosis have their own gpt-5.5
325
+ lanes with separate nudges.
325
326
 
326
327
  Set fire=true only when the prompt is clearly routine implementation — building, fixing,
327
328
  wiring, or refactoring code — with no fable-lane signal. A prompt that reviews, plans,
@@ -476,20 +477,25 @@ Auth plus concurrency is a sensitive surface — fable's inline lane.
476
477
  )
477
478
 
478
479
  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.
480
+ """Decide whether this delegated subagent runs code review, a security review/audit
481
+ or verification of security-sensitive code, or bug diagnosis that should route to
482
+ gpt-5.5 instead of fable.
481
483
 
482
484
  <delegated_spawn> holds the pending Agent/Task call: its model pin (or that it inherits
483
485
  the session model, fable), agent type, and prompt.
484
486
 
485
487
  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,
488
+ correctness, or cleanups; finder and refuter passes over findings — security
489
+ review/audit and verification of security-sensitive code (auth, input validation,
490
+ crypto, secrets), and bug diagnosis route to gpt-5.5 via the codex skill; fable is
491
+ the escalation target when gpt-5.5's output misses. Fable keeps design/architecture
492
+ review, "is this the right approach" judgment, prose review, the synthesis/
493
+ accept-reject pass over review findings and security-sensitive implementation,
494
+ which is not review.
495
+
496
+ Set fire=true only when the prompt clearly reviews code or diffs for defects, audits
497
+ or verifies security-sensitive code, or diagnoses a bug, and the spawn would run on
498
+ fable. Design review, approach judgment,
493
499
  synthesis over findings, and prose review are fable's lanes: fire=false. When
494
500
  uncertain, fire=false — the agent may have chosen fable deliberately, and a false
495
501
  alarm teaches it to ignore this nudge. Keep reasoning under 40 words.
@@ -519,10 +525,23 @@ The accept-reject pass over findings stays on fable.
519
525
  Review the README draft for factual errors.
520
526
  Prose review stays on fable.
521
527
  </example>
528
+ <example fire="true">
529
+ Audit the session-token handling in auth/middleware.py for vulnerabilities.
530
+ Security review/audit of code — gpt-5.5's lane via codex.
531
+ </example>
532
+ <example fire="true">
533
+ Verify the new input-validation layer rejects path traversal and injection payloads.
534
+ Verification of security-sensitive code routes to gpt-5.5.
535
+ </example>
536
+ <example fire="false">
537
+ Implement mitigations for the security-audit findings in auth.py.
538
+ Security-sensitive implementation, not review — the implementation lanes apply.
539
+ </example>
522
540
  </examples>""",
523
541
  message=lambda r: (
524
542
  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 "
543
+ "Code/diff review, security review/audit and verification of security-sensitive code, "
544
+ "and bug diagnosis route to gpt-5.5: run the codex skill (from a "
526
545
  "workflow or subagent, spawn a model='sonnet', effort='low' wrapper that writes a "
527
546
  "self-contained codex prompt), and escalate to fable only when gpt-5.5's output misses. "
528
547
  "Design review and findings synthesis stay on fable. "
@@ -532,7 +551,11 @@ Prose review stays on fable.
532
551
  events=Event.PreToolUse,
533
552
  only_if=[
534
553
  Tool("Agent|Task"),
535
- ToolInput("prompt", r"(?i)\b(review|refut|adversari|audit|correctness|diagnos|root.?caus)"),
554
+ ToolInput(
555
+ "prompt",
556
+ r"(?i)(\b(review|refut|adversari|audit|correctness|diagnos|root.?caus|secur|vuln|pentest)"
557
+ r"|\bverif\w*[\s\S]{0,160}?\b(auth|crypt|secret|sanitiz|inject|input.?valid|token|session))",
558
+ ),
536
559
  ],
537
560
  skip_if=[
538
561
  ToolInput("model", r"(?i)\b(opus|sonnet|haiku)\b"),
@@ -552,6 +575,13 @@ Prose review stays on fable.
552
575
  prompt="Synthesize the confirmed review findings and decide which to fix",
553
576
  llm={"fire": False},
554
577
  ): Allow(),
578
+ Input(prompt="Audit auth/session.py for security vulnerabilities"): Warn(pattern="gpt-5.5"),
579
+ Input(prompt="Verify the input-validation change blocks path traversal"): Warn(pattern="codex"),
580
+ Input(prompt="Verify the pagination change renders the last page correctly"): Allow(),
581
+ Input(
582
+ prompt="Implement mitigations for the security audit findings in auth.py",
583
+ llm={"fire": False},
584
+ ): Allow(),
555
585
  },
556
586
  )
557
587
 
@@ -666,10 +696,13 @@ line that pins a model — a stage not quoted there carries no pin and inherits
666
696
  session model, fable.
667
697
 
668
698
  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.
699
+ adversarial refuters over findings — security review/audit stages and verification
700
+ of security-sensitive code (auth, input validation, crypto, secrets), and bug
701
+ diagnosis route to gpt-5.5 via the codex skill. A stage does that correctly when it
702
+ pins model 'sonnet' at low effort and its prompt writes a self-contained codex prompt
703
+ and runs the codex skill. Fable keeps the synthesis/accept-reject stage over findings
704
+ and design/architecture judgment — and security-sensitive implementation, which is
705
+ not review.
673
706
 
674
707
  Set fire=true only when at least one review or diagnosis stage would run on fable —
675
708
  unpinned, or pinned 'fable'. Stages already wrapped for codex, synthesis stages, and
@@ -695,10 +728,14 @@ Already the codex wrapper — correctly routed.
695
728
  agent(`Synthesize the confirmed findings and decide which to fix`)
696
729
  Synthesis/accept-reject stays on fable.
697
730
  </example>
731
+ <example fire="true">
732
+ agent(`Audit the auth flow for injection and session-fixation issues; return findings as JSON`)
733
+ An unpinned security audit inherits fable; security review/audit is the codex-wrapper lane.
734
+ </example>
698
735
  </examples>""",
699
736
  message=lambda r: (
700
737
  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', "
738
+ "Route finder, refuter, security-audit, and diagnosis stages to gpt-5.5: make each a model: 'sonnet', "
702
739
  "effort: 'low' stage that writes a self-contained codex prompt and runs the codex skill; "
703
740
  "keep the synthesis/accept-reject stage on fable (inherit the session model). "
704
741
  "See CLAUDE.md § Plan Execution & Orchestration (Models)."
@@ -707,7 +744,10 @@ Synthesis/accept-reject stays on fable.
707
744
  events=Event.PreToolUse,
708
745
  only_if=[
709
746
  Tool("Workflow"),
710
- WorkflowScript(pattern=r"(?i)\b(review|refut|adversari|audit|correctness|diagnos)"),
747
+ WorkflowScript(
748
+ pattern=r"(?i)(\b(review|refut|adversari|audit|correctness|diagnos|root.?caus|secur|vuln|pentest)"
749
+ r"|\bverif\w*[\s\S]{0,160}?\b(auth|crypt|secret|sanitiz|inject|input.?valid|token|session))",
750
+ ),
711
751
  ],
712
752
  max_fires=2,
713
753
  max_context=16_000,
@@ -730,6 +770,10 @@ Synthesis/accept-reject stays on fable.
730
770
  script="agent(`Synthesize the confirmed review findings and decide which to fix`)",
731
771
  llm={"fire": False},
732
772
  ): Allow(),
773
+ Input(
774
+ script="agent(`Audit the login flow for auth bypass and injection; return findings as JSON`)"
775
+ ): Warn(pattern="gpt-5.5"),
776
+ Input(script="agent('Verify the CLI renders the last page correctly')"): Allow(),
733
777
  },
734
778
  )
735
779
 
@@ -22,6 +22,7 @@ import os
22
22
  import re
23
23
  import shutil
24
24
  import tarfile
25
+ import tempfile
25
26
  import time
26
27
  import tomllib
27
28
  from collections.abc import Iterator, Sequence
@@ -31,11 +32,13 @@ from pathlib import Path
31
32
  from typing import Any
32
33
 
33
34
  from filelock import FileLock
35
+ from loguru import logger
34
36
 
35
37
  from captain_hook.settings import resolve_cache_dir
36
38
  from captain_hook.util import http
37
39
 
38
40
  PACK_MANIFEST = "capt-hook.toml"
41
+ ATTACHED_FILE = "attached_packs.json"
39
42
  SHA_MARKER = ".sha"
40
43
  LATEST_REF = "latest"
41
44
  # Moving refs (@latest / a branch / a bare default-branch source) re-resolve to a fresh
@@ -108,12 +111,39 @@ class ExternalPack:
108
111
  commit: str | None = None
109
112
 
110
113
 
111
- type PackEntry = BuiltinPack | ExternalPack
114
+ @dataclass(frozen=True, slots=True)
115
+ class DisabledPack:
116
+ """A packs.toml entry that declines a pack by name.
117
+
118
+ ``[packs.<name>] disabled = true`` suppresses a pack the repo would otherwise
119
+ inherit — a builtin, a packs.toml source, or a plugin-attached pack — regardless of
120
+ any other keys on the entry. Disabling always wins.
121
+ """
122
+
123
+ name: str
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class AttachedPack:
128
+ """A pack a Claude plugin registered for the current session via ``pack attach``.
129
+
130
+ Stored per session in ``attached_packs.json`` keyed by ``name``; ``dir`` is the
131
+ absolute pack root (holding the ``capt-hook.toml`` manifest) and ``version`` is the
132
+ manifest version recorded at attach time.
133
+ """
134
+
135
+ name: str
136
+ dir: str
137
+ version: str
138
+
139
+
140
+ type PackEntry = BuiltinPack | ExternalPack | DisabledPack
141
+ type ResolvedEntry = BuiltinPack | ExternalPack | AttachedPack
112
142
 
113
143
 
114
144
  @dataclass(frozen=True, slots=True)
115
145
  class ResolvedPack:
116
- entry: PackEntry
146
+ entry: ResolvedEntry
117
147
  path: Path
118
148
  manifest: PackManifest
119
149
 
@@ -160,6 +190,8 @@ def manifest_in(root: Path) -> Path:
160
190
 
161
191
  def parse_entry(name: str, table: dict[str, Any]) -> PackEntry:
162
192
  match table:
193
+ case {"disabled": True}:
194
+ return DisabledPack(name=name)
163
195
  case {"source": source, "commit": commit}:
164
196
  return ExternalPack(name=name, source=PackSource.parse(source), commit=commit)
165
197
  case {"source": source}:
@@ -180,6 +212,8 @@ def render_entry(entry: PackEntry) -> str:
180
212
  match entry:
181
213
  case BuiltinPack(name=name):
182
214
  return f"[packs.{name}]\n\n"
215
+ case DisabledPack(name=name):
216
+ return f"[packs.{name}]\ndisabled = true\n\n"
183
217
  case ExternalPack(name=name, source=source, commit=None):
184
218
  return f'[packs.{name}]\nsource = "{source}"\n\n'
185
219
  case ExternalPack(name=name, source=source, commit=commit):
@@ -191,10 +225,20 @@ def render_packs_toml(entries: Sequence[PackEntry]) -> str:
191
225
 
192
226
 
193
227
  def atomic_write(path: Path, text: str) -> None:
228
+ """Write ``text`` to ``path`` atomically via a unique temp file in the same directory.
229
+
230
+ The temp name is per-call (``mkstemp``), so concurrent writers to the same path never
231
+ consume each other's temp file — one writer's ``os.replace`` can't yank another's out.
232
+ """
194
233
  path.parent.mkdir(parents=True, exist_ok=True)
195
- tmp = path.with_suffix(f"{path.suffix}.tmp")
196
- tmp.write_text(text)
197
- os.replace(tmp, path)
234
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=f"{path.suffix}.tmp")
235
+ try:
236
+ with os.fdopen(fd, "w") as f:
237
+ f.write(text)
238
+ os.replace(tmp, path)
239
+ except BaseException:
240
+ Path(tmp).unlink(missing_ok=True)
241
+ raise
198
242
 
199
243
 
200
244
  def upsert_entry(path: Path, entry: PackEntry) -> None:
@@ -444,6 +488,8 @@ def resolve_enabled_packs(root: Path) -> tuple[list[ResolvedPack], list[str]]:
444
488
  match entry:
445
489
  case BuiltinPack(name=name):
446
490
  resolved.append(resolve_builtin(name))
491
+ case DisabledPack():
492
+ pass
447
493
  case ExternalPack() as ext if found := (load_cached_fresh(ext, now) if fast else resolve_external(ext)):
448
494
  resolved.append(found)
449
495
  case ExternalPack() as ext:
@@ -451,3 +497,60 @@ def resolve_enabled_packs(root: Path) -> tuple[list[ResolvedPack], list[str]]:
451
497
  if not (fast or missing):
452
498
  atomic_write(fastpath_path(root), toml_hash(root))
453
499
  return resolved, missing
500
+
501
+
502
+ def attached_path(session_dir: Path) -> Path:
503
+ return session_dir / ATTACHED_FILE
504
+
505
+
506
+ def read_attached(session_dir: Path) -> list[AttachedPack]:
507
+ path = attached_path(session_dir)
508
+ if not path.exists():
509
+ return []
510
+ return [AttachedPack(name=e["name"], dir=e["dir"], version=e["version"]) for e in json.loads(path.read_text())]
511
+
512
+
513
+ def upsert_attached(session_dir: Path, pack: AttachedPack) -> None:
514
+ """Record ``pack`` in the session's attach file, replacing any entry of the same name.
515
+
516
+ The read-modify-write runs under a file lock so two ``pack attach`` processes (parallel
517
+ SessionStart hooks) serialize rather than clobber each other's entries.
518
+ """
519
+ path = attached_path(session_dir)
520
+ lock = path.with_name(path.name + ".lock")
521
+ with FileLock(str(lock)):
522
+ existing = read_attached(session_dir)
523
+ if (prior := next((p for p in existing if p.name == pack.name), None)) and prior.dir != pack.dir:
524
+ logger.bind(pack=pack.name, previous=prior.dir, incoming=pack.dir).debug(
525
+ "attached pack name re-bound to a different dir; the newer attach wins"
526
+ )
527
+ entries = [*(p for p in existing if p.name != pack.name), pack]
528
+ atomic_write(
529
+ path,
530
+ json.dumps([{"name": p.name, "dir": p.dir, "version": p.version} for p in entries]),
531
+ )
532
+
533
+
534
+ def resolve_attached(session_dir: Path) -> list[ResolvedPack]:
535
+ """Resolve this session's attached packs, dropping entries that no longer resolve.
536
+
537
+ A plugin update moves or non-atomically rewrites its versioned cache path, so a prior
538
+ session's attach entry can dangle or point at a half-written manifest. SessionStart
539
+ re-attaches every session, so an entry whose dir has vanished or whose manifest is
540
+ missing/malformed is skipped with a debug log rather than killing dispatch for every
541
+ other hook in the event — the same fail-soft shape as ``resolve_enabled_packs``.
542
+ """
543
+ resolved: list[ResolvedPack] = []
544
+ for pack in read_attached(session_dir):
545
+ root = Path(pack.dir)
546
+ if not root.is_dir():
547
+ continue
548
+ try:
549
+ manifest = PackManifest.load(manifest_in(root))
550
+ except (PackError, tomllib.TOMLDecodeError, KeyError, OSError):
551
+ logger.bind(pack=pack.name, dir=pack.dir).opt(exception=True).debug(
552
+ "skipped attached pack with a missing or malformed manifest"
553
+ )
554
+ continue
555
+ resolved.append(ResolvedPack(pack, manifest.hooks_dir(root), manifest))
556
+ return resolved
@@ -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 = "8.1.0"
4
+ version = "8.3.0"
5
5
  description = "Stop repeating yourself to Claude."
6
6
  readme = "README.md"
7
7
  license = "PolyForm-Noncommercial-1.0.0"
@@ -1,5 +0,0 @@
1
- name = "general"
2
- version = "0.12.0"
3
- description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, code-review + docs-freshness gates, a detour check-in nudge, a verbose-comment nudge, and delegated-prose nudges that catch doc writing handed to a subagent or workflow without the writing-docs skill."
4
- hooks = "."
5
- nlp = true
File without changes
File without changes
File without changes