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
@@ -0,0 +1,463 @@
1
+ """The `envelope` MCP server — the only way a finding leaves an agent.
2
+
3
+ Validation happens here rather than in a prompt. An agent that emits a malformed
4
+ envelope gets the field-level errors back and can correct them; an agent that
5
+ emits a finding with no evidence gets refused. Neither is negotiable by argument.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from claude_agent_sdk import create_sdk_mcp_server, tool
14
+ from pydantic import ValidationError
15
+
16
+ from qaas.envelope import DefectEnvelope, Reproduction
17
+ from qaas.mcp.context import ToolContext, err, ok
18
+
19
+ EMIT_SCHEMA: dict[str, Any] = {
20
+ "type": "object",
21
+ "required": ["domain", "class", "title", "summary", "severity", "confidence"],
22
+ "properties": {
23
+ "domain": {
24
+ "type": "string",
25
+ "enum": ["architecture", "database", "api", "websocket", "frontend", "ux", "security", "performance"],
26
+ },
27
+ "class": {
28
+ "type": "string",
29
+ "enum": ["bug", "regression", "ux-friction", "tech-debt", "vulnerability", "perf-regression"],
30
+ },
31
+ "title": {"type": "string", "maxLength": 90, "description": "One line naming the defect, not the symptom."},
32
+ "summary": {"type": "string", "description": "2-4 sentences: what breaks, when, for whom."},
33
+ "severity": {"type": "string", "enum": ["blocker", "critical", "major", "minor", "trivial"]},
34
+ "confidence": {
35
+ "type": "number", "minimum": 0, "maximum": 1,
36
+ "description": "How sure you are a maintainer would accept this. Below 0.6 goes to human review.",
37
+ },
38
+ "location": {
39
+ "type": "object",
40
+ "properties": {
41
+ "service": {"type": "string"},
42
+ "paths": {"type": "array", "items": {"type": "string"}, "description": "Repo-relative, optionally file.py:line"},
43
+ "endpoint": {"type": "string", "description": "e.g. 'GET /v1/orders'"},
44
+ "ui_route": {"type": "string", "description": "e.g. '/checkout/review'"},
45
+ "commit_sha": {"type": "string"},
46
+ },
47
+ },
48
+ "evidence": {
49
+ "type": "array",
50
+ "items": {
51
+ "type": "object",
52
+ "required": ["type", "uri"],
53
+ "properties": {
54
+ "type": {"type": "string", "enum": ["screenshot", "trace", "query_plan", "log", "frame_capture", "test_output", "har"]},
55
+ "uri": {"type": "string", "description": "An artifact:// uri from put_artifact"},
56
+ "note": {"type": "string"},
57
+ },
58
+ },
59
+ },
60
+ "reproduction": {
61
+ "type": "object",
62
+ "description": (
63
+ "The steps you took and the state you observed. You cannot mark a "
64
+ "finding reproduced — FORGE verifies that independently, which is "
65
+ "the point of a separate triage agent."
66
+ ),
67
+ "properties": {
68
+ "steps": {
69
+ "type": "array", "items": {"type": "string"},
70
+ "description": "Exactly what you did, in order, so FORGE can start from it.",
71
+ },
72
+ "environment": {
73
+ "type": "object",
74
+ "properties": {
75
+ "branch": {"type": "string"},
76
+ "fixture": {"type": "string"},
77
+ "flags": {"type": "object"},
78
+ },
79
+ },
80
+ },
81
+ },
82
+ "impact": {
83
+ "type": "object",
84
+ "properties": {
85
+ "user_facing": {"type": "boolean"},
86
+ "affected_surface": {"type": "string"},
87
+ "data_loss_risk": {"type": "boolean"},
88
+ "security_relevant": {"type": "boolean"},
89
+ "frequency_estimate": {"type": "string"},
90
+ },
91
+ },
92
+ "suggested_owner": {
93
+ "type": "object",
94
+ "properties": {"component": {"type": "string"}, "team": {"type": "string"}},
95
+ },
96
+ "suggested_fix_area": {"type": "string"},
97
+ "similar_to": {"type": "array", "items": {"type": "string"}, "description": "Known ticket keys this resembles."},
98
+ },
99
+ }
100
+
101
+
102
+ def build_tools(ctx: ToolContext) -> list:
103
+ """The envelope tools, bound to one agent's run context.
104
+
105
+ Split from `build` so tests can call the handlers directly without standing
106
+ up an MCP transport.
107
+ """
108
+
109
+ @tool(
110
+ "emit_envelope",
111
+ "Report one defect. This is the only way a finding leaves your session. "
112
+ "Rejected envelopes come back with the reason; fix the fields and retry.",
113
+ EMIT_SCHEMA,
114
+ )
115
+ async def emit_envelope(args: dict[str, Any]) -> dict[str, Any]:
116
+ cap = ctx.config.thresholds.max_findings_per_agent_run
117
+ if ctx.count("envelopes") >= cap:
118
+ ctx.store.log("escalation", agent=ctx.agent.name, reason="finding cap reached", cap=cap)
119
+ return err(
120
+ f"Finding cap reached ({cap} for this run). Emitting more is disabled. "
121
+ "If you genuinely have more real defects than this, that is an escalation, "
122
+ "not a filing problem: summarise what remains in your final message and stop."
123
+ )
124
+
125
+ payload = {k: v for k, v in args.items() if k != "similar_to"}
126
+ payload["run_id"] = ctx.store.run_id
127
+ payload["discovered_by"] = ctx.agent.name
128
+
129
+ # A discovery agent does not get to certify its own finding as
130
+ # reproduced (§2: the finder never grades its own homework). Whatever it
131
+ # claims here, the status is reset and FORGE decides independently.
132
+ # Without this the whole triage gate is bypassed by an agent simply
133
+ # asserting it already reproduced the defect — which is exactly what
134
+ # happened on the first full pipeline run, and FORGE was skipped.
135
+ environment = (args.get("reproduction") or {}).get("environment", {})
136
+ steps = (args.get("reproduction") or {}).get("steps", [])
137
+ payload["reproduction"] = {
138
+ "status": "unattempted",
139
+ "steps": steps,
140
+ "environment": environment,
141
+ }
142
+ if args.get("similar_to"):
143
+ payload["dedupe"] = {"similar_to": args["similar_to"]}
144
+
145
+ try:
146
+ envelope = DefectEnvelope.model_validate(payload)
147
+ except ValidationError as exc:
148
+ problems = "; ".join(
149
+ f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in exc.errors()[:8]
150
+ )
151
+ return err(f"Envelope rejected. Fix these fields and call again: {problems}")
152
+
153
+ fileable, reason = envelope.is_fileable(ctx.config.thresholds.min_confidence_to_file)
154
+ ctx.store.put_envelope(envelope)
155
+ n = ctx.bump("envelopes")
156
+
157
+ note = "" if fileable else f" Held from filing: {reason}. It still counts toward your cap."
158
+ return ok(
159
+ f"Recorded {envelope.severity.value} {envelope.domain.value} finding "
160
+ f"'{envelope.title}' ({n}/{cap}).{note}",
161
+ envelope_id=envelope.id,
162
+ fingerprint=envelope.fingerprint(),
163
+ fileable=fileable,
164
+ )
165
+
166
+ @tool(
167
+ "get_system_map",
168
+ "Read the shared system map: services, routes, ui_routes, schema, ownership, task_graph. "
169
+ "Read this before exploring the repository yourself.",
170
+ {"type": "object", "properties": {"section": {"type": "string", "description": "Optional single section to return."}}},
171
+ )
172
+ async def get_system_map(args: dict[str, Any]) -> dict[str, Any]:
173
+ payload = ctx.maps.get(ctx.map_version)
174
+ if payload is None:
175
+ return err("No system map exists yet. CARTOGRAPHER has not run.")
176
+ section = args.get("section")
177
+ if section:
178
+ if section not in payload:
179
+ return err(f"No section '{section}'. Available: {', '.join(sorted(payload))}")
180
+ payload = {section: payload[section]}
181
+ return ok(json.dumps(payload, indent=2)[:60_000], version=ctx.map_version or ctx.maps.latest_version())
182
+
183
+ @tool(
184
+ "put_system_map",
185
+ "Publish the system map. Call once, with the complete map. Cartographer only.",
186
+ {
187
+ "type": "object",
188
+ "required": ["map"],
189
+ "properties": {"map": {"type": "object", "description": "The complete system map."}},
190
+ },
191
+ )
192
+ async def put_system_map(args: dict[str, Any]) -> dict[str, Any]:
193
+ if ctx.agent.name != "CARTOGRAPHER":
194
+ return err("Only CARTOGRAPHER may publish the system map.")
195
+ payload = args.get("map")
196
+ if not isinstance(payload, dict) or not payload:
197
+ return err("map must be a non-empty object.")
198
+ missing = [k for k in ("services", "routes") if k not in payload]
199
+ if missing:
200
+ return err(f"map is missing required sections: {', '.join(missing)}")
201
+ version = ctx.maps.put(payload)
202
+ ctx.store.log("system_map", agent=ctx.agent.name, version=version, sections=sorted(payload))
203
+ return ok(f"Published system map {version} with sections: {', '.join(sorted(payload))}.", version=version)
204
+
205
+ @tool(
206
+ "put_artifact",
207
+ "Store evidence and get back the artifact:// uri to cite in an envelope.",
208
+ {
209
+ "type": "object",
210
+ "required": ["name", "content"],
211
+ "properties": {
212
+ "name": {"type": "string", "description": "Filename, e.g. 'orders-500.log'"},
213
+ "content": {"type": "string"},
214
+ },
215
+ },
216
+ )
217
+ async def put_artifact(args: dict[str, Any]) -> dict[str, Any]:
218
+ uri = ctx.store.put_artifact(args["name"], args["content"])
219
+ return ok(f"Stored as {uri}", uri=uri)
220
+
221
+
222
+ @tool(
223
+ "list_envelopes",
224
+ "List findings recorded in this run, with their reproduction status. "
225
+ "Use this to see what you have been asked to work on.",
226
+ {
227
+ "type": "object",
228
+ "properties": {
229
+ "status": {"type": "string", "enum": ["reproduced", "flaky", "not_reproducible", "unattempted"]},
230
+ "fileable_only": {"type": "boolean", "description": "Only findings that passed the evidence and confidence gates."},
231
+ },
232
+ },
233
+ )
234
+ async def list_envelopes(args: dict[str, Any]) -> dict[str, Any]:
235
+ envelopes = ctx.store.envelopes()
236
+ wanted = args.get("status")
237
+ if wanted:
238
+ envelopes = [e for e in envelopes if e.reproduction.status.value == wanted]
239
+ if args.get("fileable_only"):
240
+ envelopes = [e for e in envelopes if e.is_fileable(ctx.config.thresholds.min_confidence_to_file)[0]]
241
+ if not envelopes:
242
+ return ok("No findings match.", envelopes=[])
243
+
244
+ rows = [
245
+ {
246
+ "id": e.id,
247
+ "discovered_by": e.discovered_by,
248
+ "domain": e.domain.value,
249
+ "severity": e.severity.value,
250
+ "confidence": e.confidence,
251
+ "title": e.title,
252
+ "summary": e.summary,
253
+ "location": e.location.model_dump(exclude_none=True),
254
+ "evidence": [ev.model_dump() for ev in e.evidence],
255
+ "reproduction": e.reproduction.model_dump(mode="json"),
256
+ "fingerprint": e.dedupe.fingerprint,
257
+ "fileable": e.is_fileable(ctx.config.thresholds.min_confidence_to_file)[0],
258
+ }
259
+ for e in envelopes
260
+ ]
261
+ summary = "\n".join(
262
+ f"{r['id']} [{r['severity']}/{r['domain']}] {r['title']} "
263
+ f"(by {r['discovered_by']}, repro={r['reproduction']['status']})"
264
+ for r in rows
265
+ )
266
+ return ok(f"{len(rows)} finding(s):\n{summary}", envelopes=rows)
267
+
268
+ @tool(
269
+ "record_reproduction",
270
+ "Record your reproduction verdict on an existing finding. FORGE only. "
271
+ "This replaces the finding's reproduction block and adjusts its confidence.",
272
+ {
273
+ "type": "object",
274
+ "required": ["envelope_id", "status", "confidence"],
275
+ "properties": {
276
+ "envelope_id": {"type": "string"},
277
+ "status": {"type": "string", "enum": ["reproduced", "flaky", "not_reproducible"]},
278
+ "confidence": {
279
+ "type": "number", "minimum": 0, "maximum": 1,
280
+ "description": "Your confidence after attempting reproduction. Lower it honestly if you could not reproduce.",
281
+ },
282
+ "steps": {"type": "array", "items": {"type": "string"}, "description": "The minimal steps, in order."},
283
+ "failing_test": {"type": "string", "description": "e.g. qa/repro/test_orders_limit.py::test_limit_ignored"},
284
+ "flake_rate": {"type": "number", "minimum": 0, "maximum": 1},
285
+ "environment": {
286
+ "type": "object",
287
+ "properties": {"branch": {"type": "string"}, "fixture": {"type": "string"}, "flags": {"type": "object"}},
288
+ },
289
+ "note": {"type": "string", "description": "Why, if you could not reproduce it."},
290
+ },
291
+ },
292
+ )
293
+ async def record_reproduction(args: dict[str, Any]) -> dict[str, Any]:
294
+ if ctx.agent.name != "FORGE":
295
+ return err("Only FORGE records reproduction verdicts.")
296
+
297
+ envelope = ctx.store.get_envelope(args["envelope_id"])
298
+ if envelope is None:
299
+ return err(f"No finding with id {args['envelope_id']} in this run.")
300
+
301
+ repro = {
302
+ "status": args["status"],
303
+ "steps": args.get("steps", []),
304
+ "failing_test": args.get("failing_test"),
305
+ "flake_rate": args.get("flake_rate", 0.0),
306
+ "environment": args.get("environment", {}),
307
+ "verified_by": "FORGE",
308
+ }
309
+ try:
310
+ updated = envelope.model_copy(
311
+ update={
312
+ "reproduction": Reproduction.model_validate(repro),
313
+ "confidence": float(args["confidence"]),
314
+ }
315
+ )
316
+ except (ValidationError, ValueError) as exc:
317
+ return err(f"Verdict rejected: {exc}")
318
+
319
+ ctx.store.put_envelope(updated)
320
+ fileable, reason = updated.is_fileable(ctx.config.thresholds.min_confidence_to_file)
321
+ ctx.store.log(
322
+ "reproduction",
323
+ agent="FORGE",
324
+ envelope_id=updated.id,
325
+ status=args["status"],
326
+ flake_rate=repro["flake_rate"],
327
+ fileable=fileable,
328
+ )
329
+ verdict = "will reach CLERK" if fileable else f"held: {reason}"
330
+ return ok(
331
+ f"Verdict recorded for {updated.id}: {args['status']}, confidence "
332
+ f"{updated.confidence:.2f} — {verdict}.",
333
+ envelope_id=updated.id,
334
+ fileable=fileable,
335
+ )
336
+
337
+ @tool(
338
+ "record_verdict",
339
+ "Record your verification verdict on a ticket. PROOF only. Exactly one verdict "
340
+ "per ticket: VERIFIED, NOT_FIXED or REGRESSED.",
341
+ {
342
+ "type": "object",
343
+ "required": ["ticket_key", "verdict", "observed"],
344
+ "properties": {
345
+ "ticket_key": {"type": "string"},
346
+ "verdict": {"type": "string", "enum": ["VERIFIED", "NOT_FIXED", "REGRESSED"]},
347
+ "observed": {
348
+ "type": "string",
349
+ "description": "What you actually saw. On NOT_FIXED this is the delta the next agent works from, so be exact: which assertion failed, expected versus actual.",
350
+ },
351
+ "ran": {"type": "array", "items": {"type": "string"}, "description": "What you ran."},
352
+ "not_run": {"type": "array", "items": {"type": "string"}, "description": "What you skipped, and why."},
353
+ "envelope_id": {"type": "string"},
354
+ },
355
+ },
356
+ )
357
+ async def record_verdict(args: dict[str, Any]) -> dict[str, Any]:
358
+ if ctx.agent.name != "PROOF":
359
+ return err("Only PROOF records verification verdicts.")
360
+
361
+ verdict = args["verdict"]
362
+ if verdict == "VERIFIED" and not args.get("ran"):
363
+ # A verdict that closes a ticket must say what backed it. Without
364
+ # this an empty VERIFIED is indistinguishable from a thorough one.
365
+ return err(
366
+ "VERIFIED requires `ran` — name the original failing test and the "
367
+ "regression tests you executed. A verdict nobody can audit is not a verdict."
368
+ )
369
+
370
+ ctx.store.log(
371
+ "verdict",
372
+ agent="PROOF",
373
+ ticket_key=args["ticket_key"],
374
+ verdict=verdict,
375
+ envelope_id=args.get("envelope_id"),
376
+ observed=args["observed"][:2000],
377
+ ran=args.get("ran", []),
378
+ not_run=args.get("not_run", []),
379
+ )
380
+ return ok(f"Verdict {verdict} recorded for {args['ticket_key']}.", verdict=verdict)
381
+
382
+
383
+ @tool(
384
+ "record_review",
385
+ "Record your review decision on a fix. ARBITER only. This is the decision "
386
+ "the conductor routes on: APPROVE lets the fix proceed to verification, "
387
+ "REQUEST_CHANGES sends it back, ESCALATE_TO_HUMAN stops the loop.",
388
+ {
389
+ "type": "object",
390
+ "required": ["ticket_key", "decision", "reasoning"],
391
+ "properties": {
392
+ "ticket_key": {"type": "string"},
393
+ "decision": {
394
+ "type": "string",
395
+ "enum": ["APPROVE", "REQUEST_CHANGES", "ESCALATE_TO_HUMAN"],
396
+ },
397
+ "reasoning": {
398
+ "type": "string",
399
+ "description": (
400
+ "Why. For REQUEST_CHANGES this goes back to MENDER verbatim, "
401
+ "so be specific enough to act on: name the file and what is wrong."
402
+ ),
403
+ },
404
+ "root_cause_addressed": {
405
+ "type": "boolean",
406
+ "description": "Whether the fix addresses the cause rather than the symptom.",
407
+ },
408
+ "diff_files": {"type": "integer", "description": "How many files the diff touches."},
409
+ "concerns": {
410
+ "type": "array", "items": {"type": "string"},
411
+ "description": "Specific risks, even when approving.",
412
+ },
413
+ },
414
+ },
415
+ )
416
+ async def record_review(args: dict[str, Any]) -> dict[str, Any]:
417
+ if ctx.agent.name != "ARBITER":
418
+ return err("Only ARBITER records review decisions.")
419
+
420
+ decision = args["decision"]
421
+ reasoning = (args.get("reasoning") or "").strip()
422
+
423
+ # An approval with no reasoning is not a review, and it is the shape a
424
+ # rubber stamp takes. REQUEST_CHANGES with nothing actionable is worse:
425
+ # it sends MENDER round the loop with no idea what to change.
426
+ if len(reasoning) < 40:
427
+ return err(
428
+ f"{decision} needs reasoning a person can act on — at least a "
429
+ "sentence naming what you checked and what you concluded."
430
+ )
431
+ if decision == "REQUEST_CHANGES" and not args.get("concerns"):
432
+ return err(
433
+ "REQUEST_CHANGES must list concerns. MENDER gets them verbatim and "
434
+ "cannot act on a verdict with no specifics."
435
+ )
436
+
437
+ ctx.store.log(
438
+ "review",
439
+ agent="ARBITER",
440
+ ticket_key=args["ticket_key"],
441
+ decision=decision,
442
+ reasoning=reasoning,
443
+ root_cause_addressed=args.get("root_cause_addressed"),
444
+ diff_files=args.get("diff_files"),
445
+ concerns=args.get("concerns", []),
446
+ )
447
+ return ok(f"Review recorded for {args['ticket_key']}: {decision}.", decision=decision)
448
+
449
+ return [
450
+ emit_envelope,
451
+ record_review,
452
+ record_verdict,
453
+ list_envelopes,
454
+ record_reproduction,
455
+ get_system_map,
456
+ put_system_map,
457
+ put_artifact,
458
+ ]
459
+
460
+
461
+ def build(ctx: ToolContext):
462
+ """Construct the envelope MCP server bound to one agent's run context."""
463
+ return create_sdk_mcp_server(name="envelope", version="1.0.0", tools=build_tools(ctx))