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,495 @@
1
+ """The `defect_memory` MCP server — the thing that stops duplicate storms.
2
+
3
+ §10 names duplicate storms as a top failure mode and makes this server
4
+ mandatory. The memory outlives the run: it lives in one SQLite file under the
5
+ state root, because dedupe across *runs* is the whole point — a defect found
6
+ again next week must land on the ticket filed today, not beside it.
7
+
8
+ Similarity here is deterministic and offline on purpose. An embedding model
9
+ would make matching fuzzier and the system less explainable: two agents asking
10
+ the same question a minute apart must get the same answer, and a reviewer must
11
+ be able to say exactly why two reports were called the same defect. So the
12
+ score combines three things a human would also use — the structural fingerprint,
13
+ the code location, and the words — with fixed weights.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sqlite3
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from claude_agent_sdk import create_sdk_mcp_server, tool
25
+ from pydantic import ValidationError
26
+
27
+ from qaas.envelope import DefectEnvelope, Location, _strip_line_number
28
+ from qaas.mcp.context import ToolContext, err, ok
29
+
30
+ MEMORY_DB = "memory.db"
31
+
32
+ # Weights sum to 1.0. The fingerprint is the strongest single signal because it
33
+ # is already a structural identity; prose is the weakest because two agents
34
+ # describing one defect share surprisingly few words.
35
+ W_FINGERPRINT = 0.40
36
+ W_LOCATION = 0.35
37
+ W_TOKENS = 0.25
38
+
39
+ # Below this, a candidate is not worth an agent's attention. Set so that a
40
+ # location match alone clears it (0.35) but prose overlap alone never does.
41
+ MIN_SIMILARITY = 0.35
42
+
43
+ # A different domain is strong evidence of a different defect, so it halves an
44
+ # otherwise convincing score rather than being another additive term.
45
+ CROSS_DOMAIN_PENALTY = 0.5
46
+
47
+ DEFAULT_LIMIT = 10
48
+
49
+ # Words that appear in nearly every defect report carry no discriminating
50
+ # signal; leaving them in makes every pair of reports look 30% alike.
51
+ _STOPWORDS = frozenset(
52
+ """
53
+ the and for with that this from when then than but not are was were will
54
+ have has had does did doing any all its it's you your our their there here
55
+ into onto over under also only just some more most much very can could
56
+ should would may might must shall while which who whom whose what where why
57
+ how because since about after before between during without within
58
+ bug issue defect error problem fault failure broken breaks fails failing
59
+ endpoint request response server client user users page screen app
60
+ """.split()
61
+ )
62
+
63
+ _SCHEMA = """
64
+ CREATE TABLE IF NOT EXISTS defects (
65
+ fingerprint TEXT PRIMARY KEY,
66
+ title TEXT NOT NULL,
67
+ summary TEXT NOT NULL DEFAULT '',
68
+ domain TEXT NOT NULL,
69
+ defect_class TEXT NOT NULL DEFAULT '',
70
+ service TEXT,
71
+ endpoint TEXT,
72
+ ui_route TEXT,
73
+ paths TEXT NOT NULL DEFAULT '[]',
74
+ ticket_key TEXT,
75
+ occurrence_count INTEGER NOT NULL DEFAULT 1,
76
+ first_seen TEXT NOT NULL,
77
+ last_seen TEXT NOT NULL,
78
+ last_run_id TEXT,
79
+ resolved_at TEXT,
80
+ resolved_ticket_key TEXT
81
+ );
82
+ CREATE INDEX IF NOT EXISTS defects_domain ON defects(domain);
83
+ """
84
+
85
+
86
+ def _utcnow_iso() -> str:
87
+ return datetime.now(timezone.utc).isoformat()
88
+
89
+
90
+ def connect(root: Path | str) -> sqlite3.Connection:
91
+ """Open (and, first time, create) the shared defect memory."""
92
+ root = Path(root)
93
+ root.mkdir(parents=True, exist_ok=True)
94
+ conn = sqlite3.connect(root / MEMORY_DB)
95
+ conn.row_factory = sqlite3.Row
96
+ conn.executescript(_SCHEMA)
97
+ return conn
98
+
99
+
100
+ # -- similarity -----------------------------------------------------------
101
+
102
+
103
+ def tokens(text: str) -> set[str]:
104
+ """Content words of a report, lowercased and de-noised."""
105
+ words = "".join(c if c.isalnum() else " " for c in text.lower()).split()
106
+ return {w for w in words if len(w) >= 3 and w not in _STOPWORDS}
107
+
108
+
109
+ def jaccard(a: set[str], b: set[str]) -> float:
110
+ if not a or not b:
111
+ return 0.0
112
+ return len(a & b) / len(a | b)
113
+
114
+
115
+ def _paths(raw: Any) -> set[str]:
116
+ """Normalised path set. Line numbers are dropped for the same reason
117
+ `DefectEnvelope.fingerprint` drops them: code moves, the defect does not."""
118
+ if isinstance(raw, str):
119
+ raw = json.loads(raw or "[]")
120
+ return {_strip_line_number(p) for p in (raw or []) if p}
121
+
122
+
123
+ def location_score(query: dict[str, Any], row: dict[str, Any]) -> float:
124
+ """How much of the location the two reports agree on.
125
+
126
+ Only fields *both* sides supplied are compared — a query that omits
127
+ `ui_route` should not be penalised against a row that has one, or every
128
+ partially-specified search would score zero.
129
+ """
130
+ parts: list[float] = []
131
+ for field in ("service", "endpoint", "ui_route"):
132
+ a, b = (query.get(field) or "").strip().lower(), (row.get(field) or "").strip().lower()
133
+ if a and b:
134
+ parts.append(1.0 if a == b else 0.0)
135
+ qp, rp = _paths(query.get("paths")), _paths(row.get("paths"))
136
+ if qp and rp:
137
+ parts.append(len(qp & rp) / len(qp | rp))
138
+ return sum(parts) / len(parts) if parts else 0.0
139
+
140
+
141
+ def similarity(query: dict[str, Any], row: dict[str, Any]) -> float:
142
+ """Deterministic 0..1 similarity between a search and a remembered defect."""
143
+ fingerprint_match = 1.0 if query.get("fingerprint") and query["fingerprint"] == row.get("fingerprint") else 0.0
144
+ location = location_score(query, row)
145
+ prose = jaccard(
146
+ tokens(f"{query.get('title', '')} {query.get('summary', '')}"),
147
+ tokens(f"{row.get('title', '')} {row.get('summary', '')}"),
148
+ )
149
+ score = W_FINGERPRINT * fingerprint_match + W_LOCATION * location + W_TOKENS * prose
150
+ if query.get("domain") and row.get("domain") and query["domain"] != row["domain"]:
151
+ score *= CROSS_DOMAIN_PENALTY
152
+ return round(score, 4)
153
+
154
+
155
+ def _probe_fingerprint(ctx: ToolContext, args: dict[str, Any]) -> str | None:
156
+ """The structural fingerprint the searched-for defect *would* have.
157
+
158
+ Built by constructing a throwaway envelope so the hash comes from
159
+ `DefectEnvelope.fingerprint()` and cannot drift from the one stored on real
160
+ envelopes. Needs the defect class, so it returns None when the caller only
161
+ has a partial description — the other two signals still apply.
162
+ """
163
+ if not args.get("class"):
164
+ return None
165
+ try:
166
+ probe = DefectEnvelope(
167
+ run_id=ctx.store.run_id,
168
+ discovered_by=ctx.agent.name,
169
+ domain=args["domain"],
170
+ **{"class": args["class"]},
171
+ title=(args.get("title") or "probe")[:90],
172
+ summary=args.get("summary") or "probe",
173
+ severity="minor",
174
+ confidence=0.5,
175
+ location=Location(
176
+ service=args.get("service"),
177
+ endpoint=args.get("endpoint"),
178
+ ui_route=args.get("ui_route"),
179
+ paths=list(args.get("paths") or []),
180
+ ),
181
+ )
182
+ except ValidationError:
183
+ return None
184
+ return probe.fingerprint()
185
+
186
+
187
+ def _row_view(row: sqlite3.Row, score: float | None = None) -> dict[str, Any]:
188
+ view: dict[str, Any] = {
189
+ "fingerprint": row["fingerprint"],
190
+ "title": row["title"],
191
+ "domain": row["domain"],
192
+ "class": row["defect_class"],
193
+ "ticket_key": row["ticket_key"],
194
+ "occurrence_count": row["occurrence_count"],
195
+ "first_seen": row["first_seen"],
196
+ "last_seen": row["last_seen"],
197
+ "resolved": row["resolved_at"] is not None,
198
+ "location": {
199
+ "service": row["service"],
200
+ "endpoint": row["endpoint"],
201
+ "ui_route": row["ui_route"],
202
+ "paths": json.loads(row["paths"] or "[]"),
203
+ },
204
+ }
205
+ if score is not None:
206
+ view["similarity"] = score
207
+ return view
208
+
209
+
210
+ SEARCH_SCHEMA: dict[str, Any] = {
211
+ "type": "object",
212
+ "required": ["title", "summary", "domain"],
213
+ "properties": {
214
+ "title": {"type": "string", "description": "The candidate defect's title."},
215
+ "summary": {"type": "string", "description": "The candidate defect's summary."},
216
+ "domain": {
217
+ "type": "string",
218
+ "enum": ["architecture", "database", "api", "websocket", "frontend", "ux", "security", "performance"],
219
+ },
220
+ "class": {
221
+ "type": "string",
222
+ "enum": ["bug", "regression", "ux-friction", "tech-debt", "vulnerability", "perf-regression"],
223
+ "description": "Supply it when you have it: it enables exact fingerprint matching.",
224
+ },
225
+ "service": {"type": "string"},
226
+ "endpoint": {"type": "string", "description": "e.g. 'GET /v1/orders'"},
227
+ "ui_route": {"type": "string", "description": "e.g. '/checkout/review'"},
228
+ "paths": {"type": "array", "items": {"type": "string"}, "description": "Repo-relative paths."},
229
+ "limit": {"type": "integer", "minimum": 1, "maximum": 50},
230
+ },
231
+ }
232
+
233
+
234
+ def build_tools(ctx: ToolContext) -> list:
235
+ """The defect memory tools, bound to one agent's run context.
236
+
237
+ Split from `build` so tests can call the handlers directly without standing
238
+ up an MCP transport.
239
+ """
240
+ root = ctx.store.root
241
+
242
+ @tool(
243
+ "search_similar",
244
+ "Search remembered defects for prior reports of this one. Call this BEFORE filing "
245
+ "anything: a match means increment the existing ticket, not open a second one.",
246
+ SEARCH_SCHEMA,
247
+ )
248
+ async def search_similar(args: dict[str, Any]) -> dict[str, Any]:
249
+ query = dict(args)
250
+ query["fingerprint"] = _probe_fingerprint(ctx, args)
251
+ limit = int(args.get("limit") or DEFAULT_LIMIT)
252
+
253
+ conn = connect(root)
254
+ try:
255
+ rows = conn.execute("SELECT * FROM defects").fetchall()
256
+ finally:
257
+ conn.close()
258
+
259
+ scored = [(similarity(query, dict(r)), r) for r in rows]
260
+ matches = sorted(
261
+ ((s, r) for s, r in scored if s >= MIN_SIMILARITY),
262
+ key=lambda pair: (-pair[0], pair[1]["fingerprint"]),
263
+ )[:limit]
264
+
265
+ if not matches:
266
+ return ok(
267
+ f"No prior defect resembles '{args['title']}' "
268
+ f"({len(rows)} in memory, none above {MIN_SIMILARITY}). This looks new.",
269
+ candidates=[],
270
+ searched=len(rows),
271
+ )
272
+
273
+ candidates = [_row_view(r, s) for s, r in matches]
274
+ lines = [
275
+ f" {c['similarity']:.2f} {c['ticket_key'] or 'unfiled'} "
276
+ f"x{c['occurrence_count']} last seen {c['last_seen']} {c['title']}"
277
+ + (" [RESOLVED — a recurrence is a regression]" if c["resolved"] else "")
278
+ for c in candidates
279
+ ]
280
+ return ok(
281
+ f"{len(candidates)} prior defect(s) resemble this one:\n" + "\n".join(lines),
282
+ candidates=candidates,
283
+ searched=len(rows),
284
+ )
285
+
286
+ @tool(
287
+ "fingerprint",
288
+ "Return the structural fingerprint of an envelope you already emitted. "
289
+ "Two reports of the same defect share it regardless of wording.",
290
+ {
291
+ "type": "object",
292
+ "required": ["envelope_id"],
293
+ "properties": {"envelope_id": {"type": "string"}},
294
+ },
295
+ )
296
+ async def fingerprint(args: dict[str, Any]) -> dict[str, Any]:
297
+ envelope = ctx.store.get_envelope(args["envelope_id"])
298
+ if envelope is None:
299
+ return err(f"No envelope '{args['envelope_id']}' in this run. Emit it first.")
300
+ return ok(
301
+ f"{envelope.fingerprint()} — {envelope.title}",
302
+ envelope_id=envelope.id,
303
+ fingerprint=envelope.fingerprint(),
304
+ )
305
+
306
+ @tool(
307
+ "record",
308
+ "Remember this defect so future runs can dedupe against it. A second record of a "
309
+ "known fingerprint increments its occurrence count instead of duplicating it.",
310
+ {
311
+ "type": "object",
312
+ "required": ["envelope_id"],
313
+ "properties": {
314
+ "envelope_id": {"type": "string"},
315
+ "ticket_key": {"type": "string", "description": "The tracker key, if one was filed."},
316
+ },
317
+ },
318
+ )
319
+ async def record(args: dict[str, Any]) -> dict[str, Any]:
320
+ envelope = ctx.store.get_envelope(args["envelope_id"])
321
+ if envelope is None:
322
+ return err(f"No envelope '{args['envelope_id']}' in this run. Emit it first.")
323
+
324
+ fp = envelope.fingerprint()
325
+ ticket_key = args.get("ticket_key") or None
326
+ now = _utcnow_iso()
327
+
328
+ conn = connect(root)
329
+ try:
330
+ row = conn.execute("SELECT * FROM defects WHERE fingerprint = ?", (fp,)).fetchone()
331
+ if row is None:
332
+ conn.execute(
333
+ "INSERT INTO defects (fingerprint, title, summary, domain, defect_class, "
334
+ "service, endpoint, ui_route, paths, ticket_key, occurrence_count, "
335
+ "first_seen, last_seen, last_run_id) "
336
+ "VALUES (?,?,?,?,?,?,?,?,?,?,1,?,?,?)",
337
+ (
338
+ fp,
339
+ envelope.title,
340
+ envelope.summary,
341
+ envelope.domain.value,
342
+ envelope.defect_class.value,
343
+ envelope.location.service,
344
+ envelope.location.endpoint,
345
+ envelope.location.ui_route,
346
+ json.dumps(sorted(envelope.location.paths)),
347
+ ticket_key,
348
+ now,
349
+ now,
350
+ ctx.store.run_id,
351
+ ),
352
+ )
353
+ conn.commit()
354
+ ctx.store.log(
355
+ "defect_memory", agent=ctx.agent.name, action="new",
356
+ fingerprint=fp, envelope_id=envelope.id, ticket_key=ticket_key,
357
+ )
358
+ return ok(
359
+ f"New defect recorded. Fingerprint {fp}, occurrence 1"
360
+ + (f", ticket {ticket_key}." if ticket_key else ", no ticket yet."),
361
+ fingerprint=fp,
362
+ occurrence_count=1,
363
+ ticket_key=ticket_key,
364
+ regression=False,
365
+ first_time=True,
366
+ )
367
+
368
+ was_resolved = row["resolved_at"] is not None
369
+ count = row["occurrence_count"] + 1
370
+ # resolved_at is cleared unconditionally: a defect that is back is
371
+ # open again, whatever the tracker still says.
372
+ conn.execute(
373
+ "UPDATE defects SET occurrence_count = ?, last_seen = ?, last_run_id = ?, "
374
+ "ticket_key = COALESCE(?, ticket_key), resolved_at = NULL "
375
+ "WHERE fingerprint = ?",
376
+ (count, now, ctx.store.run_id, ticket_key, fp),
377
+ )
378
+ conn.commit()
379
+ known_ticket = ticket_key or row["ticket_key"]
380
+ finally:
381
+ conn.close()
382
+
383
+ if was_resolved:
384
+ closed_under = row["resolved_ticket_key"] or row["ticket_key"] or "an earlier ticket"
385
+ ctx.store.log(
386
+ "regression", agent=ctx.agent.name, fingerprint=fp,
387
+ envelope_id=envelope.id, resolved_ticket_key=closed_under,
388
+ )
389
+ return ok(
390
+ f"REGRESSION: this is a regression of {closed_under}. The defect with "
391
+ f"fingerprint {fp} was marked resolved on {row['resolved_at']} and has come "
392
+ f"back (occurrence {count}). File it as a regression and link it to "
393
+ f"{closed_under} — do not close it as a duplicate.",
394
+ fingerprint=fp,
395
+ occurrence_count=count,
396
+ ticket_key=known_ticket,
397
+ regression=True,
398
+ regression_of=closed_under,
399
+ previously_resolved_at=row["resolved_at"],
400
+ first_time=False,
401
+ )
402
+
403
+ ctx.store.log(
404
+ "defect_memory", agent=ctx.agent.name, action="occurrence",
405
+ fingerprint=fp, envelope_id=envelope.id, occurrence_count=count,
406
+ )
407
+ return ok(
408
+ f"Known defect: occurrence {count} of fingerprint {fp}"
409
+ + (f", already tracked as {known_ticket}. Add evidence there; do not file again."
410
+ if known_ticket else ". Still unfiled."),
411
+ fingerprint=fp,
412
+ occurrence_count=count,
413
+ ticket_key=known_ticket,
414
+ regression=False,
415
+ first_time=False,
416
+ )
417
+
418
+ @tool(
419
+ "get_occurrences",
420
+ "How often this fingerprint has been seen, when, and under which ticket.",
421
+ {
422
+ "type": "object",
423
+ "required": ["fingerprint"],
424
+ "properties": {"fingerprint": {"type": "string"}},
425
+ },
426
+ )
427
+ async def get_occurrences(args: dict[str, Any]) -> dict[str, Any]:
428
+ conn = connect(root)
429
+ try:
430
+ row = conn.execute(
431
+ "SELECT * FROM defects WHERE fingerprint = ?", (args["fingerprint"],)
432
+ ).fetchone()
433
+ finally:
434
+ conn.close()
435
+ if row is None:
436
+ return err(
437
+ f"Fingerprint {args['fingerprint']} is not in defect memory. "
438
+ "Either it is genuinely new, or you have the wrong fingerprint."
439
+ )
440
+ state = "resolved" if row["resolved_at"] else "open"
441
+ return ok(
442
+ f"{row['occurrence_count']} occurrence(s), first {row['first_seen']}, "
443
+ f"last {row['last_seen']}, ticket {row['ticket_key'] or 'none'} ({state}).",
444
+ **_row_view(row),
445
+ )
446
+
447
+ @tool(
448
+ "mark_resolved",
449
+ "Mark this defect resolved. A later recurrence is then reported as a regression "
450
+ "rather than a duplicate — which is the difference between reopening and ignoring it.",
451
+ {
452
+ "type": "object",
453
+ "required": ["fingerprint"],
454
+ "properties": {
455
+ "fingerprint": {"type": "string"},
456
+ "ticket_key": {"type": "string", "description": "The ticket it was resolved under."},
457
+ },
458
+ },
459
+ )
460
+ async def mark_resolved(args: dict[str, Any]) -> dict[str, Any]:
461
+ fp = args["fingerprint"]
462
+ ticket_key = args.get("ticket_key") or None
463
+ now = _utcnow_iso()
464
+ conn = connect(root)
465
+ try:
466
+ row = conn.execute("SELECT * FROM defects WHERE fingerprint = ?", (fp,)).fetchone()
467
+ if row is None:
468
+ return err(f"Fingerprint {fp} is not in defect memory; nothing to resolve.")
469
+ resolved_under = ticket_key or row["ticket_key"]
470
+ conn.execute(
471
+ "UPDATE defects SET resolved_at = ?, resolved_ticket_key = ?, "
472
+ "ticket_key = COALESCE(?, ticket_key) WHERE fingerprint = ?",
473
+ (now, resolved_under, ticket_key, fp),
474
+ )
475
+ conn.commit()
476
+ finally:
477
+ conn.close()
478
+ ctx.store.log(
479
+ "defect_memory", agent=ctx.agent.name, action="resolved",
480
+ fingerprint=fp, ticket_key=resolved_under,
481
+ )
482
+ return ok(
483
+ f"Marked {fp} resolved under {resolved_under or 'no ticket'}. "
484
+ "If it comes back, it will be reported as a regression.",
485
+ fingerprint=fp,
486
+ resolved_at=now,
487
+ resolved_ticket_key=resolved_under,
488
+ )
489
+
490
+ return [search_similar, fingerprint, record, get_occurrences, mark_resolved]
491
+
492
+
493
+ def build(ctx: ToolContext):
494
+ """Construct the defect memory MCP server bound to one agent's run context."""
495
+ return create_sdk_mcp_server(name="defect_memory", version="1.0.0", tools=build_tools(ctx))