capt-hook 8.2.0__tar.gz → 8.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {capt_hook-8.2.0 → capt_hook-8.4.0}/PKG-INFO +1 -1
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/app.py +4 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/cli.py +78 -14
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/models.py +3 -3
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/manager.py +108 -5
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/steering/capt-hook.toml +1 -1
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/steering/steering.py +334 -16
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/judge.py +23 -1
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/scan.py +4 -0
- capt_hook-8.4.0/captain_hook/signals/__init__.py +108 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/types.py +8 -1
- {capt_hook-8.2.0 → capt_hook-8.4.0}/pyproject.toml +1 -1
- capt_hook-8.2.0/captain_hook/signals/__init__.py +0 -81
- {capt_hook-8.2.0 → capt_hook-8.4.0}/LICENSE +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/README.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/__main__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/conditions.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/context.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/contexts.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/decisions.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/durable.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/events.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/fields.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/file.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/loader.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/log.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/fixes/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/fixes/capt-hook.toml +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/fixes/teammate_permissions.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/_lib.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/comments.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/detours.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/general/tombstones.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/primitives/permissions.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/prompt.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/py.typed +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/store.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/session.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/settings.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/state.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/style/types.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/tasks.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/util/automode.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/util/http.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/util/proc.py +0 -0
- {capt_hook-8.2.0 → capt_hook-8.4.0}/captain_hook/util/text.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
@@ -770,9 +770,9 @@ An unpinned security audit inherits fable; security review/audit is the codex-wr
|
|
|
770
770
|
script="agent(`Synthesize the confirmed review findings and decide which to fix`)",
|
|
771
771
|
llm={"fire": False},
|
|
772
772
|
): Allow(),
|
|
773
|
-
Input(
|
|
774
|
-
|
|
775
|
-
)
|
|
773
|
+
Input(script="agent(`Audit the login flow for auth bypass and injection; return findings as JSON`)"): Warn(
|
|
774
|
+
pattern="gpt-5.5"
|
|
775
|
+
),
|
|
776
776
|
Input(script="agent('Verify the CLI renders the last page correctly')"): Allow(),
|
|
777
777
|
},
|
|
778
778
|
)
|
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
196
|
-
|
|
197
|
-
|
|
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,5 +1,5 @@
|
|
|
1
1
|
name = "steering"
|
|
2
|
-
version = "0.
|
|
2
|
+
version = "0.5.0"
|
|
3
3
|
description = "Steer the agent's judgment on what's worth fixing: prefer fundamental, first-principles fixes over band-aids, don't dismiss a real pre-existing issue, don't chase trivial type noise, and don't defer the requested fix by swapping in docs or a follow-up nobody asked for."
|
|
4
4
|
hooks = "."
|
|
5
5
|
nlp = true
|