qaas-python 0.3.2__py3-none-any.whl → 1.0.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.
qaas/adapters/tracker.py CHANGED
@@ -19,6 +19,7 @@ import urllib.parse
19
19
  import urllib.request
20
20
  from abc import ABC, abstractmethod
21
21
  from collections.abc import Mapping
22
+ from dataclasses import dataclass, replace
22
23
  from datetime import datetime, timezone
23
24
  from pathlib import Path
24
25
  from typing import Any
@@ -469,6 +470,13 @@ def adf_to_text(node: Any) -> str:
469
470
  JIRA_API_TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens"
470
471
 
471
472
  JIRA_API_BASE = "/rest/api/3"
473
+
474
+ #: The Agile (board and sprint) API. A different base path, not a different
475
+ #: host — boards simply do not exist under `/rest/api/3`, and asking for one
476
+ #: there returns a 404 that reads like a missing board rather than a missing
477
+ #: endpoint.
478
+ JIRA_AGILE_BASE = "/rest/agile/1.0"
479
+
472
480
  JIRA_TIMEOUT_S = 30.0
473
481
  #: Reads are retried on 429; see `JiraTracker._request` for why writes are not.
474
482
  JIRA_READ_ATTEMPTS = 3
@@ -481,6 +489,12 @@ SEVERITY_LABEL_PREFIX = "severity-"
481
489
  ENVELOPE_LABEL_PREFIX = "qaas-envelope-"
482
490
  FINGERPRINT_LABEL_PREFIX = "qaas-fp-"
483
491
 
492
+ #: Every ticket a run files carries `repo-<target>`. It is what makes a
493
+ #: per-repository board possible without a per-repository *project*: the board
494
+ #: is a saved filter over this label, and creating a filter needs no
495
+ #: administrator rights while creating a project does.
496
+ REPO_LABEL_PREFIX = "repo-"
497
+
484
498
  #: House status -> the Jira workflow names it plausibly means. Jira workflows
485
499
  #: are per-project and unknowable from here, so this is a set of candidates to
486
500
  #: try, never an assertion; a miss returns the real transition list (see
@@ -534,6 +548,55 @@ def _label_safe(value: str | None, prefix: str = "") -> str | None:
534
548
  return candidate
535
549
 
536
550
 
551
+ def repo_label(slug: str | None) -> str | None:
552
+ """The label every ticket from a run against `slug` carries, or None.
553
+
554
+ None when the slug cannot survive being a Jira label at all (whitespace,
555
+ empty, too long). Returning None rather than a mangled value is deliberate:
556
+ a mangled label would never match the board filter, so the tickets would
557
+ file successfully and then be invisible on the board someone was told to
558
+ watch — the worst of the three outcomes.
559
+ """
560
+ return _label_safe(_label_slug(slug), REPO_LABEL_PREFIX)
561
+
562
+
563
+ def _label_slug(value: str | None) -> str | None:
564
+ """`My Repo.git` -> `my-repo`. The same shape `qaas init` gives a target."""
565
+ if not value:
566
+ return None
567
+ slug = re.sub(r"[^a-z0-9-]+", "-", value.strip().lower()).strip("-")[:64]
568
+ return slug or None
569
+
570
+
571
+ @dataclass(frozen=True)
572
+ class BoardInfo:
573
+ """What a per-repository board provisioning attempt produced.
574
+
575
+ `board_id` is None when Jira refused to create a board — which happens on
576
+ team-managed projects, where boards belong to the project and cannot be
577
+ made over an arbitrary filter. That is not a failure of the run: the filter
578
+ still exists, the tickets still carry the label, and `url` still points at
579
+ something a human can open. `note` says which of the two they got.
580
+ """
581
+
582
+ slug: str
583
+ label: str
584
+ jql: str
585
+ filter_id: int | None = None
586
+ filter_name: str = ""
587
+ board_id: int | None = None
588
+ board_name: str = ""
589
+ #: A link that opens. The board when there is a usable one, the filter
590
+ #: otherwise — never a constructed guess, because a link that 404s is worse
591
+ #: than no link: it reads as "the tool is broken" rather than "your Jira
592
+ #: does not do that".
593
+ url: str = ""
594
+ filter_url: str = ""
595
+ created_filter: bool = False
596
+ created_board: bool = False
597
+ note: str | None = None
598
+
599
+
537
600
  class JiraTracker(TrackerAdapter):
538
601
  """Jira Cloud, over REST API v3, with credentials from the environment.
539
602
 
@@ -603,6 +666,7 @@ class JiraTracker(TrackerAdapter):
603
666
  # environment the tracker was configured in rather than per call.
604
667
  self._opener = urllib.request.build_opener()
605
668
  self._link_type_cache: list[dict[str, Any]] | None = None
669
+ self._account_id_cache: str | None = None
606
670
 
607
671
  @classmethod
608
672
  def _missing_env_message(cls, missing: list[str]) -> str:
@@ -652,6 +716,7 @@ class JiraTracker(TrackerAdapter):
652
716
  body: dict[str, Any] | None = None,
653
717
  params: dict[str, str] | None = None,
654
718
  retry_on_429: bool = False,
719
+ api_base: str = JIRA_API_BASE,
655
720
  ) -> Any:
656
721
  """One Jira call. `retry_on_429` is only ever true for reads.
657
722
 
@@ -660,7 +725,7 @@ class JiraTracker(TrackerAdapter):
660
725
  Jira has already created the issue, so the second attempt files it
661
726
  twice. Reads are idempotent and honour `Retry-After`.
662
727
  """
663
- url = f"{self.base_url}{JIRA_API_BASE}{path}"
728
+ url = f"{self.base_url}{api_base}{path}"
664
729
  if params:
665
730
  url = f"{url}?{urllib.parse.urlencode(params)}"
666
731
  payload = json.dumps(body).encode("utf-8") if body is not None else None
@@ -924,6 +989,288 @@ class JiraTracker(TrackerAdapter):
924
989
  )
925
990
  return mapped
926
991
 
992
+ # -- boards -----------------------------------------------------------
993
+ #
994
+ # A board per repository, without a project per repository. Creating a Jira
995
+ # *project* needs administrator rights that a bot account normally does not
996
+ # have, and a project per repository is unmanageable by the tenth repo.
997
+ # Creating a saved *filter* needs no special grant, and a board can be built
998
+ # over a filter — so every repository gets its own board inside one project,
999
+ # and the tickets are separated by a label rather than by a project key.
1000
+
1001
+ def find_filter(self, name: str) -> dict[str, Any] | None:
1002
+ """A filter owned by this account with exactly this name, or None.
1003
+
1004
+ Matched on the account's own filters rather than on all visible ones:
1005
+ `filter/search` returns other people's filters too, and adopting a
1006
+ stranger's filter as the run's board would silently repoint it.
1007
+ """
1008
+ params = {"filterName": name, "expand": "jql", "maxResults": "50"}
1009
+ account = self._account_id()
1010
+ if account:
1011
+ # Omitted rather than sent empty: Jira answers an empty accountId
1012
+ # with a 400, which would read as "the filter API is broken".
1013
+ params["accountId"] = account
1014
+ data = self._request("GET", "/filter/search", params=params, retry_on_429=True)
1015
+ for entry in data.get("values") or []:
1016
+ if str(entry.get("name") or "").strip() == name:
1017
+ return entry
1018
+ return None
1019
+
1020
+ def update_filter_jql(self, filter_id: int, *, name: str, jql: str) -> dict[str, Any]:
1021
+ """Repoint an existing filter at new JQL.
1022
+
1023
+ A filter is found by name and reused, and the name does not encode the
1024
+ project. Repointing `JIRA_PROJECT_KEY` at a different project therefore
1025
+ left the filter still scoped to the old one: tickets filed correctly,
1026
+ into the new project, and did not appear on the view someone had been
1027
+ told to watch. That is the same "files fine, invisible" failure the
1028
+ `repo-` label exists to prevent, arriving by a different road.
1029
+ """
1030
+ return self._request("PUT", f"/filter/{filter_id}", body={"name": name, "jql": jql})
1031
+
1032
+ def create_filter(self, *, name: str, jql: str, description: str = "") -> dict[str, Any]:
1033
+ """A saved filter, shared with authenticated users.
1034
+
1035
+ The share permission is not optional decoration: Jira refuses to build
1036
+ a board over a private filter, and the refusal arrives as a 400 on the
1037
+ *board* call, two steps away from the cause.
1038
+ """
1039
+ return self._request(
1040
+ "POST",
1041
+ "/filter",
1042
+ body={
1043
+ "name": name,
1044
+ "jql": jql,
1045
+ "description": description,
1046
+ "favourite": True,
1047
+ "sharePermissions": [{"type": "authenticated"}],
1048
+ },
1049
+ )
1050
+
1051
+ def is_team_managed(self, key: str) -> bool:
1052
+ """Whether `key` is a team-managed (next-gen) project.
1053
+
1054
+ Team-managed projects own their boards. The Agile API will still accept
1055
+ `POST /board` over a filter and hand back an id — and the resulting
1056
+ board has no `location`, which means the Jira UI has no page for it:
1057
+ both `/jira/software/boards/<id>` and
1058
+ `/jira/software/c/projects/<KEY>/boards/<id>` answer 404. So the board
1059
+ exists, is unreachable, and the link handed to a human is broken. Ask
1060
+ first, and make only the filter.
1061
+ """
1062
+ try:
1063
+ info = self.project_info(key)
1064
+ except TrackerError:
1065
+ return False # unknown: try, and fall back on the answer
1066
+ return str(info.get("style") or "").lower() == "next-gen" or bool(info.get("simplified"))
1067
+
1068
+ def find_board(self, name: str) -> dict[str, Any] | None:
1069
+ """A board with exactly this name, or None. Agile API."""
1070
+ data = self._request(
1071
+ "GET",
1072
+ "/board",
1073
+ params={"name": name, "maxResults": "50"},
1074
+ retry_on_429=True,
1075
+ api_base=JIRA_AGILE_BASE,
1076
+ )
1077
+ for entry in data.get("values") or []:
1078
+ if str(entry.get("name") or "").strip() == name:
1079
+ return entry
1080
+ return None
1081
+
1082
+ def create_board(self, *, name: str, filter_id: int, board_type: str = "kanban") -> dict[str, Any]:
1083
+ return self._request(
1084
+ "POST",
1085
+ "/board",
1086
+ body={"name": name, "type": board_type, "filterId": filter_id},
1087
+ api_base=JIRA_AGILE_BASE,
1088
+ )
1089
+
1090
+ #: The one board URL that is never wrong. Jira redirects it to whichever
1091
+ #: canonical form this site actually uses — `/jira/software/c/projects/...`
1092
+ #: for a company-managed project, `/jira/software/projects/...` for a
1093
+ #: team-managed one, and neither for a board with no project location at
1094
+ #: all. Constructing the canonical form by hand produced a link that
1095
+ #: returned Jira's generic error page, because the guess omitted the `/c/`.
1096
+ BOARD_PATH = "/secure/RapidBoard.jspa?rapidView={id}"
1097
+
1098
+ def board_url(self, board_id: int) -> str:
1099
+ """A link to the board. Resolved with Jira rather than assembled.
1100
+
1101
+ Falls back to the redirecting form, which works everywhere but reads
1102
+ like an internal URL — worth one HTTP call to avoid handing someone a
1103
+ link they will not recognise.
1104
+ """
1105
+ redirecting = f"{self.base_url}{self.BOARD_PATH.format(id=board_id)}"
1106
+ return self._resolve_url(redirecting) or redirecting
1107
+
1108
+ def _resolve_url(self, url: str) -> str | None:
1109
+ """Where a browser would land, following Jira's own redirect. None on failure.
1110
+
1111
+ Deliberately not `_request`: this asks for a UI page, not JSON, and its
1112
+ answer is the final URL rather than the body. A failure here is never
1113
+ fatal — the caller keeps the redirecting URL, which works.
1114
+ """
1115
+ request = urllib.request.Request(
1116
+ url, method="GET", headers={**self._headers(), "Accept": "text/html"}
1117
+ )
1118
+ try:
1119
+ with self._opener.open(request, timeout=self.timeout) as response:
1120
+ resolved = response.geturl()
1121
+ except (urllib.error.URLError, TimeoutError, OSError):
1122
+ return None
1123
+ return resolved if resolved and resolved != url else None
1124
+
1125
+ def filter_url(self, filter_id: int) -> str:
1126
+ return f"{self.base_url}/issues/?filter={filter_id}"
1127
+
1128
+ def _account_id(self) -> str | None:
1129
+ """This credential's Atlassian account id, fetched once.
1130
+
1131
+ Cached because `find_filter` is called on every run and `/myself` is
1132
+ the same answer every time.
1133
+ """
1134
+ if self._account_id_cache is None:
1135
+ try:
1136
+ self._account_id_cache = str(self.whoami().get("accountId") or "")
1137
+ except TrackerError:
1138
+ self._account_id_cache = ""
1139
+ return self._account_id_cache or None
1140
+
1141
+ def ensure_repo_board(
1142
+ self,
1143
+ slug: str,
1144
+ *,
1145
+ display: str | None = None,
1146
+ project: str | None = None,
1147
+ ) -> BoardInfo:
1148
+ """Find or create the board for one repository. Idempotent.
1149
+
1150
+ Called at the top of every run, so it must be safe to call when
1151
+ everything already exists — the second run against a repository reuses
1152
+ the board rather than making `repo QA (2)`.
1153
+
1154
+ A board this could not create is reported, not raised. The run's job is
1155
+ to find defects and file them; a missing board makes the tickets harder
1156
+ to look at, and nothing else. Losing the findings over it would be the
1157
+ larger failure.
1158
+ """
1159
+ label = repo_label(slug)
1160
+ if label is None:
1161
+ raise TrackerError(
1162
+ f"'{slug}' cannot become a Jira label, so no per-repository board can be "
1163
+ "built for it. Give the target a simpler name with `qaas init --name`."
1164
+ )
1165
+ key = project or self._project
1166
+ jql = f'project = "{key}" AND labels = "{label}" ORDER BY created DESC'
1167
+ name = f"{display or _label_slug(slug)} — QA (qaas)"
1168
+
1169
+ info = BoardInfo(slug=_label_slug(slug) or slug, label=label, jql=jql)
1170
+
1171
+ existing = self.find_filter(name)
1172
+ if existing is None:
1173
+ created = self.create_filter(
1174
+ name=name,
1175
+ jql=jql,
1176
+ description=(
1177
+ f"Defects filed automatically by qaas against {slug}. "
1178
+ f"Every ticket carries the label {label}."
1179
+ ),
1180
+ )
1181
+ info = replace(info, filter_id=int(created["id"]), filter_name=name, created_filter=True)
1182
+ else:
1183
+ info = replace(info, filter_id=int(existing["id"]), filter_name=name)
1184
+ # A reused filter is only the right filter if it still asks the
1185
+ # right question. `expand=jql` on the search is what makes this
1186
+ # checkable without a second round trip.
1187
+ if str(existing.get("jql") or "").strip() != jql:
1188
+ self.update_filter_jql(info.filter_id, name=name, jql=jql)
1189
+ info = replace(info, created_filter=True)
1190
+
1191
+ filter_link = self.filter_url(info.filter_id)
1192
+ info = replace(info, url=filter_link, filter_url=filter_link)
1193
+
1194
+ if self.is_team_managed(key):
1195
+ # Not a failure and not worth attempting: the POST would succeed and
1196
+ # produce a board with no UI page. The filter is the deliverable
1197
+ # here, and it is a good one — named, starred, scoped to the label.
1198
+ return replace(
1199
+ info,
1200
+ note=(
1201
+ f"'{key}' is a team-managed project, which owns its own board and "
1202
+ "cannot have a second one built over a filter. The saved filter "
1203
+ f"'{name}' is the per-repository view instead: every ticket carries "
1204
+ f"{label}, and the link above opens exactly this repository's defects."
1205
+ ),
1206
+ )
1207
+
1208
+ board = self.find_board(name)
1209
+ if board is not None:
1210
+ return self._with_board(info, int(board["id"]), name, created=False)
1211
+
1212
+ try:
1213
+ made = self.create_board(name=name, filter_id=int(info.filter_id))
1214
+ except TrackerError as exc:
1215
+ # An account without "Create shared objects" lands here. The filter
1216
+ # is still usable, which is why this returns rather than raises.
1217
+ return replace(
1218
+ info,
1219
+ note=(
1220
+ f"Jira would not create a board over the filter ({exc}). The filter "
1221
+ f"exists and every ticket carries {label}, so open the link above, or "
1222
+ "create a board from it by hand in Jira."
1223
+ ),
1224
+ )
1225
+ return self._with_board(info, int(made["id"]), name, created=True)
1226
+
1227
+ def _with_board(self, info: BoardInfo, board_id: int, name: str, *, created: bool) -> BoardInfo:
1228
+ """Attach a board to the result — but only if the UI can actually show it.
1229
+
1230
+ `is_team_managed` is the authoritative gate and it runs before any of
1231
+ this. The `location` check below is a second, weaker one, and it is
1232
+ applied **only to a board that already existed**: Jira populates
1233
+ `location` asynchronously, so a board read back immediately after
1234
+ creation reports `location: None` whatever its project. Gating a fresh
1235
+ board on that field rejected perfectly good boards in a company-managed
1236
+ project — a false negative, observed, not theorised.
1237
+ """
1238
+ if not created and not self._board_is_reachable(board_id):
1239
+ return replace(
1240
+ info,
1241
+ board_id=board_id,
1242
+ board_name=name,
1243
+ note=(
1244
+ f"Jira created board {board_id} but gave it no project location, so it "
1245
+ "has no page in the Jira UI. The saved filter above is the working "
1246
+ "per-repository view."
1247
+ ),
1248
+ )
1249
+ return replace(
1250
+ info,
1251
+ board_id=board_id,
1252
+ board_name=name,
1253
+ url=self.board_url(board_id),
1254
+ created_board=created,
1255
+ )
1256
+
1257
+ def _board_is_reachable(self, board_id: int) -> bool:
1258
+ """Whether an existing board has a project location, and so a UI page.
1259
+
1260
+ Only meaningful for a board that has existed for a while; see
1261
+ `_with_board`. And note that a location is necessary, not sufficient —
1262
+ a team-managed project's API-made board eventually reports one and the
1263
+ UI still refuses to render it, which is why `is_team_managed` and not
1264
+ this is the real gate.
1265
+ """
1266
+ try:
1267
+ board = self._request(
1268
+ "GET", f"/board/{board_id}", retry_on_429=True, api_base=JIRA_AGILE_BASE
1269
+ )
1270
+ except TrackerError:
1271
+ return False
1272
+ return bool(board.get("location"))
1273
+
927
1274
  # -- operations -------------------------------------------------------
928
1275
 
929
1276
  @staticmethod
qaas/cli.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import re
7
7
  import subprocess
8
+ import sys
8
9
  from pathlib import Path
9
10
  from typing import Any
10
11
 
@@ -281,6 +282,21 @@ def _provision_target(
281
282
  app = typer.Typer(add_completion=False, help="Multi-agent QA & remediation system.")
282
283
  console = Console()
283
284
 
285
+
286
+ @app.callback()
287
+ def _bootstrap() -> None:
288
+ """Runs before every command. Loads credentials from `.env`, if there is one.
289
+
290
+ Credentials come from the environment and never from `config/`, which is
291
+ committed -- that rule stands. This only makes it liveable: four exports in
292
+ every new shell is how a real token ends up pasted into a config file. An
293
+ already-exported variable always wins, so nothing here can override what an
294
+ operator typed on the command line.
295
+ """
296
+ from qaas.envfile import load_env_file
297
+
298
+ load_env_file()
299
+
284
300
  #: None means "let the workspace decide" -- an explicit --config, then the
285
301
  #: project, then the defaults that shipped in the wheel. A literal "config"
286
302
  #: default meant every command outside this repo died on a missing directory.
@@ -922,6 +938,49 @@ def show(run_id: str, root: Path = Root) -> None:
922
938
  console.print(f"\n[dim]{len(entries)} ledger entries — qaas trace {run_id}[/dim]")
923
939
 
924
940
 
941
+ def _follow(store, *, agent: str | None, kinds, as_json: bool, quiet: bool = False) -> None:
942
+ """Stream a live run's ledger until it finishes or the operator stops.
943
+
944
+ Deliberately line-by-line rather than a redrawn table: a run lasts minutes,
945
+ the interesting lines are denials and verdicts, and they must survive being
946
+ scrolled past, piped, and pasted into a bug report. A `rich.Live` view that
947
+ repaints would lose all three.
948
+ """
949
+ from qaas.store import LedgerKind
950
+
951
+ wanted = set(kinds) if kinds else None
952
+ name = agent.upper() if agent else None
953
+ console.print(f"[dim]following {store.run_id} — ctrl-c to stop[/dim]")
954
+ hidden = trace_mod.QUIET_KINDS if quiet else frozenset()
955
+ seen = 0
956
+ try:
957
+ for entry in trace_mod.tail(store):
958
+ if entry.kind in hidden:
959
+ continue
960
+ if wanted is not None and entry.kind not in wanted:
961
+ continue
962
+ if name is not None and (entry.agent or "").upper() != name:
963
+ continue
964
+ seen += 1
965
+ if as_json:
966
+ # Newline-delimited, flushed per line: `--follow --json` exists
967
+ # to be piped into something that reacts, and a buffered pipe
968
+ # that only speaks at the end is not a live feed.
969
+ typer.echo(json.dumps(entry.model_dump(mode="json")), nl=True)
970
+ sys.stdout.flush()
971
+ continue
972
+ style = KIND_STYLE.get(str(entry.kind), "white")
973
+ console.print(
974
+ f"[dim]{entry.at.strftime('%H:%M:%S')}[/dim] "
975
+ f"[cyan]{(entry.agent or '-'):<12}[/cyan] "
976
+ f"[{style}]{str(entry.kind):<16}[/] {trace_mod.describe(entry)}"
977
+ )
978
+ if entry.kind == LedgerKind.RUN_FINISHED:
979
+ console.print("[dim]run finished[/dim]")
980
+ except KeyboardInterrupt:
981
+ console.print(f"\n[dim]stopped following after {seen} entries[/dim]")
982
+
983
+
925
984
  @app.command()
926
985
  def trace(
927
986
  run_id: str,
@@ -929,10 +988,12 @@ def trace(
929
988
  agent: str | None = typer.Option(None, "--agent", "-a", help="Only this agent's entries."),
930
989
  kind: list[str] = typer.Option(None, "--kind", "-k", help="Only these ledger kinds (repeatable)."),
931
990
  as_json: bool = typer.Option(False, "--json", help="Emit the filtered entries as JSON."),
991
+ follow: bool = typer.Option(False, "--follow", "-f", help="Stream new entries as the run produces them."),
992
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Drop tool_call lines and show only what an agent decided."),
932
993
  ) -> None:
933
994
  """Print one run's ledger as a timeline: dispatches, tools, denials, verdicts, cost."""
934
995
  store = RunStore(run_id, root)
935
- if not store.ledger_path.exists():
996
+ if not store.ledger_path.exists() and not follow:
936
997
  console.print(f"[red]no ledger for run {run_id}[/red] — try `qaas runs`")
937
998
  raise typer.Exit(1)
938
999
 
@@ -942,7 +1003,11 @@ def trace(
942
1003
  console.print(f"[red]{exc}[/red]")
943
1004
  raise typer.Exit(2) from None
944
1005
 
945
- entries = trace_mod.select(trace_mod.read_ledger(store), agent=agent, kinds=kinds)
1006
+ if follow:
1007
+ _follow(store, agent=agent, kinds=kinds, as_json=as_json, quiet=quiet)
1008
+ return
1009
+
1010
+ entries = trace_mod.select(trace_mod.read_ledger(store), agent=agent, kinds=kinds, quiet=quiet)
946
1011
 
947
1012
  if as_json:
948
1013
  # Plain stdout, not `console.print_json`: rich soft-wraps at the console
@@ -1097,6 +1162,10 @@ def run(
1097
1162
  console.print(f" prompt: {d['prompt_chars']} chars")
1098
1163
  return
1099
1164
 
1165
+ # Before the first agent, not after the first ticket: the board is what
1166
+ # someone watches a run *on*, and one that appears at the end is a report.
1167
+ board_info = _ensure_board(cfg)
1168
+
1100
1169
  def on_event(kind: str, detail: dict) -> None:
1101
1170
  if kind == "agent_started":
1102
1171
  console.print(f"[dim]->[/dim] {detail.get('agent')}")
@@ -1113,6 +1182,9 @@ def run(
1113
1182
 
1114
1183
  console.print()
1115
1184
  console.print_json(data=report.summary())
1185
+ console.print(f"\n[dim]watch it back:[/dim] qaas trace {report.run_id}")
1186
+ if board_info is not None and board_info.url:
1187
+ console.print(f"[dim]board:[/dim] {board_info.url}")
1116
1188
  if report.failed or report.stopped_early:
1117
1189
  raise typer.Exit(1)
1118
1190
 
@@ -1501,6 +1573,115 @@ def _tracker_check_jira(dry_run_ticket: bool) -> tuple[list[str], list[str]]:
1501
1573
  return problems, warnings
1502
1574
 
1503
1575
 
1576
+ def _ensure_board(cfg, *, create: bool = True):
1577
+ """Find or create the Jira board for this run's target. Never fatal.
1578
+
1579
+ Called at the top of every Jira-backed run so that a person told to "watch
1580
+ the board" has one to watch before the first ticket lands, rather than
1581
+ after. Returns a `BoardInfo`, or None when there is nothing to do or Jira
1582
+ could not be reached.
1583
+
1584
+ Failures are printed and swallowed. A run that found nine defects and could
1585
+ not create a board has still done its job; a run that refused to start
1586
+ because of a board has thrown the findings away.
1587
+ """
1588
+ from qaas.adapters.tracker import JiraTracker, TrackerError, repo_label
1589
+ from qaas.mcp.tracker import dry_run_enabled
1590
+
1591
+ if cfg.tracker != "jira" or not cfg.target:
1592
+ return None
1593
+
1594
+ # The rehearsal rail sends nothing. A board is a container rather than a
1595
+ # ticket, but "QAAS_TRACKER_DRY_RUN=1 wrote to my Jira" is exactly the
1596
+ # sentence that rail exists to make impossible.
1597
+ if create and dry_run_enabled():
1598
+ console.print("[dim]dry run: no board was created[/dim]")
1599
+ create = False
1600
+
1601
+ label = repo_label(cfg.target)
1602
+ if label is None:
1603
+ console.print(
1604
+ f"[yellow]note:[/yellow] target name '{cfg.target}' cannot be a Jira label, so "
1605
+ "tickets will not be grouped onto a per-repository board."
1606
+ )
1607
+ return None
1608
+
1609
+ try:
1610
+ tracker = JiraTracker()
1611
+ except TrackerError as exc:
1612
+ console.print(f"[yellow]no board:[/yellow] {exc}")
1613
+ return None
1614
+
1615
+ if not create:
1616
+ from qaas.adapters.tracker import BoardInfo
1617
+
1618
+ return BoardInfo(
1619
+ slug=cfg.target,
1620
+ label=label,
1621
+ jql=f'project = "{tracker.default_project}" AND labels = "{label}" ORDER BY created DESC',
1622
+ )
1623
+
1624
+ try:
1625
+ info = tracker.ensure_repo_board(cfg.target)
1626
+ except TrackerError as exc:
1627
+ console.print(f"[yellow]could not provision a board:[/yellow] {exc}")
1628
+ return None
1629
+
1630
+ what = "board" if info.board_id and info.url != info.filter_url else "filter"
1631
+ verb = "created" if (info.created_board or info.created_filter) else "reused"
1632
+ console.print(f"[bold]{what} {verb}[/bold] — {info.url}")
1633
+ console.print(f"[dim]every ticket from this run carries the label {info.label}[/dim]")
1634
+ if info.note:
1635
+ console.print(f"[dim]{info.note}[/dim]")
1636
+ return info
1637
+
1638
+
1639
+ @app.command()
1640
+ def board(
1641
+ config_dir: Path | None = ConfigDir,
1642
+ target: str = typer.Option(None, "--target", "-t", help="Target profile. Defaults to the configured one."),
1643
+ create: bool = typer.Option(True, "--create/--no-create", help="Create the filter and board if they are missing."),
1644
+ ) -> None:
1645
+ """Show — or create — the Jira board that collects this repository's defects.
1646
+
1647
+ One board per repository, inside one Jira project. The board is a saved
1648
+ filter over the label `repo-<target>`, which every ticket the system files
1649
+ carries. That is why it needs no administrator rights: creating a Jira
1650
+ *project* per repository does, creating a filter does not.
1651
+ """
1652
+ cfg = load_config(config_dir, target=target)
1653
+ if target:
1654
+ cfg = cfg.model_copy(update={"target": target, "profile": _load_target(target, config_dir)})
1655
+
1656
+ if cfg.tracker != "jira":
1657
+ console.print(
1658
+ f"[yellow]tracker is '{cfg.tracker}', not 'jira'[/yellow] — boards are a Jira "
1659
+ "feature. Tickets are written to .qaas/tickets/ instead. Set QAAS_TRACKER=jira "
1660
+ "(and the JIRA_* variables) to file into Jira."
1661
+ )
1662
+ raise typer.Exit(1)
1663
+ if not cfg.target:
1664
+ console.print("[red]no target configured[/red] — run `qaas init <repo>` first.")
1665
+ raise typer.Exit(1)
1666
+
1667
+ info = _ensure_board(cfg, create=create)
1668
+ if info is None:
1669
+ raise typer.Exit(1)
1670
+
1671
+ table = Table(header_style="bold", box=None, pad_edge=False)
1672
+ table.add_column("field", style="dim")
1673
+ table.add_column("value", overflow="fold")
1674
+ table.add_row("target", cfg.target)
1675
+ table.add_row("label", info.label)
1676
+ table.add_row("jql", info.jql)
1677
+ table.add_row("filter", str(info.filter_id or "not created"))
1678
+ table.add_row("board", str(info.board_id or "not created"))
1679
+ table.add_row("url", info.url or "-")
1680
+ console.print(table)
1681
+ if info.note:
1682
+ console.print(f"\n[dim]{info.note}[/dim]")
1683
+
1684
+
1504
1685
  @app.command("tracker-check")
1505
1686
  def tracker_check(
1506
1687
  config_dir: Path | None = ConfigDir,
qaas/discover.py CHANGED
@@ -42,6 +42,12 @@ FRONTEND_HINTS = re.compile(r'"(react|vue|svelte|@angular/core|next|nuxt|solid-j
42
42
  TEST_DIR_NAMES = {"tests", "test", "__tests__", "spec", "e2e", "integration_tests"}
43
43
  MIGRATION_DIR_NAMES = {"migrations", "migrate", "alembic", "db/migrate", "prisma/migrations"}
44
44
 
45
+ #: `Layout.docs` existed and nothing ever filled it, so a documentation-heavy
46
+ #: repository profiled as having no documentation at all and KEYSTONE was told
47
+ #: to go and find it. Prose is a real surface: specs that contradict the code,
48
+ #: tickets that describe behaviour nobody built, standards nothing follows.
49
+ DOC_DIR_NAMES = {"docs", "doc", "documentation", "adr", "rfcs", "specs"}
50
+
45
51
 
46
52
  @dataclass
47
53
  class Discovery:
@@ -101,6 +107,7 @@ def inspect(root: Path) -> Discovery:
101
107
  frontend: list[str] = []
102
108
  tests: list[str] = []
103
109
  migrations: list[str] = []
110
+ docs: list[str] = []
104
111
 
105
112
  for directory, children in _walk(root, excludes):
106
113
  names = {c.name for c in children}
@@ -128,6 +135,13 @@ def inspect(root: Path) -> Discovery:
128
135
  tests.append(rel)
129
136
  if directory.name in MIGRATION_DIR_NAMES and rel not in migrations:
130
137
  migrations.append(rel)
138
+ # Only the top of a documentation tree: `docs` and `docs/specs` and
139
+ # `docs/tickets` are one surface, and listing all three says nothing
140
+ # the first does not.
141
+ if directory.name in DOC_DIR_NAMES and not any(
142
+ rel == d or rel.startswith(f"{d}/") for d in docs
143
+ ):
144
+ docs.append(rel)
131
145
 
132
146
  # A source directory with .tsx/.jsx in it is a frontend even without a
133
147
  # package.json of its own — monorepos often hoist dependencies.
@@ -181,6 +195,7 @@ def inspect(root: Path) -> Discovery:
181
195
  frontend=sorted(set(frontend))[:4],
182
196
  tests=sorted(set(tests))[:4],
183
197
  migrations=sorted(set(migrations))[:3],
198
+ docs=sorted(set(docs))[:4],
184
199
  spec=spec,
185
200
  ownership=ownership,
186
201
  exclude=list(DEFAULT_EXCLUDES),
qaas/envfile.py ADDED
@@ -0,0 +1,92 @@
1
+ """Reading credentials out of a `.env` file, without a dependency.
2
+
3
+ Every credential this system needs is an environment variable, deliberately:
4
+ `config/` is committed, and a token in a committed file is a token that leaks.
5
+ That rule is right and it made the tool tedious to use — four exports in every
6
+ new shell, and a run that dies on the fourth because one was forgotten.
7
+
8
+ So: a `.env` next to the project, read once, at CLI start. Two rules keep it
9
+ from becoming a second configuration system:
10
+
11
+ * **The real environment always wins.** A value already exported is never
12
+ overwritten, so `JIRA_PROJECT_KEY=OTHER qaas run` still means what it says,
13
+ and a stale `.env` cannot silently redirect a run.
14
+ * **It is only ever read for credentials.** Nothing in `config/` is looked up
15
+ here. A `.env` that sets `QAAS_TRACKER` works because that is an environment
16
+ override that already existed, not because this file is config.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from pathlib import Path
23
+
24
+ #: Where to look, in order. The state directory first, because `.qaas/` is
25
+ #: already gitignored — a credential written there cannot be committed by
26
+ #: accident, which is not true of a `.env` at the root of someone's repository.
27
+ CANDIDATES = (".qaas/.env", ".env")
28
+
29
+ #: Overrides the search. A path names the file to read; an empty value turns
30
+ #: the whole mechanism off. Both are needed by real callers: CI passes real
31
+ #: environment variables and must not have a stray `.env` in a checkout
32
+ #: override them, and the test suite must be hermetic against whatever the
33
+ #: developer happens to have on disk.
34
+ ENV_FILE_VAR = "QAAS_ENV_FILE"
35
+
36
+
37
+ def parse_env(text: str) -> dict[str, str]:
38
+ """`KEY=value` lines to a dict. Comments, blanks and `export ` tolerated.
39
+
40
+ Quotes are stripped only when they wrap the whole value: a token that
41
+ genuinely contains a quote character is more likely than a caller who meant
42
+ to keep the wrapping ones.
43
+ """
44
+ values: dict[str, str] = {}
45
+ for raw in text.splitlines():
46
+ line = raw.strip()
47
+ if not line or line.startswith("#") or "=" not in line:
48
+ continue
49
+ if line.startswith("export "):
50
+ line = line[len("export ") :].lstrip()
51
+ key, _, value = line.partition("=")
52
+ key = key.strip()
53
+ if not key:
54
+ continue
55
+ value = value.strip()
56
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
57
+ value = value[1:-1]
58
+ values[key] = value
59
+ return values
60
+
61
+
62
+ def load_env_file(start: Path | str | None = None) -> tuple[Path | None, list[str]]:
63
+ """Load the first `.env` found, without clobbering the real environment.
64
+
65
+ Returns `(path, names_set)` — the file that was used and the variables it
66
+ actually contributed. Names already present in the environment are reported
67
+ as not set by the file, because that is the fact an operator debugging a
68
+ wrong project key needs.
69
+ """
70
+ override = os.environ.get(ENV_FILE_VAR)
71
+ if override is not None and not override.strip():
72
+ return None, []
73
+
74
+ base = Path(start).expanduser() if start else Path.cwd()
75
+ candidates = (Path(override).expanduser(),) if override else tuple(base / n for n in CANDIDATES)
76
+ for path in candidates:
77
+ if not path.is_file():
78
+ continue
79
+ try:
80
+ values = parse_env(path.read_text(encoding="utf-8", errors="replace"))
81
+ except OSError:
82
+ # An unreadable .env is not worth killing a command over; the
83
+ # missing variable will produce a far clearer error downstream.
84
+ return None, []
85
+ applied = []
86
+ for key, value in values.items():
87
+ if os.environ.get(key):
88
+ continue
89
+ os.environ[key] = value
90
+ applied.append(key)
91
+ return path, applied
92
+ return None, []
qaas/mcp/tracker.py CHANGED
@@ -29,6 +29,7 @@ from qaas.adapters.tracker import (
29
29
  TrackerError,
30
30
  build_tracker,
31
31
  issue_summary,
32
+ repo_label,
32
33
  )
33
34
  from qaas.envelope import DefectClass, DefectEnvelope
34
35
  from qaas.mcp.context import ToolContext, err, ok
@@ -202,6 +203,13 @@ def build_tools(ctx: ToolContext) -> list:
202
203
  labels.append("agent-found")
203
204
  if restricted and "security" not in labels:
204
205
  labels.append("security")
206
+ # Which repository this defect is in, stamped by the system rather than
207
+ # asked of the agent. It is the only thing that puts the ticket on that
208
+ # repository's board, and an agent that forgot it would file a ticket
209
+ # that exists and is invisible to the person watching.
210
+ repo = repo_label(ctx.config.target)
211
+ if repo and repo not in labels:
212
+ labels.append(repo)
205
213
 
206
214
  if dry_run:
207
215
  # The cap is still consumed: a rehearsal that ignores the rate limit
qaas/target.py CHANGED
@@ -52,6 +52,7 @@ class Layout(BaseModel):
52
52
  for label, paths in (
53
53
  ("backend", self.backend), ("frontend", self.frontend),
54
54
  ("tests", self.tests), ("migrations", self.migrations),
55
+ ("docs", self.docs),
55
56
  ):
56
57
  if paths:
57
58
  bits.append(f"{label}: {', '.join(paths)}")
qaas/trace.py CHANGED
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
 
18
18
  from dataclasses import dataclass, field
19
19
  from datetime import datetime
20
- from typing import Any, Iterable, Sequence
20
+ from typing import Any, Iterable, Iterator, Sequence
21
21
 
22
22
  from qaas.store import LedgerEntry, LedgerKind, RunStore
23
23
 
@@ -66,6 +66,68 @@ def read_ledger(store: RunStore) -> list[LedgerEntry]:
66
66
  return list(store.ledger())
67
67
 
68
68
 
69
+ #: How often `tail` looks for new ledger lines. A run writes a line every few
70
+ #: seconds at most, so polling faster buys nothing and spins a CPU; polling
71
+ #: slower makes `--follow` feel broken while an agent is thinking.
72
+ POLL_INTERVAL_S = 0.5
73
+
74
+
75
+ def tail(
76
+ store: RunStore,
77
+ *,
78
+ from_start: bool = True,
79
+ poll: float = POLL_INTERVAL_S,
80
+ stop_on_finish: bool = True,
81
+ timeout_s: float | None = None,
82
+ ) -> Iterator[LedgerEntry]:
83
+ """Yield ledger entries as they are appended, for `qaas trace --follow`.
84
+
85
+ The ledger is append-only, which is what makes this safe: a reader can hold
86
+ a byte offset and never be wrong about it. Two rules follow from that and
87
+ both matter.
88
+
89
+ Only whole lines are parsed. A run can be mid-`write` when this reads, and
90
+ half a JSON object is not an entry -- the offset advances to the last
91
+ newline, so the remainder is picked up on the next poll rather than raising.
92
+
93
+ The file may not exist yet. Following a run that is still starting is the
94
+ normal case, not an error, so a missing ledger is waited for.
95
+ """
96
+ import time
97
+
98
+ deadline = None if timeout_s is None else time.monotonic() + timeout_s
99
+ offset = 0
100
+ if not from_start and store.ledger_path.exists():
101
+ offset = store.ledger_path.stat().st_size
102
+ pending = ""
103
+
104
+ while True:
105
+ if store.ledger_path.exists():
106
+ with store.ledger_path.open("r", encoding="utf-8", errors="replace") as handle:
107
+ handle.seek(offset)
108
+ chunk = handle.read()
109
+ offset = handle.tell()
110
+ pending += chunk
111
+ lines = pending.split("\n")
112
+ pending = lines.pop() # the tail with no newline yet: not an entry
113
+ for line in lines:
114
+ if not line.strip():
115
+ continue
116
+ try:
117
+ entry = LedgerEntry.model_validate_json(line)
118
+ except Exception:
119
+ # A line this reader cannot parse is a line a future version
120
+ # wrote. Skipping it keeps the follow alive; killing the
121
+ # view over one unknown entry would not.
122
+ continue
123
+ yield entry
124
+ if stop_on_finish and entry.kind == LedgerKind.RUN_FINISHED:
125
+ return
126
+ if deadline is not None and time.monotonic() >= deadline:
127
+ return
128
+ time.sleep(poll)
129
+
130
+
69
131
  def parse_kinds(names: Iterable[str]) -> list[LedgerKind]:
70
132
  """Validate `--kind` arguments against the enum.
71
133
 
@@ -83,18 +145,28 @@ def parse_kinds(names: Iterable[str]) -> list[LedgerKind]:
83
145
  return kinds
84
146
 
85
147
 
148
+ #: What `--quiet` drops. A real run logs ~2400 `tool_call` lines against ~150 of
149
+ #: everything else, and every one of them is an agent reading a file. Dropping
150
+ #: them leaves what an agent *decided*: what it found, what it was refused, what
151
+ #: it filed, what it verdicted. Never dropped when asked for by `--kind`.
152
+ QUIET_KINDS = frozenset({LedgerKind.TOOL_CALL, LedgerKind.DRY_RUN})
153
+
154
+
86
155
  def select(
87
156
  entries: Sequence[LedgerEntry],
88
157
  *,
89
158
  agent: str | None = None,
90
159
  kinds: Sequence[LedgerKind] | None = None,
160
+ quiet: bool = False,
91
161
  ) -> list[LedgerEntry]:
92
162
  """Filter in memory. Agent match is case-insensitive; agent names are shouted."""
93
163
  wanted = set(kinds) if kinds else None
94
164
  name = agent.upper() if agent else None
165
+ hidden = QUIET_KINDS if quiet else frozenset()
95
166
  return [
96
167
  e for e in entries
97
- if (wanted is None or e.kind in wanted)
168
+ if e.kind not in hidden
169
+ and (wanted is None or e.kind in wanted)
98
170
  and (name is None or (e.agent or "").upper() == name)
99
171
  ]
100
172
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qaas-python
3
- Version: 0.3.2
3
+ Version: 1.0.0
4
4
  Summary: A multi-agent QA system: finds real defects, reproduces them, files tickets, fixes them, and proves the fix
5
5
  Project-URL: Homepage, https://github.com/allaabdella2-us/qa-multi-agent-system
6
6
  Project-URL: Repository, https://github.com/allaabdella2-us/qa-multi-agent-system
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
  [![Python](https://img.shields.io/pypi/pyversions/qaas-python?color=3776AB&logo=python&logoColor=white)](https://pypi.org/project/qaas-python/)
41
41
  [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
42
42
  [![CI](https://github.com/allaabdella2-us/qa-multi-agent-system/actions/workflows/ci.yml/badge.svg)](https://github.com/allaabdella2-us/qa-multi-agent-system/actions/workflows/ci.yml)
43
- [![Tests](https://img.shields.io/badge/tests-649%20offline-success)](#-contributing)
43
+ [![Tests](https://img.shields.io/badge/tests-686%20offline-success)](#-contributing)
44
44
  [![Built on](https://img.shields.io/badge/built%20on-Claude%20Agent%20SDK-D97757)](https://docs.claude.com/en/api/agent-sdk/overview)
45
45
 
46
46
  [Quickstart](#-quickstart-in-60-seconds) · [Your repo](#-point-it-at-your-repository) · [Jira](#-file-into-jira) · [Architecture](ARCHITECTURE.md)
@@ -51,7 +51,7 @@ Description-Content-Type: text/markdown
51
51
 
52
52
  Most "AI QA" tools generate tests. **This one behaves like a QA team.**
53
53
 
54
- Ten agents, each with its own context, tool allowlist and budget, coordinated by
54
+ Fifteen agents, each with its own context, tool allowlist and budget, coordinated by
55
55
  a state machine that is ordinary Python — because a model cannot enforce a budget
56
56
  it is itself spending.
57
57
 
@@ -75,7 +75,7 @@ Nothing crosses between the loops except a ticket — which is also the audit tr
75
75
 
76
76
  | | |
77
77
  |---|---|
78
- | 🧠 **The orchestrator is code, not a prompt** | A model cannot enforce a budget it is spending. Phase ordering, concurrency, retries and the loop breakers live in `conductor.py`. That is also why **649 tests run offline, free, with no API key.** |
78
+ | 🧠 **The orchestrator is code, not a prompt** | A model cannot enforce a budget it is spending. Phase ordering, concurrency, retries and the loop breakers live in `conductor.py`. That is also why **686 tests run offline, free, with no API key.** |
79
79
  | 🧱 **Every agent is its own `query()`** | Not subagents of a shared parent. Each gets a real context boundary, an enforceable tool allowlist, and its own cost number. |
80
80
  | 🔬 **Evidence or it did not happen** | `has_evidence()` and `is_fileable()` are methods on the envelope model, not requests in a prompt. An agent cannot talk its way past them. |
81
81
  | 📊 **Measured, not asserted** | A deliberately buggy demo app ships with a golden ledger of **16 seeded defects + 4 planted non-defects**. `qaas score` reports recall *and* precision, so a prompt change has a number attached. |
@@ -189,6 +189,10 @@ QAAS_TRACKER=jira qaas run --mode nightly
189
189
  maps your workflow statuses, and prints the exact JSON it *would* POST. **It
190
190
  creates nothing.**
191
191
 
192
+ Tired of four exports in every new shell? Put them in a `.env` — `.qaas/.env` is
193
+ already gitignored — and every command reads it. **Anything you export wins over
194
+ the file**, so a stale `.env` can never redirect a run.
195
+
192
196
  - 🔁 **Dedupe across runs** — tickets carry a `qaas-fp-<fingerprint>` label, so the next run recognises an already-filed defect and increments its occurrence count instead of filing again.
193
197
  - 🔐 **Security findings are refused** unless `JIRA_SECURITY_PROJECT_KEY` is set. A vulnerability in a project the whole company can read is a disclosure with no undo.
194
198
 
@@ -197,6 +201,46 @@ creates nothing.**
197
201
  > `.qaas/tickets/` so you can read what *would* be filed. Switch per shell with
198
202
  > `QAAS_TRACKER=jira`.
199
203
 
204
+ ### 📌 A view per repository, made for you
205
+
206
+ Point it at a new repository and it provisions that repository's own Jira view
207
+ before the first agent starts — so there is something to watch *during* the run,
208
+ not a report afterwards.
209
+
210
+ ```console
211
+ $ QAAS_TRACKER=jira qaas run --repo https://github.com/acme/checkout.git --mode nightly
212
+ target: checkout (none)
213
+ filter created — https://you.atlassian.net/issues/?filter=10001
214
+ every ticket from this run carries the label repo-checkout
215
+ ```
216
+
217
+ Every ticket the system files carries `repo-<target>`, stamped in code rather
218
+ than asked of an agent. A **saved filter** over exactly that label is the
219
+ per-repository view, and on a company-managed project a **board** is built over
220
+ the filter too.
221
+
222
+ ```bash
223
+ qaas board # find or create this target's view
224
+ qaas board --no-create # show the label and JQL, touch nothing
225
+ ```
226
+
227
+ > [!NOTE]
228
+ > **Not a project per repository.** Creating a Jira project needs administrator
229
+ > rights a bot account rarely has, and a project per repository is unmanageable
230
+ > by the tenth one. A filter needs no special grant.
231
+ >
232
+ > **Not always a board, either.** Team-managed (next-gen) projects own their own
233
+ > board and cannot have a second one built over a filter — Jira's API will
234
+ > happily create one and give it no page in the UI. So the project's style is
235
+ > checked first, and on a team-managed project you get the filter alone. You are
236
+ > told which you got, and the link always opens. Point `JIRA_PROJECT_KEY` at a
237
+ > **company-managed** project and you get a real board per repository, with
238
+ > To Do / In Progress / Done.
239
+
240
+ Run it twice on the same repository and it **reuses** what is there. A run is
241
+ never failed over this: a run that found nine defects and could not make a view
242
+ has still done its job.
243
+
200
244
  ---
201
245
 
202
246
  ## 🔌 Bring your own MCP servers
@@ -281,12 +325,31 @@ $ qaas trace run-20260908T182034-c6ed26
281
325
  ```
282
326
 
283
327
  ```bash
328
+ qaas trace <run-id> --follow # watch a run as it happens
329
+ qaas trace <run-id> --quiet # decisions only, no file reads
284
330
  qaas trace <run-id> --agent proof --kind verdict # filter
285
331
  qaas trace <run-id> --json # export
286
332
  qaas show <run-id> # mode, commit, tickets, escalations
287
333
  qaas runs # everything that ever ran
288
334
  ```
289
335
 
336
+ **Watch a run live.** `--follow` tails the ledger of a run in progress — start it
337
+ in a second terminal the moment a run begins, or even before, and it waits.
338
+
339
+ ```console
340
+ $ qaas trace run-20260909T163240-83b11c --follow --quiet
341
+ following run-20260909T163240-83b11c — ctrl-c to stop
342
+ 16:32:40 - run_started mode=pr-check agents=[8] target_sha=da19f406
343
+ 16:32:40 CARTOGRAPHER agent_started model=claude-sonnet-5
344
+ 16:32:46 CARTOGRAPHER denial tool=Bash reason=Bash is not in CARTOGRAPHER's
345
+ tool allowlist (Read, Grep, Glob).
346
+ 16:36:11 KEYSTONE envelope severity=major domain=architecture
347
+ 16:41:03 CLERK ticket action=created key=QA-118 severity=major
348
+ ```
349
+
350
+ Drop `--quiet` to see every file the agents read, one line each. Combine with
351
+ `--agent` and `--kind` to watch one agent, or only the verdicts.
352
+
290
353
  Runs are pinned to the **commit of the target** they examined, so a finding can
291
354
  be replayed against the tree that produced it.
292
355
 
@@ -335,18 +398,6 @@ than an opinion.
335
398
 
336
399
  ---
337
400
 
338
- ## 📋 Status
339
-
340
- Honest about what exists:
341
-
342
- - ✅ **All 16 agents in the design are built.**
343
- - ✅ **Adding one needs a prompt file and a YAML file — no Python.** Six were added that way, which is how the claim got tested.
344
- - ✅ The fix loop has closed end to end on a real defect: `NOT_FIXED → MENDER → ARBITER APPROVE → VERIFIED`.
345
- - ✅ 30 skills, 7 in-process MCP servers, 649 offline tests.
346
- - ⚠️ Running the bundled demo needs `export CORVID_PASSWORD=password123` — credentials come from the environment, including the demo's.
347
-
348
- ---
349
-
350
401
  ## 🤝 Contributing
351
402
 
352
403
  ```bash
@@ -354,7 +405,7 @@ git clone https://github.com/allaabdella2-us/qa-multi-agent-system
354
405
  cd qa-multi-agent-system
355
406
  uv venv && uv pip install -e ".[dev]"
356
407
 
357
- pytest # 649 tests, offline, free — keep it that way
408
+ pytest # 686 tests, offline, free — keep it that way
358
409
  pytest -m docker # needs: cd target-app && docker compose up -d
359
410
  qaas validate
360
411
  ```
@@ -1,8 +1,9 @@
1
- qaas/cli.py,sha256=JFCTa5rRK9i6rIyiBhofT4Jr31FdrDDOEFnbnTRCCUo,64939
1
+ qaas/cli.py,sha256=cmbO2PGpQbv0RwAaAh0_3k3h6uMNIqnAd09cYTnREM0,72613
2
2
  qaas/conductor.py,sha256=Vl71d4JAL8Fpk0swSnDnPALR5Pq2oHiftb8S6hdgATc,25855
3
3
  qaas/config.py,sha256=bYJuErdUutD6oFAMIFPtskShQtwol5hLKkqYA2Q8Gmo,17545
4
- qaas/discover.py,sha256=L5ejBsYs_s41OWAL-Dhc8VlQ5EWw2hGayWTDzfj05R8,8524
4
+ qaas/discover.py,sha256=I_SYo9rPjwrJ_JOyD8vkuQrIlnjVQk2InZX3zJIQI58,9316
5
5
  qaas/envelope.py,sha256=IiqyOy96CHZw2A0pSDWBNIg6yQ2MzfcwkpQWmgMNvzY,9095
6
+ qaas/envfile.py,sha256=TArHLDbt7qx04c6VRIUa8CTZtL0KIvG_UnzCLaxPV58,3867
6
7
  qaas/guardrails.py,sha256=4JIQGFnQpQeIU92c2_E7PQFwXBnQnzolurwp6Zf-Cnk,18797
7
8
  qaas/paths.py,sha256=NdW0kDqLXBSASIhp5xFSmMijYPU5tNknKdLj1AUp2to,12703
8
9
  qaas/registry.py,sha256=V6-405Ka-ntRMhFHwMeMKq1jlrLqwiore_CKGBBaytw,20770
@@ -10,11 +11,11 @@ qaas/runner.py,sha256=ED0arzgx0_Y6R_mcG6OXqzh3GeSOpqVUNa4NJ0QcRk8,7417
10
11
  qaas/scorecard.py,sha256=aUU7g5OtXIW4752A4NxpIZH-OdC8bpNtzJUvCi41JYg,15809
11
12
  qaas/sdk_compat.py,sha256=ftE6PK0jZY85zkYqS7UJTGYwRkYDlH4NspiKbKVfVXQ,1678
12
13
  qaas/store.py,sha256=aDJmeCog17zo_Dw2Pw_JHTcWnDQlDW3QKzNnUttVvg8,11038
13
- qaas/target.py,sha256=rXYIuVqi1csh0Ox7SWD1yRNRIgG2X5h9Dmctg9uTKiM,11726
14
+ qaas/target.py,sha256=LUXChBZeYoLRUATqwTasxP4c8AGEXPWxhlZSBDgVss8,11759
14
15
  qaas/tasks.py,sha256=6ZyxHs1zAFY5cz-a9hkt8_3ns4Y1zSRysvOYKoM33gI,18666
15
- qaas/trace.py,sha256=V-uFqh1iCYRqgWL3VzDbRdHgAkwLsr1uRFfwwycWZ94,11461
16
+ qaas/trace.py,sha256=kGNgaggD9ibDu27upo5l-98JpMQXVQ_gXF6Vvhx-gTo,14398
16
17
  qaas/adapters/__init__.py,sha256=bw2pqtDqhZGP730gwV28BJ-8TF-rhanwCEjdIizjP6A,882
17
- qaas/adapters/tracker.py,sha256=K1U7weiA_K2MM58yji3WQn3PEATVqDD9_qcRbrcZwik,54348
18
+ qaas/adapters/tracker.py,sha256=ECnV7On_Tf3kVGVK9N0YSJOFozEueR8E0xv-qKf1GRM,69985
18
19
  qaas/adapters/vcs.py,sha256=9su-4QLLxR6yTyLZWCJAky9BROJci80M5jV6nGo6pjg,19430
19
20
  qaas/defaults/config/system.yaml,sha256=dweciSWteCpO6C9tOEYwbrP8va4FDjp5eQDLNJdE4y8,2745
20
21
  qaas/defaults/config/agents/arbiter.yaml,sha256=nRajKdxgis7YnJq6SOJ6t6Ics2SdF67D5rHfWjQjAUo,719
@@ -39,7 +40,7 @@ qaas/mcp/defect_memory.py,sha256=IFs6s0zYjUv6KvCaaXNgnQn9HAl8y-zDFVCxeWkEwuM,198
39
40
  qaas/mcp/env_control.py,sha256=w19W9KHBqxFidi_et0Z5V8OGy1_b6uTCxUedWEIbk7E,38442
40
41
  qaas/mcp/envelope_server.py,sha256=RErNtlt1_SddQNBrtc8rttZu6oVMGgZFC4DqOK9_5Dc,20371
41
42
  qaas/mcp/test_runner.py,sha256=QysNYAkHoukJH13nroHLAnt19-m5fiRzvhwfWlMRCg0,30562
42
- qaas/mcp/tracker.py,sha256=WjlH_cnY50EbXTqo827qBFj820TjoA25fAcpDXuhrCc,18199
43
+ qaas/mcp/tracker.py,sha256=llCt96fl-KRcqZ_3AmcCrp-1qJ8MQCLtjARi6KQ0pWA,18633
43
44
  qaas/mcp/vcs.py,sha256=oo9uWZrfFyFy6bYELe_8DTWubhI4VrcfrO3nkT27-i4,20757
44
45
  qaas/plugin/.claude-plugin/plugin.json,sha256=XbzYID6aMD3dVPvJxOP9X_0iJR5isqIfKgGkZmn38BU,263
45
46
  qaas/plugin/skills/a11y-audit/SKILL.md,sha256=hTpKJkKTYqxkl2kQRqHrsKU1YfhntilzCykkToBnWyY,2634
@@ -88,8 +89,8 @@ qaas/prompts/USHER.md,sha256=HfJxJMQym81qHHMy3Tyv5YeeTbvU9iTFWCMgeasyzpM,5144
88
89
  qaas/prompts/VAULT.md,sha256=Ansowimx-wMbinQkn9_gGgZFmvmMFMc9v_N8s2Ahws4,2915
89
90
  qaas/prompts/WARDEN.md,sha256=39KsbwWRwe7mtjYEik1owBIDgu4qyD52UHG_T_7gTMw,2950
90
91
  qaas/prompts/_shared.md,sha256=lN_s_rAmakhGuyRuWItC--iyy-Er6ohT_dzGeE_g-fo,2620
91
- qaas_python-0.3.2.dist-info/METADATA,sha256=IgEwLQXmnu1Qs68t5hXgac4qmsuN1mJLvulKj0VzQr8,16455
92
- qaas_python-0.3.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
93
- qaas_python-0.3.2.dist-info/entry_points.txt,sha256=6UScfruyhP9N_xGx3tXJGkaoAiB36dkINuKyOH6OkK4,38
94
- qaas_python-0.3.2.dist-info/licenses/LICENSE,sha256=pHWke5oMtv7PLjIQbN6hRa31J0AKj51VCd5TCTUbbX0,1069
95
- qaas_python-0.3.2.dist-info/RECORD,,
92
+ qaas_python-1.0.0.dist-info/METADATA,sha256=gxoKbbNSwX_zLyX34GHrwIiAq25xpT-bke8iQnxpiWA,18985
93
+ qaas_python-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
94
+ qaas_python-1.0.0.dist-info/entry_points.txt,sha256=6UScfruyhP9N_xGx3tXJGkaoAiB36dkINuKyOH6OkK4,38
95
+ qaas_python-1.0.0.dist-info/licenses/LICENSE,sha256=pHWke5oMtv7PLjIQbN6hRa31J0AKj51VCd5TCTUbbX0,1069
96
+ qaas_python-1.0.0.dist-info/RECORD,,