claude-code-capabilities 0.1.2__py3-none-any.whl

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 (47) hide show
  1. capdisc/__init__.py +69 -0
  2. capdisc/base.py +56 -0
  3. capdisc/catalog/__init__.py +71 -0
  4. capdisc/catalog/entries.py +104 -0
  5. capdisc/catalog/types.py +227 -0
  6. capdisc/discovery.py +278 -0
  7. capdisc/frontmatter.py +49 -0
  8. capdisc/hooks/__init__.py +61 -0
  9. capdisc/hooks/schema.py +216 -0
  10. capdisc/hooks/types.py +156 -0
  11. capdisc/html.py +75 -0
  12. capdisc/mcp_catalog.py +87 -0
  13. capdisc/mcp_harvest/__init__.py +17 -0
  14. capdisc/mcp_harvest/auth.py +120 -0
  15. capdisc/mcp_harvest/cache.py +144 -0
  16. capdisc/mcp_harvest/config.py +325 -0
  17. capdisc/mcp_harvest/connect.py +187 -0
  18. capdisc/mcp_harvest/types.py +29 -0
  19. capdisc/plugin_catalog.py +308 -0
  20. capdisc/py.typed +0 -0
  21. capdisc/report/__init__.py +36 -0
  22. capdisc/report/__main__.py +6 -0
  23. capdisc/report/assets.py +138 -0
  24. capdisc/report/cards.py +109 -0
  25. capdisc/report/cli.py +35 -0
  26. capdisc/report/components.py +144 -0
  27. capdisc/report/harvest.py +150 -0
  28. capdisc/report/html.py +79 -0
  29. capdisc/report/models.py +82 -0
  30. capdisc/report/page.py +129 -0
  31. capdisc/report/sections.py +154 -0
  32. capdisc/report/types.py +43 -0
  33. capdisc/scope/__init__.py +83 -0
  34. capdisc/scope/inventory/__init__.py +20 -0
  35. capdisc/scope/inventory/assets.py +54 -0
  36. capdisc/scope/inventory/capture.py +219 -0
  37. capdisc/scope/inventory/render.py +149 -0
  38. capdisc/scope/inventory/roots.py +175 -0
  39. capdisc/scope/locations.py +330 -0
  40. capdisc/scope/types.py +154 -0
  41. capdisc/settings.py +230 -0
  42. capdisc/tokens.py +48 -0
  43. claude_code_capabilities-0.1.2.dist-info/METADATA +136 -0
  44. claude_code_capabilities-0.1.2.dist-info/RECORD +47 -0
  45. claude_code_capabilities-0.1.2.dist-info/WHEEL +4 -0
  46. claude_code_capabilities-0.1.2.dist-info/entry_points.txt +2 -0
  47. claude_code_capabilities-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,154 @@
1
+ """Section bodies: each tab's HTML, assembled from cards over one slice of the report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..catalog import CatalogMcpServer, CatalogTool
8
+ from ..html import e, pill, preview, redact_home
9
+ from ..scope import ArtifactKind, CapturedArtifact, ScanRoot, ScopeInventory
10
+ from .cards import entry_card, plugin_card
11
+ from .components import plugin_components
12
+ from .html import (
13
+ card_block,
14
+ empty_block,
15
+ group_label,
16
+ id_span,
17
+ kind_class,
18
+ kv_line,
19
+ path_block,
20
+ ref_span,
21
+ row_block,
22
+ search_key,
23
+ stat_block,
24
+ )
25
+ from .models import EnvironmentReport, IndexedPlugin, IndexedSkill
26
+
27
+ _PREVIEW_LEN = 600
28
+
29
+
30
+ def render_roots_section(scan_roots: list[ScanRoot], plugin_dirs: list[Path]) -> str:
31
+ cards: list[str] = []
32
+ for root in scan_roots:
33
+ exists = root.base.exists()
34
+ head = (
35
+ pill(root.scope.value)
36
+ + f'<span class="ref mono" style="font-size:12.5px">{e(redact_home(root.base))}</span>'
37
+ + (pill("exists", "eff-b") if exists else pill("missing"))
38
+ )
39
+ if root.kinds is not None:
40
+ head += id_span("kinds: " + ", ".join(sorted(k.value for k in root.kinds)))
41
+ cards.append(
42
+ card_block(
43
+ search_key(root.scope.value, redact_home(root.base)),
44
+ row_block(head),
45
+ "eff" if exists else "shadow",
46
+ )
47
+ )
48
+ if plugin_dirs:
49
+ cards.append(group_label(f"plugin install dirs ({len(plugin_dirs)})"))
50
+ cards.extend(f'<div class="card">{path_block(p)}</div>' for p in plugin_dirs)
51
+ return "".join(cards)
52
+
53
+
54
+ def _capture_card(capture: CapturedArtifact, is_effective: bool) -> str:
55
+ head = (
56
+ pill(capture.kind.value, kind_class(capture.kind))
57
+ + ref_span(capture.name)
58
+ + pill(f"{capture.scope.value}/{capture.shareable.value}")
59
+ + (
60
+ pill("effective", "eff-b")
61
+ if is_effective
62
+ else id_span(f"rank {capture.precedence} (shadowed)")
63
+ )
64
+ + pill(capture.resolution.value)
65
+ )
66
+ inner = row_block(head) + path_block(capture.path) + preview(capture.contents[:_PREVIEW_LEN])
67
+ search = search_key(
68
+ capture.name, capture.kind.value, capture.scope.value, redact_home(capture.path)
69
+ )
70
+ return card_block(search, inner, "eff" if is_effective else "shadow")
71
+
72
+
73
+ def render_inventory_section(inventory: ScopeInventory) -> str:
74
+ effective = set(inventory.effective)
75
+ captures = sorted(inventory.artifacts, key=lambda c: (c.kind.value, c.name, c.precedence))
76
+ by_kind: dict[ArtifactKind, list[str]] = {}
77
+ for capture in captures:
78
+ by_kind.setdefault(capture.kind, []).append(_capture_card(capture, capture in effective))
79
+ out: list[str] = []
80
+ for kind in ArtifactKind:
81
+ cards = by_kind.get(kind, [])
82
+ if cards:
83
+ out.append(group_label(f"{kind.value} ({len(cards)})"))
84
+ out.extend(cards)
85
+ return "".join(out) or empty_block("nothing captured on disk")
86
+
87
+
88
+ def render_skills_section(skills: list[IndexedSkill]) -> str:
89
+ cards = [entry_card(s.card, s.path) for s in sorted(skills, key=lambda s: s.card.ref)]
90
+ return "".join(cards) or empty_block("no skills found")
91
+
92
+
93
+ def render_tools_section(tools: list[CatalogTool]) -> str:
94
+ return "".join(entry_card(tool, None) for tool in tools) or empty_block("no builtin tools")
95
+
96
+
97
+ def render_plugins_section(
98
+ plugins: list[IndexedPlugin],
99
+ inventory: ScopeInventory,
100
+ mcp_servers: list[CatalogMcpServer],
101
+ ) -> str:
102
+ cards = [
103
+ plugin_card(p.card, plugin_components(p, inventory, mcp_servers))
104
+ for p in sorted(plugins, key=lambda p: p.card.ref)
105
+ ]
106
+ return "".join(cards) or empty_block("no plugins found")
107
+
108
+
109
+ def render_mcp_section(servers: list[CatalogMcpServer]) -> str:
110
+ cards = [entry_card(s, None) for s in sorted(servers, key=lambda s: s.ref)]
111
+ return "".join(cards) or empty_block("no MCP servers reachable")
112
+
113
+
114
+ def render_overview_section(report: EnvironmentReport) -> str:
115
+ top = (
116
+ '<div class="bigstat">'
117
+ + stat_block(report.skill_count, "skills")
118
+ + stat_block(report.tool_count, "builtin tools")
119
+ + stat_block(report.plugin_count, "plugins")
120
+ + stat_block(report.mcp_server_count, "mcp servers")
121
+ + stat_block(report.capture_count, "captures on disk")
122
+ + stat_block(report.hook_config_count, "hook configs")
123
+ + stat_block(report.scan_root_count, "scan roots")
124
+ + "</div>"
125
+ )
126
+ env_block = (
127
+ group_label("environment")
128
+ + '<div class="card">'
129
+ + kv_line("cwd", report.cwd)
130
+ + kv_line("home", report.home)
131
+ + kv_line("plugins root", report.plugins_root)
132
+ + kv_line("managed dir", report.managed_dir or "(none on this OS)")
133
+ + kv_line("mcp source", report.mcp_source)
134
+ + "</div>"
135
+ )
136
+ by_kind: dict[ArtifactKind, int] = {}
137
+ for capture in report.inventory.artifacts:
138
+ by_kind[capture.kind] = by_kind.get(capture.kind, 0) + 1
139
+ kinds = (
140
+ group_label("captures by kind")
141
+ + '<div class="bigstat">'
142
+ + "".join(stat_block(n, kind.value) for kind, n in by_kind.items())
143
+ + "</div>"
144
+ )
145
+ events = sorted({ev.value for cfg in report.inventory.hook_configs for ev in cfg.root})
146
+ hooks = ""
147
+ if events:
148
+ hooks = (
149
+ group_label("hook events in effect")
150
+ + '<div class="eventbar">'
151
+ + "".join(f'<span class="event">{e(ev)}</span>' for ev in events)
152
+ + "</div>"
153
+ )
154
+ return top + env_block + kinds + hooks
@@ -0,0 +1,43 @@
1
+ """Domain-primitive aliases for the report package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from pydantic import Field
8
+
9
+ ComponentCount = Annotated[
10
+ int,
11
+ Field(
12
+ ge=0,
13
+ title="Component count",
14
+ description="Number of components (skills, agents, hooks, or MCP servers) in a group.",
15
+ ),
16
+ ]
17
+ SectionId = Annotated[
18
+ str,
19
+ Field(
20
+ pattern=r"^[a-z]+$",
21
+ title="Section id",
22
+ description="Nav/anchor id of one report tab.",
23
+ examples=["overview", "skills"],
24
+ ),
25
+ ]
26
+ SectionLabel = Annotated[
27
+ str,
28
+ Field(
29
+ min_length=1,
30
+ max_length=40,
31
+ title="Section label",
32
+ description="Human-readable nav label of one report tab.",
33
+ examples=["Scan roots"],
34
+ ),
35
+ ]
36
+ SectionCount = Annotated[
37
+ int,
38
+ Field(
39
+ ge=0,
40
+ title="Section count",
41
+ description="Item count shown next to a report tab's nav label.",
42
+ ),
43
+ ]
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from .inventory import (
4
+ CapturedArtifact,
5
+ ScanRoot,
6
+ ScopeInventory,
7
+ ScopeRoots,
8
+ artifact_kinds,
9
+ default_managed_dir,
10
+ parse_frontmatter_hooks,
11
+ parse_hooks,
12
+ render_inventory,
13
+ render_inventory_html,
14
+ )
15
+ from .locations import (
16
+ Artifact,
17
+ CaptureHit,
18
+ Command,
19
+ Hook,
20
+ HostFrontmatter,
21
+ InMemoryFlag,
22
+ Location,
23
+ SettingsEntry,
24
+ Skill,
25
+ StandaloneFile,
26
+ Storage,
27
+ Subagent,
28
+ )
29
+ from .types import (
30
+ ArtifactContents,
31
+ ArtifactKind,
32
+ ArtifactName,
33
+ ArtifactPath,
34
+ ArtifactSubdir,
35
+ CliFlag,
36
+ GlobPattern,
37
+ JsonKey,
38
+ ResolutionMode,
39
+ ResolutionRank,
40
+ ScopeKind,
41
+ ScopeRoot,
42
+ SettingsFile,
43
+ Shareability,
44
+ )
45
+
46
+ __all__ = [
47
+ "Artifact",
48
+ "ArtifactContents",
49
+ "ArtifactKind",
50
+ "ArtifactName",
51
+ "ArtifactPath",
52
+ "ArtifactSubdir",
53
+ "CaptureHit",
54
+ "CapturedArtifact",
55
+ "CliFlag",
56
+ "Command",
57
+ "GlobPattern",
58
+ "Hook",
59
+ "HostFrontmatter",
60
+ "InMemoryFlag",
61
+ "JsonKey",
62
+ "Location",
63
+ "ResolutionMode",
64
+ "ResolutionRank",
65
+ "ScanRoot",
66
+ "ScopeInventory",
67
+ "ScopeKind",
68
+ "ScopeRoot",
69
+ "ScopeRoots",
70
+ "SettingsEntry",
71
+ "SettingsFile",
72
+ "Shareability",
73
+ "Skill",
74
+ "StandaloneFile",
75
+ "Storage",
76
+ "Subagent",
77
+ "artifact_kinds",
78
+ "default_managed_dir",
79
+ "parse_frontmatter_hooks",
80
+ "parse_hooks",
81
+ "render_inventory",
82
+ "render_inventory_html",
83
+ ]
@@ -0,0 +1,20 @@
1
+ """Scan every artifact location under a set of scope roots into a `ScopeInventory` snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .capture import CapturedArtifact, ScopeInventory, parse_frontmatter_hooks, parse_hooks
6
+ from .render import render_inventory, render_inventory_html
7
+ from .roots import ScanRoot, ScopeRoots, artifact_kinds, default_managed_dir
8
+
9
+ __all__ = [
10
+ "CapturedArtifact",
11
+ "ScanRoot",
12
+ "ScopeInventory",
13
+ "ScopeRoots",
14
+ "artifact_kinds",
15
+ "default_managed_dir",
16
+ "parse_frontmatter_hooks",
17
+ "parse_hooks",
18
+ "render_inventory",
19
+ "render_inventory_html",
20
+ ]
@@ -0,0 +1,54 @@
1
+ """Inline CSS and JS for the inventory's self-contained HTML render — no external resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # How many characters of a capture's contents the HTML preview shows.
6
+ PREVIEW_CHARS = 600
7
+
8
+ INVENTORY_STYLE = """
9
+ :root{--bg:#0d1117;--panel:#161b22;--panel2:#1c2430;--bd:#2d3440;--fg:#e6edf3;--mut:#8b949e;
10
+ --ac:#58a6ff;--gn:#3fb950;--yl:#d29922;--pu:#bc8cff;--cy:#39c5cf;}
11
+ *{box-sizing:border-box}
12
+ body{margin:0;background:var(--bg);color:var(--fg);
13
+ font:14px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,sans-serif}
14
+ header{padding:20px 24px;border-bottom:1px solid var(--bd);background:var(--panel)}
15
+ h1{margin:0 0 4px;font-size:20px}
16
+ .meta{color:var(--mut);font-size:12px}.meta b{color:var(--fg)}
17
+ main{padding:20px 24px}
18
+ .search{width:100%;max-width:480px;padding:9px 12px;background:var(--panel2);
19
+ border:1px solid var(--bd);border-radius:8px;color:var(--fg);font-size:13px;margin-bottom:16px}
20
+ .search:focus{outline:none;border-color:var(--ac)}
21
+ .card{background:var(--panel);border:1px solid var(--bd);border-radius:10px;
22
+ padding:14px 16px;margin-bottom:10px}
23
+ .card.eff{border-left:3px solid var(--gn)}.card.shadow{opacity:.62}
24
+ .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
25
+ .ref{font-weight:600;font-size:14px}.id{color:var(--mut);font-size:11px}
26
+ .path{color:var(--mut);font-size:11.5px;margin-top:6px;word-break:break-all}
27
+ .pill{font-size:10.5px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;
28
+ padding:2px 8px;border-radius:5px}
29
+ .k-skill{background:#1f2d3d;color:var(--ac)}.k-agent{background:#2d1f3d;color:var(--pu)}
30
+ .k-command{background:#1f3d2a;color:var(--gn)}.k-hook{background:#3d2f1f;color:var(--yl)}
31
+ .sc{background:var(--panel2);border:1px solid var(--bd);color:var(--mut)}
32
+ .eff-b{background:#16341f;color:var(--gn)}
33
+ .preview{margin-top:8px;background:var(--bg);border:1px solid var(--bd);border-radius:6px;
34
+ padding:8px 10px;color:var(--mut);font-size:11.5px;white-space:pre-wrap;
35
+ max-height:160px;overflow:auto;display:none}
36
+ .card.open .preview{display:block}
37
+ .toggle{cursor:pointer;color:var(--ac);font-size:11px;background:none;border:none;padding:0;margin-top:6px}
38
+ .grp{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin:18px 0 8px}
39
+ .empty{color:var(--mut);padding:24px;text-align:center}
40
+ .eventbar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
41
+ .event{background:#3d2f1f;color:var(--yl);border-radius:5px;padding:2px 9px;font-size:11.5px}
42
+ """
43
+
44
+ INVENTORY_SCRIPT = """
45
+ const search=document.querySelector('.search');
46
+ if(search){
47
+ search.addEventListener('input',()=>{
48
+ const q=search.value.toLowerCase().trim();
49
+ document.querySelectorAll('.card[data-s]').forEach(c=>{
50
+ c.style.display=(!q||c.dataset.s.includes(q))?'':'none';
51
+ });
52
+ });
53
+ }
54
+ """
@@ -0,0 +1,219 @@
1
+ """The capture engine: scans artifact locations into `CapturedArtifact` records, parses each
2
+ capture's hook config, and resolves the effective (collision-winning) set."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pydantic import JsonValue, ValidationError
7
+
8
+ from ...base import FrozenModel, InputModel
9
+ from ...frontmatter import read_frontmatter
10
+ from ...hooks import HookConfig
11
+ from ..locations import Artifact, Location
12
+ from ..types import (
13
+ ArtifactContents,
14
+ ArtifactKind,
15
+ ArtifactName,
16
+ ArtifactPath,
17
+ ResolutionMode,
18
+ ResolutionRank,
19
+ ScopeKind,
20
+ Shareability,
21
+ )
22
+ from .roots import ScanRoot, ScopeRoots, artifact_kinds
23
+
24
+
25
+ class CapturedArtifact(FrozenModel):
26
+ """One definition found on disk: its placement, exact path, contents, and resolution rank."""
27
+
28
+ kind: ArtifactKind
29
+ scope: ScopeKind
30
+ shareable: Shareability
31
+ resolution: ResolutionMode
32
+ name: ArtifactName
33
+ path: ArtifactPath
34
+ contents: ArtifactContents
35
+ precedence: ResolutionRank
36
+
37
+
38
+ class _WrappedHooks(InputModel):
39
+ """A plugin hooks.json body: the event map under a `hooks` key (description etc. ignored)."""
40
+
41
+ hooks: JsonValue = None
42
+
43
+
44
+ def parse_hooks(captured: CapturedArtifact) -> HookConfig | None:
45
+ """Parse a captured hook artifact into a typed `HookConfig`.
46
+
47
+ Ingest is fail-soft: a config using an event, handler type, or structure newer than we model
48
+ skips rather than crashing the scan — unknown *fields* are already tolerated by
49
+ `FrozenWireModel`, so this only trips on shape we cannot represent at all.
50
+
51
+ Args:
52
+ captured: A captured hook artifact; its contents are a settings.json `hooks` slice
53
+ (the bare event map) or a plugin hooks.json (that same map under a `hooks` key).
54
+
55
+ Returns:
56
+ The typed config, or None if it cannot be parsed.
57
+ """
58
+ try:
59
+ return HookConfig.model_validate_json(captured.contents)
60
+ except ValidationError:
61
+ pass
62
+ try:
63
+ wrapped = _WrappedHooks.model_validate_json(captured.contents).hooks
64
+ return None if wrapped is None else HookConfig.model_validate(wrapped)
65
+ except ValidationError:
66
+ return None
67
+
68
+
69
+ def _frontmatter_hooks_value(text: str) -> JsonValue | None:
70
+ """The `hooks` value from a markdown file's frontmatter, or None if absent or not a mapping."""
71
+ data = read_frontmatter(text)
72
+ return None if data is None else data.get("hooks")
73
+
74
+
75
+ def parse_frontmatter_hooks(captured: CapturedArtifact) -> HookConfig | None:
76
+ """Parse the hooks a host agent or skill declares in its frontmatter.
77
+
78
+ These are the component-scope hooks, active while their host is. Fail-soft like `parse_hooks`.
79
+
80
+ Args:
81
+ captured: A captured agent or skill artifact whose frontmatter may declare `hooks`.
82
+
83
+ Returns:
84
+ The typed config, or None if it declares none or they cannot be parsed.
85
+ """
86
+ hooks = _frontmatter_hooks_value(captured.contents)
87
+ if hooks is None:
88
+ return None
89
+ try:
90
+ return HookConfig.model_validate(hooks)
91
+ except ValidationError:
92
+ return None
93
+
94
+
95
+ def _ranked_roots(artifact: Artifact, roots: list[ScanRoot]) -> list[ScanRoot]:
96
+ """The scan roots an artifact loads from, ordered by its own precedence.
97
+
98
+ Roots are keyed by the artifact's scope order, then by scan position so a nearer project dir
99
+ outranks a farther one. Position in the result is the collision rank (0 wins).
100
+
101
+ Args:
102
+ artifact: The artifact descriptor whose scope order and kind-filter apply.
103
+ roots: All scan roots to filter and rank.
104
+
105
+ Returns:
106
+ The applicable roots, highest precedence first; roots whose scope the artifact has no
107
+ location for (e.g. a managed dir for a command), or whose `kinds` exclude it, are dropped.
108
+ """
109
+ order = {location.scope: index for index, location in enumerate(artifact.locations)}
110
+ ranked = [
111
+ (position, root)
112
+ for position, root in enumerate(roots)
113
+ if root.scope in order and (root.kinds is None or artifact.kind in root.kinds)
114
+ ]
115
+ ranked.sort(key=lambda pair: (order[pair[1].scope], pair[0]))
116
+ return [root for _, root in ranked]
117
+
118
+
119
+ class ScopeInventory(FrozenModel):
120
+ """A single snapshot of every definition across every scanned location — the concrete
121
+ grounding of the abstract location taxonomy. One instance holds the lot."""
122
+
123
+ artifacts: list[CapturedArtifact] = []
124
+
125
+ @staticmethod
126
+ def _scan_artifact(artifact: Artifact, roots: ScopeRoots) -> list[CapturedArtifact]:
127
+ """Capture every on-disk definition for one artifact descriptor.
128
+
129
+ A scope can hold more than one location (a hook lives in both `settings.json` and
130
+ `settings.local.json`), so every location for the matched scope is scanned, not just one.
131
+
132
+ Args:
133
+ artifact: The artifact descriptor to scan for.
134
+ roots: The scan roots to look under.
135
+
136
+ Returns:
137
+ Every capture found, each carrying its location's scope/shareability and the
138
+ artifact's collision rank.
139
+ """
140
+ by_scope: dict[ScopeKind, list[Location]] = {}
141
+ for location in artifact.locations:
142
+ by_scope.setdefault(location.scope, []).append(location)
143
+
144
+ captured: list[CapturedArtifact] = []
145
+ for precedence, root in enumerate(_ranked_roots(artifact, roots.roots)):
146
+ for location in by_scope[root.scope]:
147
+ captured.extend(
148
+ CapturedArtifact(
149
+ kind=artifact.kind,
150
+ scope=location.scope,
151
+ shareable=location.shareable,
152
+ resolution=artifact.resolution,
153
+ name=hit.name,
154
+ path=hit.path,
155
+ contents=hit.contents,
156
+ precedence=precedence,
157
+ )
158
+ for hit in location.storage.discover(root.base)
159
+ )
160
+
161
+ return captured
162
+
163
+ @property
164
+ def hook_configs(self) -> list[HookConfig]:
165
+ """Every hook config in effect across the snapshot: settings/plugin `hooks` entries plus
166
+ those declared in an agent's or skill's frontmatter. Unparseable captures are skipped.
167
+
168
+ Reads from `effective`, not `artifacts` — an agent or skill shadowed by a
169
+ higher-precedence same-named capture never loads, so its frontmatter hooks never run."""
170
+ configs: list[HookConfig] = []
171
+ for captured in self.effective:
172
+ if captured.kind is ArtifactKind.hook:
173
+ config = parse_hooks(captured)
174
+ elif captured.kind in (ArtifactKind.agent, ArtifactKind.skill):
175
+ config = parse_frontmatter_hooks(captured)
176
+ else:
177
+ config = None
178
+ if config is not None:
179
+ configs.append(config)
180
+ return configs
181
+
182
+ @property
183
+ def effective(self) -> list[CapturedArtifact]:
184
+ """What takes effect: for each name-collision (kind, name) the lowest-rank capture, plus
185
+ every additive capture (hooks) unchanged. A plain property — derived from the precedence
186
+ rank on each capture, so it is order-independent rather than relying on scan order."""
187
+ best: dict[tuple[ArtifactKind, ArtifactName], CapturedArtifact] = {}
188
+ additive: list[CapturedArtifact] = []
189
+ for captured in self.artifacts:
190
+ if captured.resolution is ResolutionMode.additive:
191
+ additive.append(captured)
192
+ continue
193
+ identity = (captured.kind, captured.name)
194
+ current = best.get(identity)
195
+ if current is None or captured.precedence < current.precedence:
196
+ best[identity] = captured
197
+ return list(best.values()) + additive
198
+
199
+ @classmethod
200
+ def scan(cls, roots: ScopeRoots) -> ScopeInventory:
201
+ """Scan every artifact's locations under `roots` into one snapshot.
202
+
203
+ Walks each artifact's locations under every applicable root, dispatching to each storage
204
+ shape's `discover`. Each capture's rank is its position in that artifact's own precedence
205
+ order, so the right scope wins per kind (skills rank user over project, subagents the
206
+ reverse) and nearer project dirs win on collision.
207
+
208
+ Args:
209
+ roots: The scan roots, as resolved by `ScopeRoots.discover`.
210
+
211
+ Returns:
212
+ The inventory snapshot of every captured artifact.
213
+ """
214
+ captured = [
215
+ capture
216
+ for artifact in artifact_kinds()
217
+ for capture in cls._scan_artifact(artifact, roots)
218
+ ]
219
+ return cls(artifacts=captured)