capt-hook 8.2.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 (114) hide show
  1. {capt_hook-8.2.0 → capt_hook-8.3.0}/PKG-INFO +1 -1
  2. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/app.py +4 -0
  3. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/cli.py +78 -14
  4. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/manager.py +108 -5
  5. {capt_hook-8.2.0 → capt_hook-8.3.0}/pyproject.toml +1 -1
  6. {capt_hook-8.2.0 → capt_hook-8.3.0}/LICENSE +0 -0
  7. {capt_hook-8.2.0 → capt_hook-8.3.0}/README.md +0 -0
  8. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  9. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/__init__.py +0 -0
  10. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/__main__.py +0 -0
  11. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/ast_grep.py +0 -0
  12. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/classifiers/__init__.py +0 -0
  13. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/classifiers/conductor.py +0 -0
  14. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/classifiers/droid.py +0 -0
  15. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/classifiers/native.py +0 -0
  16. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/conditions.py +0 -0
  17. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/context.py +0 -0
  18. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/contexts.py +0 -0
  19. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/decisions.py +0 -0
  20. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/dispatch.py +0 -0
  21. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/durable.py +0 -0
  22. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/events.py +0 -0
  23. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/fields.py +0 -0
  24. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/file.py +0 -0
  25. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/loader.py +0 -0
  26. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/log.py +0 -0
  27. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/__init__.py +0 -0
  28. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/fixes/__init__.py +0 -0
  29. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/fixes/capt-hook.toml +0 -0
  30. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/fixes/teammate_permissions.py +0 -0
  31. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/__init__.py +0 -0
  32. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/_lib.py +0 -0
  33. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  34. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/commands.py +0 -0
  35. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/comments.py +0 -0
  36. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/detours.py +0 -0
  37. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/docs.py +0 -0
  38. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/models.py +0 -0
  39. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/plans.py +0 -0
  40. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/prompts.py +0 -0
  41. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/review.py +0 -0
  42. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/tasks.py +0 -0
  43. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/general/tombstones.py +0 -0
  44. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/go/__init__.py +0 -0
  45. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  46. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/go/testing.py +0 -0
  47. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/go/toolchain.py +0 -0
  48. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/python/__init__.py +0 -0
  49. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  50. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/python/style.py +0 -0
  51. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/python/testing.py +0 -0
  52. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/python/toolchain.py +0 -0
  53. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/steering/__init__.py +0 -0
  54. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  55. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/packs/steering/steering.py +0 -0
  56. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/primitives/__init__.py +0 -0
  57. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/primitives/commands.py +0 -0
  58. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/primitives/lint.py +0 -0
  59. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/primitives/llm.py +0 -0
  60. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/primitives/nudge.py +0 -0
  61. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/primitives/permissions.py +0 -0
  62. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/primitives/rewrite.py +0 -0
  63. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/primitives/workflow.py +0 -0
  64. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/prompt.py +0 -0
  65. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/py.typed +0 -0
  66. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/__init__.py +0 -0
  67. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/cli.py +0 -0
  68. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/dashboard.py +0 -0
  69. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/fix.py +0 -0
  70. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/formats.py +0 -0
  71. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/judge.py +0 -0
  72. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/pipeline.py +0 -0
  73. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/repo.py +0 -0
  74. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/scan.py +0 -0
  75. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/settings.py +0 -0
  76. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/store.py +0 -0
  77. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/review/sync.py +0 -0
  78. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/session.py +0 -0
  79. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/settings.py +0 -0
  80. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/signals/__init__.py +0 -0
  81. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/signals/nlp.py +0 -0
  82. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  83. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  84. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  85. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  86. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  87. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  88. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  89. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  90. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  91. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  92. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  93. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  94. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  95. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/state.py +0 -0
  96. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/style/__init__.py +0 -0
  97. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/style/ast_grep.py +0 -0
  98. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/style/matchers.py +0 -0
  99. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/style/scope.py +0 -0
  100. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/style/types.py +0 -0
  101. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/tasks.py +0 -0
  102. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  103. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/testing/__init__.py +0 -0
  104. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/testing/helpers.py +0 -0
  105. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/testing/session_cache.py +0 -0
  106. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/testing/types.py +0 -0
  107. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/types.py +0 -0
  108. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/util/__init__.py +0 -0
  109. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/util/automode.py +0 -0
  110. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/util/fs.py +0 -0
  111. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/util/http.py +0 -0
  112. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/util/model_cache.py +0 -0
  113. {capt_hook-8.2.0 → capt_hook-8.3.0}/captain_hook/util/proc.py +0 -0
  114. {capt_hook-8.2.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.2.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:
@@ -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.2.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"
File without changes
File without changes
File without changes