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.
- qaas/adapters/tracker.py +96 -10
- qaas/adapters/vcs.py +80 -19
- qaas/cli.py +52 -31
- qaas/config.py +5 -5
- qaas/defaults/config/agents/{conduit.yaml → api.yaml} +2 -2
- qaas/defaults/config/agents/{keystone.yaml → architect.yaml} +3 -3
- qaas/defaults/config/agents/{warden.yaml → auditor.yaml} +3 -3
- qaas/defaults/config/agents/{surface.yaml → browser.yaml} +2 -2
- qaas/defaults/config/agents/{vault.yaml → dba.yaml} +2 -2
- qaas/defaults/config/agents/{mender.yaml → fixer.yaml} +3 -3
- qaas/defaults/config/agents/{usher.yaml → guide.yaml} +3 -3
- qaas/defaults/config/agents/{gauge.yaml → load.yaml} +2 -2
- qaas/defaults/config/agents/{cartographer.yaml → mapper.yaml} +2 -2
- qaas/defaults/config/agents/{chronicle.yaml → reporter.yaml} +2 -2
- qaas/defaults/config/agents/{forge.yaml → reproducer.yaml} +3 -3
- qaas/defaults/config/agents/{arbiter.yaml → reviewer.yaml} +4 -4
- qaas/defaults/config/agents/{pulse.yaml → socket.yaml} +4 -4
- qaas/defaults/config/agents/{clerk.yaml → triage.yaml} +2 -2
- qaas/defaults/config/agents/{proof.yaml → verifier.yaml} +2 -2
- qaas/defaults/config/system.yaml +10 -10
- qaas/discover.py +6 -6
- qaas/envelope.py +31 -3
- qaas/envfile.py +9 -1
- qaas/guardrails.py +174 -16
- qaas/mcp/context.py +11 -3
- qaas/mcp/contract_diff.py +86 -12
- qaas/mcp/env_control.py +27 -7
- qaas/mcp/envelope_server.py +26 -26
- qaas/mcp/test_runner.py +76 -7
- qaas/mcp/tracker.py +5 -5
- qaas/mcp/vcs.py +29 -34
- qaas/paths.py +2 -2
- qaas/plugin/skills/adversarial-review/SKILL.md +5 -5
- qaas/plugin/skills/environment-pinning/SKILL.md +1 -1
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +3 -3
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +2 -2
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +1 -1
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +6 -6
- qaas/plugin/skills/test-first-fix/SKILL.md +1 -1
- qaas/plugin/skills/test-quality-audit/SKILL.md +3 -3
- qaas/plugin/skills/ticket-writer/SKILL.md +1 -1
- qaas/prompts/{CONDUIT.md → API.md} +2 -2
- qaas/prompts/{KEYSTONE.md → ARCHITECT.md} +5 -5
- qaas/prompts/{WARDEN.md → AUDITOR.md} +3 -3
- qaas/prompts/{SURFACE.md → BROWSER.md} +1 -1
- qaas/prompts/{VAULT.md → DBA.md} +4 -4
- qaas/prompts/{MENDER.md → FIXER.md} +1 -1
- qaas/prompts/{USHER.md → GUIDE.md} +6 -6
- qaas/prompts/{GAUGE.md → LOAD.md} +6 -6
- qaas/prompts/{CARTOGRAPHER.md → MAPPER.md} +2 -2
- qaas/prompts/{CHRONICLE.md → REPORTER.md} +3 -3
- qaas/prompts/{FORGE.md → REPRODUCER.md} +1 -1
- qaas/prompts/{ARBITER.md → REVIEWER.md} +3 -3
- qaas/prompts/{PULSE.md → SOCKET.md} +4 -4
- qaas/prompts/{CLERK.md → TRIAGE.md} +2 -2
- qaas/prompts/{PROOF.md → VERIFIER.md} +2 -2
- qaas/registry.py +21 -11
- qaas/{conductor.py → router.py} +58 -58
- qaas/runner.py +25 -7
- qaas/scorecard.py +30 -7
- qaas/store.py +57 -24
- qaas/target.py +10 -10
- qaas/tasks.py +13 -13
- qaas/trace.py +3 -3
- {qaas_python-1.0.0.dist-info → qaas_python-2.0.0.dist-info}/METADATA +37 -37
- qaas_python-2.0.0.dist-info/RECORD +96 -0
- qaas_python-1.0.0.dist-info/RECORD +0 -96
- {qaas_python-1.0.0.dist-info → qaas_python-2.0.0.dist-info}/WHEEL +0 -0
- {qaas_python-1.0.0.dist-info → qaas_python-2.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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:
|
|
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()
|
|
1017
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
"--
|
|
360
|
-
|
|
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 =
|
|
402
|
-
head_ref =
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
1181
|
-
report = asyncio.run(
|
|
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.
|
|
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
|
-
|
|
1282
|
-
report = asyncio.run(
|
|
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:
|
|
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
|
|
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": "
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
7
|
+
prompt: API.md
|
|
8
8
|
model: claude-opus-5
|
|
9
9
|
effort: high
|
|
10
10
|
max_turns: 60
|