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/mcp/tracker.py ADDED
@@ -0,0 +1,412 @@
1
+ """The `tracker` MCP server — every ticket the system files passes through here.
2
+
3
+ The rules below are code, not prompt text, and that is deliberate. §8.1 says
4
+ only CLERK creates and only CLERK and PROOF transition; §4.12 caps tickets per
5
+ run and requires that hitting the cap escalates instead of filing; §10 lists
6
+ "security findings leak into public tickets" as a named failure mode. A prompt
7
+ can be argued with, misread, or dropped from a truncated context. A refusal
8
+ returned from the tool cannot.
9
+
10
+ Which backend actually stores the ticket is `config.tracker`'s business — see
11
+ `qaas.adapters.tracker`. Policy lives here so it holds identically for local
12
+ files and for real Jira.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from collections.abc import Mapping
19
+ from typing import Any
20
+
21
+ from claude_agent_sdk import create_sdk_mcp_server, tool
22
+ from rich.console import Console
23
+
24
+ from qaas.adapters.tracker import (
25
+ DEFAULT_PROJECT,
26
+ LINK_TYPES,
27
+ SECURITY_PROJECT,
28
+ STATUSES,
29
+ TrackerError,
30
+ build_tracker,
31
+ issue_summary,
32
+ )
33
+ from qaas.envelope import DefectClass, DefectEnvelope
34
+ from qaas.mcp.context import ToolContext, err, ok
35
+
36
+ #: Set to 1 to rehearse every tracker write instead of performing it. This is
37
+ #: the safety rail for first contact with a real Jira: the policy above still
38
+ #: runs, the payload is still assembled, the ledger still records what would
39
+ #: have happened — and nothing is sent. Reads (`search`) stay live, because a
40
+ #: dedupe check that cannot see the real backlog rehearses the wrong run.
41
+ TRACKER_DRY_RUN_ENV = "QAAS_TRACKER_DRY_RUN"
42
+
43
+ #: Prefixed to every dry-run tool result. An agent that is told "filed CORVID-1"
44
+ #: will report a filed ticket, and a human will go looking for it; the notice
45
+ #: has to be the first thing in the result, not a footnote.
46
+ DRY_RUN_NOTICE = (
47
+ f"DRY RUN — NOTHING WAS FILED. {TRACKER_DRY_RUN_ENV}=1 is set, so the tracker rehearsed "
48
+ "this call and sent nothing to the backend. The key below is a placeholder and does not "
49
+ "exist. Do not report this as a filed ticket."
50
+ )
51
+
52
+ #: Dry-run lines go to stderr so an operator watching a run sees them without
53
+ #: them landing in anything that parses stdout.
54
+ _console = Console(stderr=True)
55
+
56
+
57
+ def dry_run_enabled(env: Mapping[str, str] | None = None) -> bool:
58
+ """Whether tracker writes are rehearsed rather than performed.
59
+
60
+ Read from the environment rather than config/ so that turning the rail off
61
+ is a deliberate act in the shell that starts the run, and so that a
62
+ committed config file can never quietly disable it.
63
+ """
64
+ source: Mapping[str, str] = os.environ if env is None else env
65
+ return (source.get(TRACKER_DRY_RUN_ENV) or "").strip().lower() in {"1", "true", "yes", "on"}
66
+
67
+
68
+ def is_restricted(envelope: DefectEnvelope) -> bool:
69
+ """Whether this finding may only be filed into the restricted project.
70
+
71
+ Two independent triggers, because either alone is enough to make a public
72
+ ticket a disclosure: the reporter flagged security impact, or the defect is
73
+ classified as a vulnerability.
74
+ """
75
+ return envelope.impact.security_relevant or envelope.defect_class == DefectClass.VULNERABILITY
76
+
77
+
78
+ CREATE_SCHEMA: dict[str, Any] = {
79
+ "type": "object",
80
+ "required": ["title", "body"],
81
+ "properties": {
82
+ "title": {"type": "string", "description": "Names the defect, not the symptom."},
83
+ "body": {
84
+ "type": "string",
85
+ "description": "House format: repro steps, evidence links, impact, acceptance criteria.",
86
+ },
87
+ "envelope_id": {
88
+ "type": "string",
89
+ "description": "The envelope this ticket files. Supply it: routing and severity are read from it.",
90
+ },
91
+ "project": {
92
+ "type": "string",
93
+ "description": f"Defaults to {DEFAULT_PROJECT}. Security findings are forced to {SECURITY_PROJECT}.",
94
+ },
95
+ "labels": {"type": "array", "items": {"type": "string"}},
96
+ "severity": {"type": "string", "enum": ["blocker", "critical", "major", "minor", "trivial"]},
97
+ "security_relevant": {
98
+ "type": "boolean",
99
+ "description": "Force restricted routing when no envelope carries the flag.",
100
+ },
101
+ },
102
+ }
103
+
104
+
105
+ def build_tools(ctx: ToolContext) -> list:
106
+ """The tracker tools, bound to one agent's run context.
107
+
108
+ Split from `build` so tests can call the handlers directly without standing
109
+ up an MCP transport.
110
+ """
111
+ # Built even in dry-run mode: constructing a JiraTracker is what validates
112
+ # the credentials, and a rehearsal against a configuration that could never
113
+ # have worked proves nothing.
114
+ tracker = build_tracker(ctx.config.tracker, ctx.store.root)
115
+ policy = ctx.agent.policy
116
+ dry_run = dry_run_enabled()
117
+
118
+ def deny(tool_name: str, reason: str) -> dict[str, Any]:
119
+ """Refuse, and leave a trace. A denial nobody can see is not a guardrail."""
120
+ ctx.store.log("denial", agent=ctx.agent.name, tool=tool_name, reason=reason)
121
+ return err(reason)
122
+
123
+ def rehearse(tool_name: str, line: str, **detail: Any) -> None:
124
+ """Record a write that was not performed, on the ledger and on stderr.
125
+
126
+ The ledger kind is `dry_run`, never `ticket`: anything counting filed
127
+ tickets must not count these, and a flag on a `ticket` entry is one
128
+ missed `if` away from being counted.
129
+ """
130
+ ctx.store.log("dry_run", agent=ctx.agent.name, tool=tool_name, **detail)
131
+ _console.print(f"[yellow]dry run[/yellow] {ctx.agent.name}: {line} [dim](not sent)[/dim]")
132
+
133
+ @tool(
134
+ "create_issue",
135
+ "File one tracker issue. Dedupe first — a duplicate costs the team more than a miss. "
136
+ "Security findings are routed to the restricted project automatically.",
137
+ CREATE_SCHEMA,
138
+ )
139
+ async def create_issue(args: dict[str, Any]) -> dict[str, Any]:
140
+ if not policy.may_create_tickets:
141
+ return deny(
142
+ "create_issue",
143
+ f"{ctx.agent.name} may not create tickets (§8.1: CLERK only). "
144
+ "Emit your finding as an envelope; CLERK files it.",
145
+ )
146
+
147
+ cap = policy.max_tickets_per_run
148
+ if ctx.count("tickets") >= cap:
149
+ ctx.store.log(
150
+ "escalation", agent=ctx.agent.name,
151
+ reason="ticket cap reached", cap=cap, tool="create_issue",
152
+ )
153
+ return deny(
154
+ "create_issue",
155
+ f"Ticket cap reached ({cap} for this run). Filing is disabled for the rest "
156
+ "of the run. Hitting the cap means something upstream is wrong, and forty "
157
+ "more tickets will not fix it: this is an escalation, not a filing problem. "
158
+ "Summarise what remains unfiled in your final message and stop.",
159
+ )
160
+
161
+ envelope = None
162
+ if args.get("envelope_id"):
163
+ envelope = ctx.store.get_envelope(args["envelope_id"])
164
+ if envelope is None:
165
+ return err(f"No envelope '{args['envelope_id']}' in this run. Emit it first.")
166
+
167
+ restricted = bool(args.get("security_relevant")) or (envelope is not None and is_restricted(envelope))
168
+ requested = (args.get("project") or "").strip() or None
169
+
170
+ # The project *names* come from the adapter, because a real Jira's keys
171
+ # are set by the deployment (JIRA_PROJECT_KEY / JIRA_SECURITY_PROJECT_KEY)
172
+ # rather than by the house constants. The routing *rule* is here, once,
173
+ # so it holds identically for local files and for Jira.
174
+ restricted_project = tracker.security_project
175
+
176
+ if restricted:
177
+ if restricted_project is None:
178
+ return deny(
179
+ "create_issue",
180
+ "Refused: this is a security-relevant finding and this tracker has no "
181
+ "restricted project configured (set JIRA_SECURITY_PROJECT_KEY to a "
182
+ f"project with restricted visibility). Filing it into "
183
+ f"'{tracker.default_project}' would be a disclosure, and there is no "
184
+ "undo (§4.12, §10). Escalate this finding to a human through a private "
185
+ "channel instead — do not file it anywhere.",
186
+ )
187
+ if requested and requested != restricted_project:
188
+ return deny(
189
+ "create_issue",
190
+ f"Refused: this is a security-relevant finding and '{requested}' is not the "
191
+ f"restricted project. Security findings never go to a public project (§4.12, "
192
+ f"§10) — file it into {restricted_project}, or omit `project` and it is routed "
193
+ "there for you.",
194
+ )
195
+ project = restricted_project
196
+ else:
197
+ project = requested or tracker.default_project
198
+
199
+ severity = args.get("severity") or (envelope.severity.value if envelope else None)
200
+ labels = list(args.get("labels") or [])
201
+ if "agent-found" not in labels:
202
+ labels.append("agent-found")
203
+ if restricted and "security" not in labels:
204
+ labels.append("security")
205
+
206
+ if dry_run:
207
+ # The cap is still consumed: a rehearsal that ignores the rate limit
208
+ # is not a rehearsal of the run you are about to do.
209
+ n = ctx.bump("tickets")
210
+ key = f"{project}-{9000 + n}"
211
+ rehearse(
212
+ "create_issue",
213
+ f"would file '{args['title']}' in {project} as {key}",
214
+ action="create_issue", key=key, project=project, title=args["title"],
215
+ severity=severity, labels=sorted(labels), restricted=restricted,
216
+ envelope_id=envelope.id if envelope else None, count=n, cap=cap,
217
+ )
218
+ return ok(
219
+ f"{DRY_RUN_NOTICE} It would have filed '{args['title']}' into {project} "
220
+ f"(placeholder key {key}, severity {severity or 'unset'}, labels "
221
+ f"{', '.join(sorted(labels))}) — {n}/{cap} for this run.",
222
+ key=key, project=project, title=args["title"], severity=severity,
223
+ labels=sorted(labels), restricted=restricted,
224
+ envelope_id=envelope.id if envelope else None,
225
+ tickets_filed=n, tickets_cap=cap, dry_run=True, filed=False,
226
+ )
227
+
228
+ try:
229
+ issue = tracker.create_issue(
230
+ project=project,
231
+ title=args["title"],
232
+ body=args["body"],
233
+ labels=labels,
234
+ severity=severity,
235
+ envelope_id=envelope.id if envelope else None,
236
+ fingerprint=envelope.fingerprint() if envelope else None,
237
+ reporter=ctx.agent.name,
238
+ )
239
+ except TrackerError as exc:
240
+ return err(f"Tracker rejected the issue: {exc}")
241
+
242
+ # Stamp the key back onto the envelope. The two loops of this system meet
243
+ # at the ticket (§1) and this write is that junction: without it the
244
+ # remediation half can never find anything to work on, because it selects
245
+ # by `envelope.jira.key`. A whole full-loop run reached CLERK, filed ten
246
+ # tickets, and then skipped verification entirely for exactly this reason.
247
+ if envelope is not None:
248
+ ctx.store.put_envelope(
249
+ envelope.model_copy(
250
+ update={
251
+ "jira": envelope.jira.model_copy(
252
+ update={"key": issue.key, "project": project, "status": issue.status}
253
+ )
254
+ }
255
+ )
256
+ )
257
+
258
+ n = ctx.bump("tickets")
259
+ ctx.store.log(
260
+ "ticket", agent=ctx.agent.name, action="created", key=issue.key,
261
+ project=project, severity=severity, restricted=restricted,
262
+ envelope_id=issue.envelope_id, count=n, cap=cap,
263
+ )
264
+ routed = " Routed to the restricted project because it is security-relevant." if restricted else ""
265
+ return ok(
266
+ f"Filed {issue.key} in {project} ({n}/{cap} this run).{routed}",
267
+ **issue_summary(issue),
268
+ tickets_filed=n,
269
+ tickets_cap=cap,
270
+ restricted=restricted,
271
+ )
272
+
273
+ @tool(
274
+ "transition",
275
+ "Move an issue to a new status. Say why in the comment — the history is the audit trail.",
276
+ {
277
+ "type": "object",
278
+ "required": ["key", "status"],
279
+ "properties": {
280
+ "key": {"type": "string", "description": f"e.g. {DEFAULT_PROJECT}-12"},
281
+ "status": {"type": "string", "enum": list(STATUSES)},
282
+ "comment": {"type": "string", "description": "Why. Verdicts without reasons are not reviewable."},
283
+ },
284
+ },
285
+ )
286
+ async def transition(args: dict[str, Any]) -> dict[str, Any]:
287
+ if not policy.may_transition_tickets:
288
+ return deny(
289
+ "transition",
290
+ f"{ctx.agent.name} may not transition tickets (§8.1: CLERK and PROOF only).",
291
+ )
292
+
293
+ if dry_run:
294
+ # The issue is not read back either: in a rehearsal nothing was ever
295
+ # filed, so the key the agent holds is a placeholder and a lookup
296
+ # would fail for a reason that has nothing to do with the workflow.
297
+ rehearse(
298
+ "transition",
299
+ f"would move {args['key']} to '{args['status']}'",
300
+ action="transition", key=args["key"], status=args["status"],
301
+ comment=args.get("comment", ""),
302
+ )
303
+ return ok(
304
+ f"{DRY_RUN_NOTICE} It would have moved {args['key']} to '{args['status']}'"
305
+ + (f" with the comment: {args['comment']}" if args.get("comment") else "")
306
+ + ". The ticket's real status is unchanged.",
307
+ key=args["key"], status=args["status"], dry_run=True, filed=False,
308
+ )
309
+
310
+ try:
311
+ issue = tracker.transition(
312
+ args["key"], args["status"], by=ctx.agent.name, comment=args.get("comment", "")
313
+ )
314
+ except TrackerError as exc:
315
+ return err(f"Transition refused: {exc}")
316
+
317
+ ctx.store.log(
318
+ "ticket", agent=ctx.agent.name, action="transitioned",
319
+ key=issue.key, status=issue.status, comment=args.get("comment", ""),
320
+ )
321
+ return ok(f"{issue.key} is now '{issue.status}'.", **issue_summary(issue))
322
+
323
+ @tool(
324
+ "link",
325
+ "Link two existing issues — 'duplicates' for a repeat, 'regression-of' when a resolved "
326
+ "defect has come back.",
327
+ {
328
+ "type": "object",
329
+ "required": ["key", "to"],
330
+ "properties": {
331
+ "key": {"type": "string"},
332
+ "to": {"type": "string"},
333
+ "type": {"type": "string", "enum": list(LINK_TYPES), "default": "relates"},
334
+ },
335
+ },
336
+ )
337
+ async def link(args: dict[str, Any]) -> dict[str, Any]:
338
+ if not (policy.may_create_tickets or policy.may_transition_tickets):
339
+ return deny(
340
+ "link",
341
+ f"{ctx.agent.name} has no tracker write access, and a link is a write (§8.1).",
342
+ )
343
+
344
+ link_type = args.get("type") or "relates"
345
+ if dry_run:
346
+ rehearse(
347
+ "link",
348
+ f"would link {args['key']} {link_type} {args['to']}",
349
+ action="link", key=args["key"], to=args["to"], type=link_type,
350
+ )
351
+ return ok(
352
+ f"{DRY_RUN_NOTICE} It would have linked {args['key']} {link_type} "
353
+ f"{args['to']}. No link exists.",
354
+ key=args["key"], to=args["to"], type=link_type, dry_run=True, filed=False,
355
+ )
356
+
357
+ try:
358
+ issue = tracker.link(args["key"], args["to"], link_type)
359
+ except TrackerError as exc:
360
+ return err(f"Link refused: {exc}")
361
+ ctx.store.log(
362
+ "ticket", agent=ctx.agent.name, action="linked",
363
+ key=args["key"], to=args["to"], type=link_type,
364
+ )
365
+ return ok(f"{issue.key} {link_type} {args['to']}.", **issue_summary(issue))
366
+
367
+ @tool(
368
+ "search",
369
+ "Find existing issues before filing a new one, or to pick up work.",
370
+ {
371
+ "type": "object",
372
+ "properties": {
373
+ "text": {"type": "string", "description": "Substring of title or body."},
374
+ "project": {"type": "string"},
375
+ "status": {"type": "string", "enum": list(STATUSES)},
376
+ "label": {"type": "string"},
377
+ "envelope_id": {"type": "string"},
378
+ "fingerprint": {"type": "string"},
379
+ "limit": {"type": "integer", "minimum": 1, "maximum": 50},
380
+ },
381
+ },
382
+ )
383
+ async def search(args: dict[str, Any]) -> dict[str, Any]:
384
+ # Deliberately live even in dry-run mode: dedupe against an imaginary
385
+ # backlog would rehearse a run that files things the real one would not.
386
+ try:
387
+ issues = tracker.search(
388
+ text=args.get("text"),
389
+ project=args.get("project"),
390
+ status=args.get("status"),
391
+ label=args.get("label"),
392
+ envelope_id=args.get("envelope_id"),
393
+ fingerprint=args.get("fingerprint"),
394
+ limit=int(args.get("limit") or 20),
395
+ )
396
+ except TrackerError as exc:
397
+ return err(f"Search refused: {exc}")
398
+ if not issues:
399
+ return ok("No issues match.", issues=[], count=0)
400
+ lines = [f" {i.key} [{i.status}] {i.severity or '-'} {i.title}" for i in issues]
401
+ return ok(
402
+ f"{len(issues)} issue(s):\n" + "\n".join(lines),
403
+ issues=[issue_summary(i) for i in issues],
404
+ count=len(issues),
405
+ )
406
+
407
+ return [create_issue, transition, link, search]
408
+
409
+
410
+ def build(ctx: ToolContext):
411
+ """Construct the tracker MCP server bound to one agent's run context."""
412
+ return create_sdk_mcp_server(name="tracker", version="1.0.0", tools=build_tools(ctx))