qaas-python 0.1.0__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 (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/scorecard.py ADDED
@@ -0,0 +1,425 @@
1
+ """Scoring a run against the golden ledger.
2
+
3
+ This is the module that keeps the project honest. Everything else in the system
4
+ can look like it works — agents run, envelopes appear, tickets get filed — while
5
+ the findings are noise. The scorecard is what says otherwise, in numbers.
6
+
7
+ Matching is deliberately deterministic. Using a model to judge whether a finding
8
+ matches a seeded defect would make the score depend on the same class of system
9
+ being measured, and a generous judge would flatter the result exactly when the
10
+ result least deserves it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any, Iterable
19
+
20
+ import yaml
21
+
22
+ from qaas.envelope import DefectEnvelope, normalize_path, Severity
23
+
24
+ MATCH_THRESHOLD = 0.5
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class GoldenDefect:
29
+ """One seeded defect, as recorded in target-app/defects.yaml."""
30
+
31
+ id: str
32
+ domain: str
33
+ defect_class: str
34
+ severity: str
35
+ title: str
36
+ detail: str
37
+ endpoint: str | None = None
38
+ ui_route: str | None = None
39
+ paths: tuple[str, ...] = ()
40
+ keywords: tuple[str, ...] = ()
41
+ phase: int = 1
42
+ security_relevant: bool = False
43
+ # True for defects the system found rather than ones placed for it to find.
44
+ # They still count, but they are weaker evidence: the ledger is a floor on
45
+ # what exists in the app, never a complete oracle.
46
+ discovered_not_seeded: bool = False
47
+ #: The ref a fix for this defect landed on, once one has. Retires the entry
48
+ #: from the recall denominator without deleting it -- ARBITER escalated a
49
+ #: correct fix because the schema had no way to say this, and CLAUDE.md
50
+ #: requires the ledger to change in the same commit as the defect. Deleting
51
+ #: the entry instead would lose the severity and domain expectations that
52
+ #: make a past score reproducible.
53
+ fixed_in: str | None = None
54
+
55
+ @property
56
+ def retired(self) -> bool:
57
+ """Fixed, so no longer expected to be present. Not scored for recall."""
58
+ return self.fixed_in is not None
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class PlantedNonDefect:
63
+ """Correct behaviour that looks wrong. Reporting one is a false positive."""
64
+
65
+ id: str
66
+ title: str
67
+ why_correct: str
68
+ endpoint: str | None = None
69
+ ui_route: str | None = None
70
+ paths: tuple[str, ...] = ()
71
+
72
+
73
+ @dataclass
74
+ class GoldenLedger:
75
+ defects: list[GoldenDefect]
76
+ not_defects: list[PlantedNonDefect]
77
+
78
+ @classmethod
79
+ def load(cls, path: Path | str) -> "GoldenLedger":
80
+ raw = yaml.safe_load(Path(path).read_text())
81
+ return cls(
82
+ defects=[_golden(d) for d in raw.get("defects", [])],
83
+ not_defects=[_planted(d) for d in raw.get("not_defects", [])],
84
+ )
85
+
86
+ def for_phase(self, phase: int) -> list[GoldenDefect]:
87
+ return [d for d in self.defects if d.phase <= phase]
88
+
89
+ def by_id(self, defect_id: str) -> GoldenDefect | None:
90
+ return next((d for d in self.defects if d.id == defect_id), None)
91
+
92
+
93
+ def _golden(d: dict[str, Any]) -> GoldenDefect:
94
+ loc = d.get("location", {}) or {}
95
+ return GoldenDefect(
96
+ id=d["id"],
97
+ domain=d["domain"],
98
+ defect_class=d.get("class", "bug"),
99
+ severity=d["severity"],
100
+ title=d["title"],
101
+ detail=d.get("detail", ""),
102
+ endpoint=loc.get("endpoint"),
103
+ ui_route=loc.get("ui_route"),
104
+ paths=tuple(loc.get("paths", ())),
105
+ keywords=tuple(d.get("keywords", ())),
106
+ phase=int(d.get("phase", 1)),
107
+ security_relevant=bool(d.get("security_relevant", False)),
108
+ discovered_not_seeded=bool(d.get("discovered_not_seeded", False)),
109
+ fixed_in=(str(d["fixed_in"]) if d.get("fixed_in") else None),
110
+ )
111
+
112
+
113
+ def _planted(d: dict[str, Any]) -> PlantedNonDefect:
114
+ loc = d.get("location", {}) or {}
115
+ return PlantedNonDefect(
116
+ id=d["id"],
117
+ title=d["title"],
118
+ why_correct=d.get("why_correct", ""),
119
+ endpoint=loc.get("endpoint"),
120
+ ui_route=loc.get("ui_route"),
121
+ paths=tuple(loc.get("paths", ())),
122
+ )
123
+
124
+
125
+ # -- similarity -------------------------------------------------------------
126
+
127
+
128
+ def _norm_endpoint(endpoint: str | None) -> str | None:
129
+ """`GET /v1/orders/{order_id}` and `get /v1/orders/{id}` are the same endpoint."""
130
+ if not endpoint:
131
+ return None
132
+ e = re.sub(r"\{[^}]*\}", "{}", endpoint.strip().lower())
133
+ return re.sub(r"\s+", " ", e)
134
+
135
+
136
+ def _norm_path(path: str) -> str:
137
+ """Compare by tail, so `target-app/api/app/routes/orders.py:88` matches `api/app/routes/orders.py`.
138
+
139
+ Delegates to the envelope's own normalisation rather than restating it. The
140
+ two were separate implementations and drifted: the scorer handled `:104-112`
141
+ line ranges and the repo-root prefix, the fingerprint handled neither, so a
142
+ defect the scorer counted as one thing hashed as three.
143
+ """
144
+ return normalize_path(path)
145
+
146
+
147
+ def _path_overlap(a: Iterable[str], b: Iterable[str]) -> float:
148
+ """Fraction of the golden defect's files the report also names."""
149
+ golden = {_norm_path(p) for p in b}
150
+ if not golden:
151
+ return 0.0
152
+ reported = {_norm_path(p) for p in a}
153
+ hits = sum(
154
+ 1
155
+ for g in golden
156
+ if any(r == g or r.endswith("/" + g) or g.endswith("/" + r) for r in reported)
157
+ )
158
+ return hits / len(golden)
159
+
160
+
161
+ def _keyword_overlap(text: str, keywords: Iterable[str]) -> float:
162
+ terms = list(keywords)
163
+ if not terms:
164
+ return 0.0
165
+ blob = text.lower()
166
+ return sum(1 for t in terms if t.lower() in blob) / len(terms)
167
+
168
+
169
+ # Domains that describe the same surface. An agent choosing either one has
170
+ # classified the defect defensibly, so scoring must accept both.
171
+ #
172
+ # Both entries were learned from real runs, and both times the scorer was wrong
173
+ # rather than the agent: CONDUIT filed a cross-tenant read under `security`, and
174
+ # SURFACE filed a missing label and a contrast failure under `ux`. Marking those
175
+ # as misses would have hidden a perfect discovery run behind a 50% score.
176
+ _EQUIVALENT_DOMAINS: dict[str, set[str]] = {
177
+ "ux": {"frontend"},
178
+ "frontend": {"ux"},
179
+ }
180
+
181
+
182
+ def _domains_compatible(reported: str, golden: GoldenDefect) -> bool:
183
+ """Whether a reported domain is an acceptable classification of this defect.
184
+
185
+ Domain stays a gate — naming the right file under a genuinely wrong domain
186
+ means the defect was misunderstood — but the gate accepts any defensible
187
+ reading, not only the one the ledger happened to write down.
188
+ """
189
+ if reported == golden.domain:
190
+ return True
191
+ if reported == "security" and golden.security_relevant:
192
+ return True
193
+ return golden.domain in _EQUIVALENT_DOMAINS.get(reported, set())
194
+
195
+
196
+ def similarity(env: DefectEnvelope, golden: GoldenDefect) -> float:
197
+ """0..1 confidence that `env` reports `golden`."""
198
+ if not _domains_compatible(env.domain.value, golden):
199
+ return 0.0
200
+
201
+ text = f"{env.title} {env.summary} {env.suggested_fix_area}"
202
+
203
+ endpoint_match = (
204
+ _norm_endpoint(env.location.endpoint) is not None
205
+ and _norm_endpoint(env.location.endpoint) == _norm_endpoint(golden.endpoint)
206
+ )
207
+ route_match = (
208
+ env.location.ui_route is not None
209
+ and golden.ui_route is not None
210
+ and env.location.ui_route.rstrip("/") == golden.ui_route.rstrip("/")
211
+ )
212
+
213
+ # A cross-cutting defect has no single endpoint or route to anchor on, and
214
+ # the files that best demonstrate it are a judgement call — a report of
215
+ # inconsistent error shapes may cite whichever two handlers differ. For
216
+ # those, the prose has to carry the identification.
217
+ anchored = bool(golden.endpoint or golden.ui_route)
218
+ weights = (0.45, 0.30, 0.35) if anchored else (0.0, 0.35, 0.65)
219
+ w_location, w_paths, w_keywords = weights
220
+
221
+ score = w_location if (endpoint_match or route_match) else 0.0
222
+ score += w_paths * _path_overlap(env.location.paths, golden.paths)
223
+ score += w_keywords * _keyword_overlap(text, golden.keywords)
224
+ return min(score, 1.0)
225
+
226
+
227
+ def resembles_planted(env: DefectEnvelope, planted: PlantedNonDefect) -> float:
228
+ """Whether a finding is a report of deliberately-correct behaviour.
229
+
230
+ This needs a real anchor — the same endpoint or the same route. Sharing a
231
+ file is not enough: `orders.py` holds six seeded defects as well as the
232
+ deliberately-correct legacy handler, so a path-only rule blamed agents for
233
+ reporting the legacy endpoint when they had reported something else entirely.
234
+ """
235
+ if planted.endpoint and _norm_endpoint(env.location.endpoint) == _norm_endpoint(planted.endpoint):
236
+ return 1.0
237
+ if planted.ui_route and env.location.ui_route == planted.ui_route:
238
+ return 1.0
239
+ return 0.0
240
+
241
+
242
+ # -- results ----------------------------------------------------------------
243
+
244
+
245
+ @dataclass
246
+ class Match:
247
+ golden_id: str
248
+ envelope_id: str
249
+ score: float
250
+ reported_severity: str
251
+ expected_severity: str
252
+
253
+ @property
254
+ def severity_delta(self) -> int:
255
+ """Ranks apart. 0 is agreement, positive means the report was too calm."""
256
+ return Severity(self.reported_severity).rank - Severity(self.expected_severity).rank
257
+
258
+
259
+ @dataclass
260
+ class Scorecard:
261
+ matches: list[Match] = field(default_factory=list)
262
+ missed: list[str] = field(default_factory=list)
263
+ false_positives: list[str] = field(default_factory=list)
264
+ duplicates: list[str] = field(default_factory=list)
265
+ regressions_on_planted: list[tuple[str, str]] = field(default_factory=list)
266
+ #: Findings that matched a defect already marked `fixed_in`. Neither a find
267
+ #: nor a false positive: the report is correct wherever the fix has not
268
+ #: landed, so scoring it either way would be a lie about the run.
269
+ retired_hits: list[tuple[str, str]] = field(default_factory=list)
270
+ total_golden: int = 0
271
+ total_envelopes: int = 0
272
+ cost_usd: float = 0.0
273
+
274
+ @property
275
+ def recall(self) -> float:
276
+ return len(self.matches) / self.total_golden if self.total_golden else 0.0
277
+
278
+ @property
279
+ def precision(self) -> float:
280
+ judged = len(self.matches) + len(self.false_positives)
281
+ return len(self.matches) / judged if judged else 0.0
282
+
283
+ @property
284
+ def false_positive_rate(self) -> float:
285
+ return len(self.false_positives) / self.total_envelopes if self.total_envelopes else 0.0
286
+
287
+ @property
288
+ def duplicate_rate(self) -> float:
289
+ return len(self.duplicates) / self.total_envelopes if self.total_envelopes else 0.0
290
+
291
+ @property
292
+ def severity_agreement(self) -> float:
293
+ """Share of matches scored within one rank of the expected severity."""
294
+ if not self.matches:
295
+ return 0.0
296
+ return sum(1 for m in self.matches if abs(m.severity_delta) <= 1) / len(self.matches)
297
+
298
+ @property
299
+ def cost_per_accepted(self) -> float | None:
300
+ return self.cost_usd / len(self.matches) if self.matches else None
301
+
302
+ def summary(self) -> dict[str, Any]:
303
+ return {
304
+ "found": len(self.matches),
305
+ "of": self.total_golden,
306
+ "recall": round(self.recall, 3),
307
+ "precision": round(self.precision, 3),
308
+ "false_positives": len(self.false_positives),
309
+ "false_positive_rate": round(self.false_positive_rate, 3),
310
+ "duplicates": len(self.duplicates),
311
+ "duplicate_rate": round(self.duplicate_rate, 3),
312
+ "severity_agreement": round(self.severity_agreement, 3),
313
+ "planted_misreported": len(self.regressions_on_planted),
314
+ "retired_hits": len(self.retired_hits),
315
+ "cost_usd": round(self.cost_usd, 4),
316
+ "cost_per_accepted": (
317
+ round(self.cost_per_accepted, 4) if self.cost_per_accepted is not None else None
318
+ ),
319
+ "missed": sorted(self.missed),
320
+ }
321
+
322
+
323
+ def score(
324
+ envelopes: list[DefectEnvelope],
325
+ ledger: GoldenLedger,
326
+ *,
327
+ phase: int = 1,
328
+ domains: set[str] | None = None,
329
+ cost_usd: float = 0.0,
330
+ threshold: float = MATCH_THRESHOLD,
331
+ ) -> Scorecard:
332
+ """Match findings to seeded defects, best pair first.
333
+
334
+ Assignment is one-to-one and greedy on the strongest pair remaining. A second
335
+ envelope for an already-matched defect is a duplicate, not a second find —
336
+ counting it as a find would reward exactly the ticket-spam this system is
337
+ built to avoid.
338
+ """
339
+ in_scope = [d for d in ledger.for_phase(phase) if domains is None or d.domain in domains]
340
+ # A defect marked `fixed_in` is still matched -- so a report of it is not
341
+ # written off as a false positive -- but it leaves the recall denominator.
342
+ # Counting a repaired defect as a miss on every future run is exactly the
343
+ # silent corruption CLAUDE.md warns about.
344
+ golden = [d for d in in_scope if not d.retired]
345
+ retired = {d.id for d in in_scope if d.retired}
346
+ matchable = in_scope
347
+ card = Scorecard(total_golden=len(golden), total_envelopes=len(envelopes), cost_usd=cost_usd)
348
+
349
+ pairs = sorted(
350
+ (
351
+ (similarity(env, g), env, g)
352
+ for env in envelopes
353
+ for g in matchable
354
+ if similarity(env, g) >= threshold
355
+ ),
356
+ key=lambda t: -t[0],
357
+ )
358
+
359
+ claimed_golden: set[str] = set()
360
+ claimed_env: set[str] = set()
361
+ contested: list[tuple[float, DefectEnvelope, GoldenDefect]] = []
362
+
363
+ # First pass: settle the unambiguous pairs, strongest first.
364
+ for sim, env, g in pairs:
365
+ if g.id in claimed_golden or env.id in claimed_env:
366
+ contested.append((sim, env, g))
367
+ continue
368
+ claimed_golden.add(g.id)
369
+ claimed_env.add(env.id)
370
+ if g.id in retired:
371
+ card.retired_hits.append((env.id, g.id))
372
+ else:
373
+ card.matches.append(
374
+ Match(
375
+ golden_id=g.id,
376
+ envelope_id=env.id,
377
+ score=round(sim, 3),
378
+ reported_severity=env.severity.value,
379
+ expected_severity=g.severity,
380
+ )
381
+ )
382
+
383
+ # Second pass: an envelope whose best match was taken gets its next-best
384
+ # before anything else. Branding it a duplicate here was a real bug — two
385
+ # defects in one file on one route (a missing empty state and an unhandled
386
+ # rejection, both on /orders in OrdersList.tsx) each match the other's
387
+ # golden entry, so whichever lost the first pass was written off entirely.
388
+ # Only an envelope with no unclaimed match left is genuinely a duplicate.
389
+ for sim, env, g in contested:
390
+ if env.id in claimed_env:
391
+ continue
392
+ if g.id not in claimed_golden:
393
+ claimed_golden.add(g.id)
394
+ claimed_env.add(env.id)
395
+ if g.id in retired:
396
+ card.retired_hits.append((env.id, g.id))
397
+ else:
398
+ card.matches.append(
399
+ Match(
400
+ golden_id=g.id,
401
+ envelope_id=env.id,
402
+ score=round(sim, 3),
403
+ reported_severity=env.severity.value,
404
+ expected_severity=g.severity,
405
+ )
406
+ )
407
+
408
+ for sim, env, g in contested:
409
+ if env.id not in claimed_env:
410
+ card.duplicates.append(env.id)
411
+ claimed_env.add(env.id)
412
+
413
+ card.missed = [g.id for g in golden if g.id not in claimed_golden]
414
+
415
+ for env in envelopes:
416
+ if env.id in claimed_env:
417
+ continue
418
+ card.false_positives.append(env.id)
419
+ for planted in ledger.not_defects:
420
+ if resembles_planted(env, planted) >= MATCH_THRESHOLD:
421
+ card.regressions_on_planted.append((env.id, planted.id))
422
+ break
423
+
424
+ card.matches.sort(key=lambda m: m.golden_id)
425
+ return card
qaas/sdk_compat.py ADDED
@@ -0,0 +1,52 @@
1
+ """Pinned facts about the installed Claude Agent SDK.
2
+
3
+ These were wrong in the published docs at the time of writing: the docs give
4
+ hook events as camelCase (`preToolUse`) and `HookMatcher(event=, handler=)`,
5
+ while the installed SDK uses PascalCase events and `HookMatcher(matcher=, hooks=)`.
6
+ Rather than trust either, this module reads the truth out of the installed
7
+ package at import time and fails loudly if it changes under us.
8
+
9
+ Guardrails do not depend on hooks working (they live in `can_use_tool`), so a
10
+ drift here degrades observability, not enforcement — but it should still be a
11
+ noisy failure rather than a silent one.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import typing
17
+
18
+ from claude_agent_sdk import types as _sdk_types
19
+
20
+ _EVENTS: tuple[str, ...] = tuple(
21
+ typing.get_args(arg)[0] for arg in typing.get_args(_sdk_types.HookEvent)
22
+ )
23
+
24
+ PRE_TOOL_USE = "PreToolUse"
25
+ POST_TOOL_USE = "PostToolUse"
26
+ SUBAGENT_START = "SubagentStart"
27
+ STOP = "Stop"
28
+
29
+ _REQUIRED = (PRE_TOOL_USE, POST_TOOL_USE, STOP)
30
+
31
+
32
+ def check() -> None:
33
+ """Raise if the SDK no longer exposes the hook events we register."""
34
+ missing = [e for e in _REQUIRED if e not in _EVENTS]
35
+ if missing:
36
+ raise RuntimeError(
37
+ f"claude-agent-sdk no longer exposes hook events {missing}; "
38
+ f"it offers {list(_EVENTS)}. Update qaas.sdk_compat and guardrails."
39
+ )
40
+
41
+
42
+ def mcp_tool_name(server: str, tool: str) -> str:
43
+ """The name an MCP tool is exposed under: `mcp__<server>__<tool>`."""
44
+ return f"mcp__{server}__{tool}"
45
+
46
+
47
+ def mcp_server_wildcard(server: str) -> str:
48
+ """Allowlist pattern covering every tool on one server."""
49
+ return f"mcp__{server}"
50
+
51
+
52
+ check()