qaas-python 1.0.0__py3-none-any.whl → 2.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.
Files changed (70) hide show
  1. qaas/adapters/tracker.py +96 -10
  2. qaas/adapters/vcs.py +80 -19
  3. qaas/cli.py +52 -31
  4. qaas/config.py +5 -5
  5. qaas/defaults/config/agents/{conduit.yaml → api.yaml} +2 -2
  6. qaas/defaults/config/agents/{keystone.yaml → architect.yaml} +3 -3
  7. qaas/defaults/config/agents/{warden.yaml → auditor.yaml} +3 -3
  8. qaas/defaults/config/agents/{surface.yaml → browser.yaml} +2 -2
  9. qaas/defaults/config/agents/{vault.yaml → dba.yaml} +2 -2
  10. qaas/defaults/config/agents/{mender.yaml → fixer.yaml} +3 -3
  11. qaas/defaults/config/agents/{usher.yaml → guide.yaml} +3 -3
  12. qaas/defaults/config/agents/{gauge.yaml → load.yaml} +2 -2
  13. qaas/defaults/config/agents/{cartographer.yaml → mapper.yaml} +2 -2
  14. qaas/defaults/config/agents/{chronicle.yaml → reporter.yaml} +2 -2
  15. qaas/defaults/config/agents/{forge.yaml → reproducer.yaml} +3 -3
  16. qaas/defaults/config/agents/{arbiter.yaml → reviewer.yaml} +4 -4
  17. qaas/defaults/config/agents/{pulse.yaml → socket.yaml} +4 -4
  18. qaas/defaults/config/agents/{clerk.yaml → triage.yaml} +2 -2
  19. qaas/defaults/config/agents/{proof.yaml → verifier.yaml} +2 -2
  20. qaas/defaults/config/system.yaml +10 -10
  21. qaas/discover.py +6 -6
  22. qaas/envelope.py +31 -3
  23. qaas/envfile.py +9 -1
  24. qaas/guardrails.py +174 -16
  25. qaas/mcp/context.py +11 -3
  26. qaas/mcp/contract_diff.py +86 -12
  27. qaas/mcp/env_control.py +27 -7
  28. qaas/mcp/envelope_server.py +26 -26
  29. qaas/mcp/test_runner.py +76 -7
  30. qaas/mcp/tracker.py +5 -5
  31. qaas/mcp/vcs.py +29 -34
  32. qaas/paths.py +2 -2
  33. qaas/plugin/skills/adversarial-review/SKILL.md +5 -5
  34. qaas/plugin/skills/environment-pinning/SKILL.md +1 -1
  35. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +3 -3
  36. qaas/plugin/skills/regression-risk-scoring/SKILL.md +2 -2
  37. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +1 -1
  38. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +6 -6
  39. qaas/plugin/skills/test-first-fix/SKILL.md +1 -1
  40. qaas/plugin/skills/test-quality-audit/SKILL.md +3 -3
  41. qaas/plugin/skills/ticket-writer/SKILL.md +1 -1
  42. qaas/prompts/{CONDUIT.md → API.md} +2 -2
  43. qaas/prompts/{KEYSTONE.md → ARCHITECT.md} +5 -5
  44. qaas/prompts/{WARDEN.md → AUDITOR.md} +3 -3
  45. qaas/prompts/{SURFACE.md → BROWSER.md} +1 -1
  46. qaas/prompts/{VAULT.md → DBA.md} +4 -4
  47. qaas/prompts/{MENDER.md → FIXER.md} +1 -1
  48. qaas/prompts/{USHER.md → GUIDE.md} +6 -6
  49. qaas/prompts/{GAUGE.md → LOAD.md} +6 -6
  50. qaas/prompts/{CARTOGRAPHER.md → MAPPER.md} +2 -2
  51. qaas/prompts/{CHRONICLE.md → REPORTER.md} +3 -3
  52. qaas/prompts/{FORGE.md → REPRODUCER.md} +1 -1
  53. qaas/prompts/{ARBITER.md → REVIEWER.md} +3 -3
  54. qaas/prompts/{PULSE.md → SOCKET.md} +4 -4
  55. qaas/prompts/{CLERK.md → TRIAGE.md} +2 -2
  56. qaas/prompts/{PROOF.md → VERIFIER.md} +2 -2
  57. qaas/registry.py +21 -11
  58. qaas/{conductor.py → router.py} +58 -58
  59. qaas/runner.py +25 -7
  60. qaas/scorecard.py +30 -7
  61. qaas/store.py +57 -24
  62. qaas/target.py +10 -10
  63. qaas/tasks.py +13 -13
  64. qaas/trace.py +3 -3
  65. {qaas_python-1.0.0.dist-info → qaas_python-2.0.0.dist-info}/METADATA +37 -37
  66. qaas_python-2.0.0.dist-info/RECORD +96 -0
  67. qaas_python-1.0.0.dist-info/RECORD +0 -96
  68. {qaas_python-1.0.0.dist-info → qaas_python-2.0.0.dist-info}/WHEEL +0 -0
  69. {qaas_python-1.0.0.dist-info → qaas_python-2.0.0.dist-info}/entry_points.txt +0 -0
  70. {qaas_python-1.0.0.dist-info → qaas_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
qaas/adapters/tracker.py CHANGED
@@ -207,12 +207,12 @@ class LocalTracker(TrackerAdapter):
207
207
  return self.dir / f"{key}.json"
208
208
 
209
209
  def _write(self, issue: Issue) -> Issue:
210
- self._path(issue.key).write_text(issue.model_dump_json(indent=2))
210
+ self._path(issue.key).write_text(issue.model_dump_json(indent=2), encoding="utf-8")
211
211
  return issue
212
212
 
213
213
  def get(self, key: str) -> Issue | None:
214
214
  path = self._path(key)
215
- return Issue.model_validate_json(path.read_text()) if path.exists() else None
215
+ return Issue.model_validate_json(path.read_text(encoding="utf-8")) if path.exists() else None
216
216
 
217
217
  def _require(self, key: str) -> Issue:
218
218
  issue = self.get(key)
@@ -224,7 +224,7 @@ class LocalTracker(TrackerAdapter):
224
224
  found = []
225
225
  for path in self.dir.glob("*.json"):
226
226
  if _KEY_RE.match(path.stem):
227
- found.append(Issue.model_validate_json(path.read_text()))
227
+ found.append(Issue.model_validate_json(path.read_text(encoding="utf-8")))
228
228
  return sorted(found, key=lambda i: i.number)
229
229
 
230
230
  def _next_key(self, project: str) -> str:
@@ -568,6 +568,43 @@ def _label_slug(value: str | None) -> str | None:
568
568
  return slug or None
569
569
 
570
570
 
571
+ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
572
+ """Drop `Authorization` when a redirect leaves the site it was minted for.
573
+
574
+ urllib strips credentials across hosts only for the auth *handlers*. A
575
+ header set by hand on the `Request` — which is how every call here sends
576
+ `Basic <email:api_token>` — is copied onto the redirected request verbatim:
577
+ `HTTPRedirectHandler.redirect_request` filters out `content-length` and
578
+ `content-type` and nothing else.
579
+
580
+ That matters because `_resolve_url` deliberately follows Jira's redirects to
581
+ learn where a board actually lives, and it runs at the top of every
582
+ Jira-backed run via `cli._ensure_board`. On an SSO-enforced site
583
+ `/secure/RapidBoard.jspa?rapidView=<id>` answers 302 to the identity
584
+ provider — a different host — and the bot's API token went with it. The same
585
+ exposure sits on `_request`, which follows redirects too; both share this
586
+ one opener, so both are fixed here rather than at either call site.
587
+
588
+ Scheme counts as well as host: an https -> http redirect would put the token
589
+ on the wire in clear.
590
+ """
591
+
592
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
593
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
594
+ if new is None:
595
+ return None
596
+ before = urllib.parse.urlsplit(req.full_url)
597
+ after = urllib.parse.urlsplit(new.full_url)
598
+ if (before.hostname, before.scheme) != (after.hostname, after.scheme):
599
+ # `Request.headers` is capitalised; `unredirected_hdrs` holds the
600
+ # ones urllib adds itself. Clear from both so nothing puts it back.
601
+ new.headers = {k: v for k, v in new.headers.items() if k.lower() != "authorization"}
602
+ new.unredirected_hdrs = {
603
+ k: v for k, v in new.unredirected_hdrs.items() if k.lower() != "authorization"
604
+ }
605
+ return new
606
+
607
+
571
608
  @dataclass(frozen=True)
572
609
  class BoardInfo:
573
610
  """What a per-repository board provisioning attempt produced.
@@ -664,7 +701,8 @@ class JiraTracker(TrackerAdapter):
664
701
  self.timeout = timeout
665
702
  # Built once, at construction, so proxy settings are read from the
666
703
  # environment the tracker was configured in rather than per call.
667
- self._opener = urllib.request.build_opener()
704
+ # `_StripAuthOnRedirect` is not optional: see its docstring.
705
+ self._opener = urllib.request.build_opener(_StripAuthOnRedirect)
668
706
  self._link_type_cache: list[dict[str, Any]] | None = None
669
707
  self._account_id_cache: str | None = None
670
708
 
@@ -950,6 +988,33 @@ class JiraTracker(TrackerAdapter):
950
988
  for name in self.PROJECT_PERMISSIONS
951
989
  }
952
990
 
991
+ def _jira_status_name(self, status: str, project: str | None = None) -> str:
992
+ """A house status translated into this project's own vocabulary.
993
+
994
+ The `search` tool advertises `{"enum": list(STATUSES)}` — the house names
995
+ — and forwarded them to Jira verbatim, which validates status names and
996
+ answers 400. So TRIAGE following its own schema to dedupe
997
+ (`search(status="open", fingerprint=...)`) errored on every call, dedupe
998
+ degraded silently, and the duplicate ticket this system exists to prevent
999
+ got filed. `transition` goes to real trouble to translate via
1000
+ `JIRA_STATUS_ALIASES`; this did not, and `_issue_from_jira` deliberately
1001
+ stores Jira's own status name, so the house vocabulary could never match.
1002
+
1003
+ A name that is already this project's own is passed through untouched:
1004
+ the one existing test for this path searches `status="Done"`, which is
1005
+ Jira vocabulary rather than anything in `STATUSES`.
1006
+ """
1007
+ if status not in STATUSES:
1008
+ return status
1009
+ key = project or self._project
1010
+ try:
1011
+ names = sorted({n for group in self.project_statuses(key).values() for n in group})
1012
+ except TrackerError:
1013
+ # The workflow is unreadable; the caller's word is the best we have,
1014
+ # and a 400 from a search is not worth failing a run over.
1015
+ return status
1016
+ return self.map_house_statuses(names).get(status) or status
1017
+
953
1018
  def project_statuses(self, key: str) -> dict[str, list[str]]:
954
1019
  """Issue type name -> the status names its workflow contains.
955
1020
 
@@ -976,7 +1041,7 @@ class JiraTracker(TrackerAdapter):
976
1041
 
977
1042
  Mirrors `_match_transition`'s candidate order, so what this predicts is
978
1043
  what a transition will actually do. A None is a silent failure waiting
979
- to happen: PROOF asks for 'closed', nothing matches, and the ticket sits
1044
+ to happen: VERIFIER asks for 'closed', nothing matches, and the ticket sits
980
1045
  open while the run reports success.
981
1046
  """
982
1047
  available = {name.strip().lower(): name for name in status_names if name.strip()}
@@ -1005,7 +1070,7 @@ class JiraTracker(TrackerAdapter):
1005
1070
  `filter/search` returns other people's filters too, and adopting a
1006
1071
  stranger's filter as the run's board would silently repoint it.
1007
1072
  """
1008
- params = {"filterName": name, "expand": "jql", "maxResults": "50"}
1073
+ params = {"filterName": name, "expand": "jql,owner", "maxResults": "50"}
1009
1074
  account = self._account_id()
1010
1075
  if account:
1011
1076
  # Omitted rather than sent empty: Jira answers an empty accountId
@@ -1013,8 +1078,20 @@ class JiraTracker(TrackerAdapter):
1013
1078
  params["accountId"] = account
1014
1079
  data = self._request("GET", "/filter/search", params=params, retry_on_429=True)
1015
1080
  for entry in data.get("values") or []:
1016
- if str(entry.get("name") or "").strip() == name:
1017
- return entry
1081
+ if str(entry.get("name") or "").strip() != name:
1082
+ continue
1083
+ # With no account id the scoping parameter was omitted, so the search
1084
+ # returned everyone's filters and the first name match was adopted --
1085
+ # a stranger's filter, which `ensure_repo_board` now PUTs new JQL
1086
+ # onto. Failing open was survivable while this only read; since the
1087
+ # repair landed it rewrites someone else's saved filter. When the
1088
+ # owner cannot be established, decline to reuse and make our own.
1089
+ owner = (entry.get("owner") or {}).get("accountId")
1090
+ if account and owner and owner != account:
1091
+ continue
1092
+ if not account and owner:
1093
+ continue
1094
+ return entry
1018
1095
  return None
1019
1096
 
1020
1097
  def update_filter_jql(self, filter_id: int, *, name: str, jql: str) -> dict[str, Any]:
@@ -1027,7 +1104,16 @@ class JiraTracker(TrackerAdapter):
1027
1104
  told to watch. That is the same "files fine, invisible" failure the
1028
1105
  `repo-` label exists to prevent, arriving by a different road.
1029
1106
  """
1030
- return self._request("PUT", f"/filter/{filter_id}", body={"name": name, "jql": jql})
1107
+ # `sharePermissions` is sent again on purpose. A PUT replaces the
1108
+ # filter, so omitting it un-shares the filter this call just repaired --
1109
+ # and Jira refuses to render a board over a private filter, which is the
1110
+ # same "files fine, invisible" outcome two calls later. `create_filter`
1111
+ # below records why the share matters.
1112
+ return self._request(
1113
+ "PUT",
1114
+ f"/filter/{filter_id}",
1115
+ body={"name": name, "jql": jql, "sharePermissions": [{"type": "authenticated"}]},
1116
+ )
1031
1117
 
1032
1118
  def create_filter(self, *, name: str, jql: str, description: str = "") -> dict[str, Any]:
1033
1119
  """A saved filter, shared with authenticated users.
@@ -1568,7 +1654,7 @@ class JiraTracker(TrackerAdapter):
1568
1654
  "project in (" + ", ".join(self._jql_value(p) for p in scope) + ")"
1569
1655
  )
1570
1656
  if status:
1571
- clauses.append(f"status = {self._jql_value(status)}")
1657
+ clauses.append(f"status = {self._jql_value(self._jira_status_name(status, project))}")
1572
1658
  for value, prefix in (
1573
1659
  (label, ""),
1574
1660
  (envelope_id, ENVELOPE_LABEL_PREFIX),
qaas/adapters/vcs.py CHANGED
@@ -6,7 +6,7 @@ one layer up, in `qaas.mcp.vcs`, because that is where the run context lives and
6
6
  where a refusal can be logged to the ledger.
7
7
 
8
8
  The split also keeps the §8.1 matrix honest when the backend changes. Swapping
9
- `vcs: local` for `vcs: github` must not quietly widen what FORGE may do, and it
9
+ `vcs: local` for `vcs: github` must not quietly widen what REPRODUCER may do, and it
10
10
  cannot, because the enforcement is not in here.
11
11
 
12
12
  Two capabilities are absent on purpose rather than by oversight: there is no
@@ -129,16 +129,21 @@ class LocalGit(VcsAdapter):
129
129
  return name or "HEAD"
130
130
 
131
131
  def create_branch(self, name: str, from_ref: str | None = None) -> str:
132
- args = ["checkout", "-b", name]
132
+ # `from_ref` had no validator of any kind while `name` had two. git
133
+ # accepts options after positionals, so a start point like
134
+ # `--upload-pack=...` was a flag in a value's clothing -- the exact
135
+ # shape `_reject_flaglike` exists to stop, on the one argument nothing
136
+ # was checking.
137
+ args = ["checkout", "-b", _reject_refspec("branch", name)]
133
138
  if from_ref:
134
- args.append(from_ref)
139
+ args.append(_reject_ref("from_ref", from_ref))
135
140
  self._git(*args)
136
141
  return self.current_branch()
137
142
 
138
143
  def checkout(self, ref: str) -> str:
139
144
  """Switch to an existing ref. Not part of the abstract surface: only the
140
145
  local backend has a working tree to switch."""
141
- self._git("checkout", ref)
146
+ self._git("checkout", _reject_ref("ref", ref))
142
147
  return self.current_branch()
143
148
 
144
149
  def write_files(self, files: Mapping[str, str]) -> list[str]:
@@ -146,7 +151,7 @@ class LocalGit(VcsAdapter):
146
151
  for rel, content in files.items():
147
152
  path = self.repo / rel
148
153
  path.parent.mkdir(parents=True, exist_ok=True)
149
- path.write_text(content)
154
+ path.write_text(content, encoding="utf-8")
150
155
  written.append(rel)
151
156
  return written
152
157
 
@@ -162,9 +167,14 @@ class LocalGit(VcsAdapter):
162
167
  return self._git("rev-parse", "HEAD").strip()
163
168
 
164
169
  def diff(self, ref: str | None = None, paths: Sequence[str] | None = None) -> str:
170
+ # `ref` reached argv raw while `pr_diff` and `list_changed_files` both
171
+ # called `_reject_flaglike` on theirs. `git diff --output=<path>` exits 0
172
+ # and writes the diff to that path, so the one tool documented
173
+ # "read-only" could create or truncate any file the process can reach --
174
+ # and REVIEWER, whose policy grants no write access at all, holds it.
165
175
  args = ["diff"]
166
176
  if ref:
167
- args.append(ref)
177
+ args.append(_reject_flaglike("ref", ref))
168
178
  if paths:
169
179
  args.extend(["--", *paths])
170
180
  return self._git(*args)
@@ -221,6 +231,52 @@ def _reject_flaglike(kind: str, value: str) -> str:
221
231
  return text
222
232
 
223
233
 
234
+ #: git's own refname rules, narrowed to what this system ever needs. Notably it
235
+ #: excludes `?`, `#`, `%` and `..` — which matter because `list_changed_files`
236
+ #: interpolates a ref into a `gh api` *path*, where those characters restructure
237
+ #: the request rather than name a commit.
238
+ _REF_CHARS = re.compile(r"^[A-Za-z0-9._/-]+$")
239
+
240
+
241
+ def _reject_ref(kind: str, value: str) -> str:
242
+ """A ref is letters, digits and a little punctuation. Anything else is not one."""
243
+ text = _reject_flaglike(kind, value)
244
+ if ".." in text or not _REF_CHARS.match(text):
245
+ raise VcsError(
246
+ f"refusing {kind} '{text}': a git ref is letters, digits, '.', '_', '-' "
247
+ "and '/', and may not contain '..'."
248
+ )
249
+ return text
250
+
251
+
252
+ def _reject_refspec(kind: str, value: str) -> str:
253
+ """Refuse a branch name that git would read as a *refspec* rather than a name.
254
+
255
+ `git push origin <x>` parses `<x>` as a refspec, so `qa/repro/x:main` pushes
256
+ the local branch `qa/repro/x` onto the remote's `main` — while every gate on
257
+ the way sees a string that is neither `main` nor outside the agent's
258
+ patterns: `fnmatch("qa/repro/x:main", "qa/repro/*")` is True, and
259
+ `is_protected_head` tests the whole string. Verified against a real bare
260
+ remote: exit 0, `qa/repro/x -> main`. A leading `+` is a forced update in the
261
+ same grammar, which is the other thing this file promises can never happen.
262
+
263
+ Git itself forbids `:` in a branch name, so nothing legitimate is lost: the
264
+ only way one gets here is a caller passing a refspec where a name belongs.
265
+ """
266
+ text = _reject_flaglike(kind, value)
267
+ if ":" in text:
268
+ raise VcsError(
269
+ f"refusing {kind} '{text}': a branch name may not contain ':'. "
270
+ "git would read that as a refspec and push to whatever follows it."
271
+ )
272
+ if text.startswith("+"):
273
+ raise VcsError(
274
+ f"refusing {kind} '{text}': a leading '+' is a forced update, "
275
+ "and force-pushing is never permitted (§8.1)."
276
+ )
277
+ return _reject_ref(kind, text)
278
+
279
+
224
280
  def is_protected_head(branch: str) -> bool:
225
281
  """Whether `branch` is one no agent may push or open a PR from (§8.1)."""
226
282
  name = branch.strip().lower().removeprefix("refs/heads/")
@@ -299,7 +355,7 @@ class GitHubVcs(LocalGit):
299
355
 
300
356
  @staticmethod
301
357
  def _guard_head(branch: str) -> str:
302
- name = _reject_flaglike("branch", branch)
358
+ name = _reject_refspec("branch", branch)
303
359
  if is_protected_head(name):
304
360
  raise VcsError(
305
361
  f"refusing to publish '{name}': it is a protected branch (§8.1). "
@@ -317,8 +373,12 @@ class GitHubVcs(LocalGit):
317
373
  follow-up `gh pr create` can resolve the head without the agent having
318
374
  to know about tracking refs.
319
375
  """
320
- name = self._guard_head(branch or self.current_branch())
321
- self._git("push", "--set-upstream", self.remote, name)
376
+ name = self._guard_head(branch or self.current_branch()).removeprefix("refs/heads/")
377
+ # Fully qualified on both sides, so the destination is stated rather than
378
+ # inferred from a string git is free to re-parse. `_guard_head` already
379
+ # refuses a `:`; this makes the refspec explicit even if that ever
380
+ # changes, because the cost of being wrong here is a push to main.
381
+ self._git("push", "--set-upstream", self.remote, f"refs/heads/{name}:refs/heads/{name}")
322
382
  return name
323
383
 
324
384
  def open_pr(
@@ -350,18 +410,19 @@ class GitHubVcs(LocalGit):
350
410
  "must say on its face what finding it answers."
351
411
  )
352
412
 
413
+ # `--flag=value`, not `--flag value`: `title` and `body` are the two
414
+ # arguments an agent writes freely here, and they were the two with no
415
+ # shape check at all. The joined form removes the question rather than
416
+ # answering it — there is no position left for a value to be read as a
417
+ # flag, and a title may still legitimately begin with a dash.
353
418
  args = [
354
419
  "pr",
355
420
  "create",
356
421
  *self._repo_args(),
357
- "--head",
358
- head,
359
- "--base",
360
- base_ref,
361
- "--title",
362
- subject,
363
- "--body",
364
- self.pr_body(body, key),
422
+ f"--head={head}",
423
+ f"--base={base_ref}",
424
+ f"--title={subject}",
425
+ f"--body={self.pr_body(body, key)}",
365
426
  ]
366
427
  if draft:
367
428
  args.append("--draft")
@@ -398,8 +459,8 @@ class GitHubVcs(LocalGit):
398
459
  Asking GitHub rather than the local tree means this answers for a PR
399
460
  whose head was never fetched into this checkout.
400
461
  """
401
- base_ref = _reject_flaglike("base", base)
402
- head_ref = _reject_flaglike("head", head)
462
+ base_ref = _reject_ref("base", base)
463
+ head_ref = _reject_ref("head", head)
403
464
  out = self._gh(
404
465
  "api",
405
466
  f"repos/{self._api_repo_path()}/compare/{base_ref}...{head_ref}",
qaas/cli.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import re
7
+ import shutil
7
8
  import subprocess
8
9
  import sys
9
10
  from pathlib import Path
@@ -175,11 +176,21 @@ def _materialise_repo(repo: str, clone_to: Path | str | None) -> tuple[Path, str
175
176
 
176
177
  root.parent.mkdir(parents=True, exist_ok=True)
177
178
  console.print(f"cloning {repo} -> {root}")
178
- result = subprocess.run(
179
- ["git", "clone", "--depth", "50", repo, str(root)],
180
- capture_output=True, text=True, timeout=600,
181
- )
179
+ try:
180
+ result = subprocess.run(
181
+ ["git", "clone", "--depth", "50", repo, str(root)],
182
+ capture_output=True, text=True, timeout=600,
183
+ )
184
+ except subprocess.TimeoutExpired:
185
+ # git leaves the partial tree behind, and `root.exists()` above then
186
+ # reports "using existing clone" on the next run -- so a clone that timed
187
+ # out was silently reused as a complete checkout, and every finding after
188
+ # it described a repository that was never fully there.
189
+ shutil.rmtree(root, ignore_errors=True)
190
+ console.print(f"[red]clone timed out[/red] after 600s; removed the partial checkout at {root}")
191
+ raise typer.Exit(1) from None
182
192
  if result.returncode != 0:
193
+ shutil.rmtree(root, ignore_errors=True)
183
194
  console.print(f"[red]clone failed:[/red] {result.stderr.strip()[:400]}")
184
195
  raise typer.Exit(1)
185
196
  return root, repo
@@ -224,7 +235,7 @@ def _write_profile(profile, out: Path) -> None:
224
235
  "# Target profile. Everything here was guessed by inspection — review it.\n"
225
236
  "# Credentials never belong in this file: reference environment variables.\n\n"
226
237
  + _yaml.safe_dump(payload, sort_keys=False, width=88)
227
- )
238
+ , encoding="utf-8")
228
239
 
229
240
 
230
241
  def _provision_target(
@@ -338,15 +349,15 @@ def init(
338
349
  system_yaml = project_config / "system.yaml"
339
350
  if not system_yaml.exists():
340
351
  shipped = Workspace.resolve().config_file("system.yaml")
341
- base = shipped.read_text() if shipped else "project: qaas\n"
352
+ base = shipped.read_text(encoding="utf-8") if shipped else "project: qaas\n"
342
353
  system_yaml.write_text(
343
354
  _activate_target(base, target_name)
344
355
  if shipped
345
356
  else f"project: qaas\ntarget: {target_name}\n"
346
- )
357
+ , encoding="utf-8")
347
358
  wrote_system = True
348
359
  else:
349
- system_yaml.write_text(_activate_target(system_yaml.read_text(), target_name))
360
+ system_yaml.write_text(_activate_target(system_yaml.read_text(encoding="utf-8"), target_name))
350
361
  wrote_system = False
351
362
 
352
363
  # `.qaas/` now holds a user's committed config next to their disposable run
@@ -358,7 +369,7 @@ def init(
358
369
  "# Run state: regenerated every run, never worth committing.\n"
359
370
  "runs/\ntickets/\ngenerated/\nsystem-map/\nmemory.db\nartifacts/\n"
360
371
  "\n# config/ is NOT ignored -- it is yours, and it is the point.\n"
361
- )
372
+ , encoding="utf-8")
362
373
 
363
374
  console.print(f"\n[green]wrote {out}[/green]")
364
375
  console.print(
@@ -465,7 +476,7 @@ def doctor(
465
476
 
466
477
 
467
478
  def _agent_usable(spec, caps: dict[str, bool]) -> bool:
468
- """Delegates to `target.agent_usable`, which the conductor also uses.
479
+ """Delegates to `target.agent_usable`, which the router also uses.
469
480
 
470
481
  Two copies of this rule meant `qaas doctor` could report an agent unusable
471
482
  while a run dispatched it anyway.
@@ -512,7 +523,7 @@ def validate(config_dir: Path | None = ConfigDir) -> None:
512
523
  # A mode whose agents cannot fit inside its cap is a mode that stops
513
524
  # partway through, every time, and looks like it worked: agents run,
514
525
  # findings reach the ledger, nothing errors. `pr-check` shipped that way --
515
- # $6 cap against a $15 roster, so discovery spent $6.09 and FORGE and CLERK
526
+ # $6 cap against a $15 roster, so discovery spent $6.09 and REPRODUCER and TRIAGE
516
527
  # never dispatched. The mode meant for every pull request could not file a
517
528
  # ticket. It took a live run to notice; this check makes it free.
518
529
  for mode_name, mode in sorted(cfg.run_modes.items()):
@@ -720,7 +731,7 @@ def prompts_list(config_dir: Path | None = ConfigDir) -> None:
720
731
  SHARED_PROMPT,
721
732
  _prompt_origin(shared, ws) if shared else "[red]missing[/red]",
722
733
  "-",
723
- str(len(shared.read_text())) if shared else "-",
734
+ str(len(shared.read_text(encoding="utf-8"))) if shared else "-",
724
735
  )
725
736
  console.print(table)
726
737
 
@@ -767,7 +778,7 @@ def prompts_eject(
767
778
  skipped.append(dest)
768
779
  continue
769
780
  dest.parent.mkdir(parents=True, exist_ok=True)
770
- dest.write_text(src.read_text())
781
+ dest.write_text(src.read_text(encoding="utf-8"))
771
782
  written.append(dest)
772
783
 
773
784
  for path in written:
@@ -815,8 +826,8 @@ def prompts_diff(
815
826
  if in_force is not None and packaged.is_file() and in_force != packaged.resolve():
816
827
  diff = list(
817
828
  difflib.unified_diff(
818
- packaged.read_text().splitlines(),
819
- in_force.read_text().splitlines(),
829
+ packaged.read_text(encoding="utf-8").splitlines(),
830
+ in_force.read_text(encoding="utf-8").splitlines(),
820
831
  fromfile=f"packaged/{filename}",
821
832
  tofile=str(in_force),
822
833
  lineterm="",
@@ -839,7 +850,7 @@ def prompts_diff(
839
850
  changed += 1
840
851
  console.print(f"\n[bold]{label}[/bold] [dim]+ {append_name(filename)}[/dim]")
841
852
  console.print(f"--- {path}", markup=False, highlight=False)
842
- for line in path.read_text().splitlines():
853
+ for line in path.read_text(encoding="utf-8").splitlines():
843
854
  console.print(f"+{line}", style="green", markup=False, highlight=False)
844
855
 
845
856
  if not changed:
@@ -857,7 +868,7 @@ def runs(root: Path = Root, limit: int = 10) -> None:
857
868
  for col in ("run", "envelopes", "agents"):
858
869
  table.add_column(col)
859
870
  for run_id in ids:
860
- store = RunStore(run_id, root)
871
+ store = RunStore(run_id, root, create=False)
861
872
  results = store.results()
862
873
  table.add_row(
863
874
  run_id,
@@ -878,7 +889,7 @@ VERDICT_STYLE = {
878
889
  @app.command()
879
890
  def show(run_id: str, root: Path = Root) -> None:
880
891
  """Show one run's findings and ledger: cost, duration, tickets, escalations."""
881
- store = RunStore(run_id, root)
892
+ store = RunStore(run_id, root, create=False)
882
893
  # One pass over the ledger, shared by the header and the denial list -- each
883
894
  # `store.ledger(kind)` call is a full-file scan of a file that reaches tens
884
895
  # of thousands of lines on a real run.
@@ -992,7 +1003,7 @@ def trace(
992
1003
  quiet: bool = typer.Option(False, "--quiet", "-q", help="Drop tool_call lines and show only what an agent decided."),
993
1004
  ) -> None:
994
1005
  """Print one run's ledger as a timeline: dispatches, tools, denials, verdicts, cost."""
995
- store = RunStore(run_id, root)
1006
+ store = RunStore(run_id, root, create=False)
996
1007
  if not store.ledger_path.exists() and not follow:
997
1008
  console.print(f"[red]no ledger for run {run_id}[/red] — try `qaas runs`")
998
1009
  raise typer.Exit(1)
@@ -1039,11 +1050,11 @@ def trace(
1039
1050
 
1040
1051
  @app.command()
1041
1052
  def map(root: Path = Root, version: str | None = None) -> None:
1042
- """Show the system map Cartographer produced."""
1053
+ """Show the system map Mapper produced."""
1043
1054
  maps = SystemMapStore(root)
1044
1055
  payload = maps.get(version)
1045
1056
  if payload is None:
1046
- console.print("[dim]no system map yet — run CARTOGRAPHER[/dim]")
1057
+ console.print("[dim]no system map yet — run MAPPER[/dim]")
1047
1058
  raise typer.Exit(1)
1048
1059
  console.print(f"[bold]version[/bold] {version or maps.latest_version()}")
1049
1060
  console.print_json(data=payload)
@@ -1066,7 +1077,7 @@ def run(
1066
1077
  """Execute a run. Costs real money unless --dry-run."""
1067
1078
  import asyncio
1068
1079
 
1069
- from qaas.conductor import Conductor
1080
+ from qaas.router import Router
1070
1081
  from qaas.registry import describe
1071
1082
 
1072
1083
  if repo and target:
@@ -1177,8 +1188,8 @@ def run(
1177
1188
  elif kind == "stopped":
1178
1189
  console.print(f"[yellow]stopped: {detail.get('reason')}[/yellow]")
1179
1190
 
1180
- conductor = Conductor(cfg, root=root, on_event=on_event, tickets=list(ticket) if ticket else None)
1181
- report = asyncio.run(conductor.run(mode, run_id=run_id))
1191
+ router = Router(cfg, root=root, on_event=on_event, tickets=list(ticket) if ticket else None)
1192
+ report = asyncio.run(router.run(mode, run_id=run_id))
1182
1193
 
1183
1194
  console.print()
1184
1195
  console.print_json(data=report.summary())
@@ -1198,11 +1209,21 @@ def score(
1198
1209
  domains: list[str] = typer.Option(
1199
1210
  None, "--domain", help="Restrict scoring to these domains. Use it when a run covered only part of the surface."
1200
1211
  ),
1212
+ target: str = typer.Option(
1213
+ None, "--target", "-t", help="Score against this profile's ledger rather than the active one's."
1214
+ ),
1201
1215
  ) -> None:
1202
1216
  """Score a run against the golden ledger. This is the honest number."""
1203
1217
  from qaas.scorecard import GoldenLedger, score as score_run
1204
1218
 
1205
1219
  cfg = load_config(config_dir)
1220
+ # Every other command that reads a run takes `--target`; this one did not,
1221
+ # so a `--repo` run was scored against whatever profile `system.yaml`
1222
+ # happened to name -- in practice the bundled demo's ledger, which describes
1223
+ # a different application entirely. Recall and precision against the wrong
1224
+ # oracle are worse than no number, because they look like a number.
1225
+ if target:
1226
+ cfg = cfg.model_copy(update={"target": target, "profile": _load_target(target, config_dir)})
1206
1227
  ledger_path = _ledger_path(cfg)
1207
1228
  if ledger_path is None or not ledger_path.exists():
1208
1229
  console.print(
@@ -1222,7 +1243,7 @@ def score(
1222
1243
  raise typer.Exit(1)
1223
1244
  run_id = ids[0]
1224
1245
 
1225
- store = RunStore(run_id, root)
1246
+ store = RunStore(run_id, root, create=False)
1226
1247
  card = score_run(
1227
1248
  store.envelopes(),
1228
1249
  GoldenLedger.load(ledger_path),
@@ -1274,12 +1295,12 @@ def sweep(
1274
1295
  """
1275
1296
  import asyncio
1276
1297
 
1277
- from qaas.conductor import Conductor
1298
+ from qaas.router import Router
1278
1299
  from qaas.scorecard import GoldenLedger, score as score_run
1279
1300
 
1280
1301
  cfg = load_config(config_dir)
1281
- conductor = Conductor(cfg, root=root)
1282
- report = asyncio.run(conductor.run(mode))
1302
+ router = Router(cfg, root=root)
1303
+ report = asyncio.run(router.run(mode))
1283
1304
  console.print_json(data=report.summary())
1284
1305
 
1285
1306
  ledger_path = _ledger_path(cfg)
@@ -1323,12 +1344,12 @@ _JIRA_ENV_HELP: dict[str, str] = {
1323
1344
  _JIRA_SECRET_ENV = frozenset({"JIRA_API_TOKEN"})
1324
1345
 
1325
1346
  #: House statuses this system actually drives. An unmapped one here is a real
1326
- #: failure: PROOF asks for 'closed', nothing in the workflow matches, and the
1347
+ #: failure: VERIFIER asks for 'closed', nothing in the workflow matches, and the
1327
1348
  #: ticket stays open while the run reports a clean close. The rest of `STATUSES`
1328
1349
  #: are human dispositions — worth reporting, not worth failing on.
1329
1350
  _DRIVEN_STATUSES = ("open", "in_progress", "resolved", "closed")
1330
1351
 
1331
- #: What `--dry-run-ticket` renders. A realistic CLERK ticket rather than a
1352
+ #: What `--dry-run-ticket` renders. A realistic TRIAGE ticket rather than a
1332
1353
  #: placeholder, because the point is to see the ADF, the labels and the
1333
1354
  #: fingerprint an engineer will actually receive.
1334
1355
  _SAMPLE_TICKET: dict[str, object] = {
@@ -1357,7 +1378,7 @@ _SAMPLE_TICKET: dict[str, object] = {
1357
1378
  "severity": "critical",
1358
1379
  "envelope_id": "env-sample-0001",
1359
1380
  "fingerprint": "sha256:" + "ab12cd34" * 8,
1360
- "reporter": "CLERK",
1381
+ "reporter": "TRIAGE",
1361
1382
  }
1362
1383
 
1363
1384
 
qaas/config.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Configuration: agents are data, not code.
2
2
 
3
3
  An agent is a prompt file plus an entry in `config/agents/`. Adding one of the
4
- remaining agents from the roster should never require touching the conductor,
4
+ remaining agents from the roster should never require touching the router,
5
5
  the runner, or the guardrails — that is the property this module exists to keep.
6
6
  """
7
7
 
@@ -87,7 +87,7 @@ class AgentSpec(BaseModel):
87
87
 
88
88
  # Tools this agent must have called before it is allowed to finish. The Stop
89
89
  # hook enforces it. Without this an agent can produce a confident summary and
90
- # no artifact, and the failure only surfaces afterwards in the conductor —
90
+ # no artifact, and the failure only surfaces afterwards in the router —
91
91
  # too late for the agent to fix it.
92
92
  must_call: list[str] = Field(default_factory=list)
93
93
 
@@ -310,7 +310,7 @@ def load_config(
310
310
  looked = ", ".join(str(d) for d in dirs) or "(nowhere -- no search path)"
311
311
  raise FileNotFoundError(f"no system config at {dirs[0] / 'system.yaml'} (looked in: {looked})")
312
312
 
313
- raw: dict[str, Any] = yaml.safe_load(system_path.read_text()) or {}
313
+ raw: dict[str, Any] = yaml.safe_load(system_path.read_text(encoding="utf-8")) or {}
314
314
 
315
315
  # `target_app:` used to name the application's directory relative to the
316
316
  # process cwd. The target profile's `root` says the same thing and says it
@@ -321,7 +321,7 @@ def load_config(
321
321
  raw.pop("target_app", None)
322
322
 
323
323
  # Backend overrides from the environment, so pointing a run at a real
324
- # tracker or forge is not a committed file change.
324
+ # tracker or reproducer is not a committed file change.
325
325
  #
326
326
  # `tracker: local` is the committed default and must stay that way. When
327
327
  # `jira` was committed instead, 18 tests failed and 14 errored: the agent
@@ -348,7 +348,7 @@ def load_config(
348
348
 
349
349
  agents: dict[str, Any] = {}
350
350
  for path in by_stem.values():
351
- spec = yaml.safe_load(path.read_text()) or {}
351
+ spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
352
352
  name = spec.get("name") or path.stem.upper()
353
353
  spec["name"] = name
354
354
  if name in agents:
@@ -1,10 +1,10 @@
1
- name: CONDUIT
1
+ name: API
2
2
  layer: discovery
3
3
  role: >
4
4
  Backend, API and contract analyst. Finds spec drift, breaking changes, missing
5
5
  authorization, error-taxonomy inconsistency, unbounded results, validation gaps.
6
6
  Ships a failing contract test as evidence, never a bare opinion.
7
- prompt: CONDUIT.md
7
+ prompt: API.md
8
8
  model: claude-opus-5
9
9
  effort: high
10
10
  max_turns: 60