qaas-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/tasks.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""The task each agent is given: the user turn, distinct from its system prompt.
|
|
2
|
+
|
|
3
|
+
The system prompt says who an agent is and what its standards are; that is
|
|
4
|
+
stable across every run and lives in `prompts/`. The task says what to do this
|
|
5
|
+
time — which application, which environment, which findings — and is built here
|
|
6
|
+
from the target profile.
|
|
7
|
+
|
|
8
|
+
Nothing in this module may name a specific application. A prompt that mentions
|
|
9
|
+
one repository's directory layout or one app's seeded users works exactly once.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from qaas.config import SystemConfig
|
|
15
|
+
from qaas.envelope import DefectEnvelope
|
|
16
|
+
from qaas.target import TargetProfile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _profile(config: SystemConfig) -> TargetProfile:
|
|
20
|
+
if config.profile is None:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"no target profile loaded. Point `target:` in system.yaml at a file "
|
|
23
|
+
"in config/targets/, or create one with `qaas init <path-to-repo>`."
|
|
24
|
+
)
|
|
25
|
+
return config.profile
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _where(config: SystemConfig) -> str:
|
|
29
|
+
"""How to name the application under test to an agent.
|
|
30
|
+
|
|
31
|
+
The resolved path, not `profile.root`. An agent's process cwd *is* the
|
|
32
|
+
target root, and `root:` is spelled relative to the qaas project -- so
|
|
33
|
+
telling a run "the application at `target-app`" sent it looking for
|
|
34
|
+
`<target>/target-app`, a directory that does not exist. Resolved, the
|
|
35
|
+
sentence is true from wherever the agent happens to be standing.
|
|
36
|
+
"""
|
|
37
|
+
return str(config.target_root())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _environment_brief(profile: TargetProfile) -> str:
|
|
41
|
+
"""What an agent can and cannot do to the running application."""
|
|
42
|
+
env = profile.environment
|
|
43
|
+
if env.mode == "none":
|
|
44
|
+
return (
|
|
45
|
+
"There is no running instance of this application available. Work "
|
|
46
|
+
"statically: read the code, the schema, the spec and the tests. Say so "
|
|
47
|
+
"plainly in any finding you cannot execute — a defect you reasoned to "
|
|
48
|
+
"but did not observe is a weaker claim, and its confidence should show "
|
|
49
|
+
"that."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
lines = []
|
|
53
|
+
if env.api_url:
|
|
54
|
+
lines.append(f"API: {env.api_url}")
|
|
55
|
+
if env.web_url:
|
|
56
|
+
lines.append(f"web: {env.web_url}")
|
|
57
|
+
where = "; ".join(lines)
|
|
58
|
+
|
|
59
|
+
if env.mode == "external":
|
|
60
|
+
return (
|
|
61
|
+
f"The application is already running ({where}) and this system does not "
|
|
62
|
+
"own it. You may read it and exercise it. You may NOT reset it, reseed "
|
|
63
|
+
"it, or destroy state — someone else may be relying on it. Prefer "
|
|
64
|
+
"read-only calls, and never send a request whose side effect you would "
|
|
65
|
+
"not want to explain."
|
|
66
|
+
)
|
|
67
|
+
return (
|
|
68
|
+
f"Bring the application up with `env_control` ({where}). You own this "
|
|
69
|
+
"environment: seed it, reset it between journeys, and tear it down when "
|
|
70
|
+
"done. Reset between independent checks so one test's leftovers are not "
|
|
71
|
+
"the next one's bug."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _auth_brief(profile: TargetProfile) -> str:
|
|
76
|
+
auth = profile.auth
|
|
77
|
+
if auth.mode == "none":
|
|
78
|
+
return "The application needs no authentication."
|
|
79
|
+
if auth.mode == "token":
|
|
80
|
+
return (
|
|
81
|
+
f"Authenticate with the bearer token in ${auth.token_env}. "
|
|
82
|
+
"`env_control.impersonate` will hand it to you."
|
|
83
|
+
)
|
|
84
|
+
roles = "\n".join(
|
|
85
|
+
f" - `{name}` ({r.username}){': ' + r.description if r.description else ''}"
|
|
86
|
+
for name, r in auth.roles.items()
|
|
87
|
+
)
|
|
88
|
+
return (
|
|
89
|
+
f"Sign in via `{auth.login_endpoint}`. `env_control.impersonate(role)` returns "
|
|
90
|
+
f"a token for any of these accounts:\n{roles}\n"
|
|
91
|
+
"Use more than one. A permission defect is invisible from a single role."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cartographer(config: SystemConfig) -> str:
|
|
96
|
+
p = _profile(config)
|
|
97
|
+
spec = (
|
|
98
|
+
f"`{p.layout.spec}` is the declared API contract — read it for the intended "
|
|
99
|
+
"surface, but record what the code actually implements, and put any "
|
|
100
|
+
"disagreement in `drift`."
|
|
101
|
+
if p.layout.spec
|
|
102
|
+
else "There is no API specification in this repository. Record the surface "
|
|
103
|
+
"the code actually exposes, and note the absence of a spec in `gaps`."
|
|
104
|
+
)
|
|
105
|
+
ownership = (
|
|
106
|
+
f"`{p.layout.ownership}` is the ownership source."
|
|
107
|
+
if p.layout.ownership
|
|
108
|
+
else "There is no CODEOWNERS file. Leave ownership null rather than guessing "
|
|
109
|
+
"a team from a directory name — a misrouted ticket is worse than an "
|
|
110
|
+
"unassigned one."
|
|
111
|
+
)
|
|
112
|
+
return f"""Map the application at `{_where(config)}` — your working directory.
|
|
113
|
+
|
|
114
|
+
{p.description.strip() or "No description was supplied; work it out from the code."}
|
|
115
|
+
|
|
116
|
+
Known layout — {p.layout.described()}. Treat that as a starting point, not an
|
|
117
|
+
inventory: verify it and record what is actually there.
|
|
118
|
+
|
|
119
|
+
{spec}
|
|
120
|
+
|
|
121
|
+
{ownership}
|
|
122
|
+
|
|
123
|
+
Ignore these directories entirely: {', '.join(p.layout.exclude)}.
|
|
124
|
+
|
|
125
|
+
Explore with Read, Grep and Glob, then publish one complete map with
|
|
126
|
+
`put_system_map`. Get the shape of the repository before reading any single file
|
|
127
|
+
closely. Breadth first: every route and every table matters more than a deep
|
|
128
|
+
read of any one handler.
|
|
129
|
+
|
|
130
|
+
Publish the map once, when it is complete."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def conduit(config: SystemConfig, mode: str) -> str:
|
|
134
|
+
p = _profile(config)
|
|
135
|
+
spec_line = (
|
|
136
|
+
f"Compare the implementation against `{p.layout.spec}` with `diff_openapi`, "
|
|
137
|
+
"and judge each difference by consumer impact with `classify_breaking`."
|
|
138
|
+
if p.layout.spec
|
|
139
|
+
else "There is no specification to diff against, so the contract is implicit. "
|
|
140
|
+
"Judge each endpoint against what its own code promises and what its "
|
|
141
|
+
"consumers assume: look for handlers that disagree with each other."
|
|
142
|
+
)
|
|
143
|
+
prove = (
|
|
144
|
+
"Prove what you report. Call the endpoint and capture the real request and "
|
|
145
|
+
"response. A finding you have not observed is a hypothesis, not a defect, "
|
|
146
|
+
"and its confidence should say so."
|
|
147
|
+
if p.environment.is_reachable
|
|
148
|
+
else "You cannot call this API, so every finding is a reading of the code. "
|
|
149
|
+
"Quote the lines that support it, and keep confidence honest about the "
|
|
150
|
+
"fact that you did not observe the behaviour."
|
|
151
|
+
)
|
|
152
|
+
filing = (
|
|
153
|
+
"This is a diagnostic run: report what you find, but nothing will be filed."
|
|
154
|
+
if mode == "incident"
|
|
155
|
+
else "Findings that survive reproduction become tickets. Hold yourself to that bar."
|
|
156
|
+
)
|
|
157
|
+
return f"""Audit the API of the application at `{_where(config)}` — your working directory.
|
|
158
|
+
|
|
159
|
+
Start with `get_system_map` for the route inventory. Do not rediscover it.
|
|
160
|
+
|
|
161
|
+
{_environment_brief(p)}
|
|
162
|
+
|
|
163
|
+
{_auth_brief(p)}
|
|
164
|
+
|
|
165
|
+
Then work the surface systematically. For each endpoint: does the implementation
|
|
166
|
+
match what is declared? Who is allowed to call it, and does the code actually
|
|
167
|
+
check that? What happens on the error paths, and with a large or hostile input?
|
|
168
|
+
|
|
169
|
+
{spec_line}
|
|
170
|
+
|
|
171
|
+
The structural questions a tool can answer for you. The ones it cannot — is this
|
|
172
|
+
endpoint scoped to the caller's tenant, is this check the right check — need you
|
|
173
|
+
to read the handler and compare it against its neighbours. Endpoints in the same
|
|
174
|
+
file that disagree with each other are where the defects are.
|
|
175
|
+
|
|
176
|
+
{prove}
|
|
177
|
+
|
|
178
|
+
{filing}"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def surface(config: SystemConfig, mode: str) -> str:
|
|
182
|
+
p = _profile(config)
|
|
183
|
+
if not p.environment.is_reachable or not p.environment.web_url:
|
|
184
|
+
return f"""There is no running UI for the application at `{_where(config)}`, so there is
|
|
185
|
+
nothing for you to explore.
|
|
186
|
+
|
|
187
|
+
Report that in your final message and stop. Do not substitute reading the
|
|
188
|
+
frontend source for driving it: your entire value is that you see what a user
|
|
189
|
+
sees, and a static read of a component is a different, weaker claim that another
|
|
190
|
+
agent is better placed to make."""
|
|
191
|
+
|
|
192
|
+
scope = (
|
|
193
|
+
"Walk the primary journeys from the task graph only. This run is time-boxed, "
|
|
194
|
+
"so depth on the critical paths beats coverage."
|
|
195
|
+
if mode == "pr-check"
|
|
196
|
+
else "Walk the primary journeys from the task graph first, then explore. "
|
|
197
|
+
"Exploratory wandering is where you find what nobody wrote a test for — "
|
|
198
|
+
"take the paths a confused or impatient user would take."
|
|
199
|
+
)
|
|
200
|
+
return f"""Explore the running product at {p.environment.web_url} as a user experiences it.
|
|
201
|
+
|
|
202
|
+
{p.description.strip()}
|
|
203
|
+
|
|
204
|
+
Start with `get_system_map` for `ui_routes` and `task_graph`. That is your itinerary.
|
|
205
|
+
|
|
206
|
+
{_environment_brief(p)}
|
|
207
|
+
|
|
208
|
+
{_auth_brief(p)}
|
|
209
|
+
|
|
210
|
+
{scope}
|
|
211
|
+
|
|
212
|
+
At every step: read the page, check the console, interact, observe what changed.
|
|
213
|
+
When something is wrong, find the shortest path to it, then capture a screenshot
|
|
214
|
+
and the console output as evidence before moving on.
|
|
215
|
+
|
|
216
|
+
Judge like a user, not like a reviewer with opinions about the code. Report what
|
|
217
|
+
fails, misleads, blocks or excludes someone. Do not report what you would have
|
|
218
|
+
designed differently."""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def forge(envelope: DefectEnvelope, config: SystemConfig, flake_runs: int) -> str:
|
|
222
|
+
p = _profile(config)
|
|
223
|
+
evidence = "\n".join(f" - {e.type.value}: {e.uri} {e.note}".rstrip() for e in envelope.evidence)
|
|
224
|
+
managed = p.environment.is_managed
|
|
225
|
+
pinning = (
|
|
226
|
+
"Pin the environment with `env_control` — a known fixture, known flags, a "
|
|
227
|
+
"known branch — so the reproduction runs identically later."
|
|
228
|
+
if managed
|
|
229
|
+
else "You cannot reset this environment, so pin what you can and record the "
|
|
230
|
+
"rest: note in `environment` exactly what state you found it in. An "
|
|
231
|
+
"unpinnable reproduction is worth recording as such, not worth faking."
|
|
232
|
+
)
|
|
233
|
+
return f"""Reproduce this finding, or demote it.
|
|
234
|
+
|
|
235
|
+
id: {envelope.id}
|
|
236
|
+
reported by {envelope.discovered_by} as {envelope.severity.value} / {envelope.domain.value}
|
|
237
|
+
title: {envelope.title}
|
|
238
|
+
summary: {envelope.summary}
|
|
239
|
+
location: {envelope.location.model_dump(exclude_none=True)}
|
|
240
|
+
confidence: {envelope.confidence:.2f}
|
|
241
|
+
evidence:
|
|
242
|
+
{evidence or " (none attached)"}
|
|
243
|
+
|
|
244
|
+
The application is at `{_where(config)}`, which is your working directory. {pinning}
|
|
245
|
+
|
|
246
|
+
Find the shortest path that makes the defect appear. Write a failing test for it
|
|
247
|
+
under `qa/repro/` on a `qa/repro/*` branch, and run it {flake_runs} times with
|
|
248
|
+
`run_n_times` to measure flake.
|
|
249
|
+
|
|
250
|
+
Finish by calling `record_reproduction` with your verdict. If you could not make
|
|
251
|
+
it happen, say `not_reproducible` and lower the confidence to match. That is a
|
|
252
|
+
useful, correct outcome — it is the filter this whole system depends on, and
|
|
253
|
+
passing through a finding you could not reproduce costs more than dropping a
|
|
254
|
+
real one."""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def clerk(config: SystemConfig, cap: int) -> str:
|
|
258
|
+
p = _profile(config)
|
|
259
|
+
owners = (
|
|
260
|
+
f"Resolve component and team from the system map's ownership section, which "
|
|
261
|
+
f"came from `{p.layout.ownership}`."
|
|
262
|
+
if p.layout.ownership
|
|
263
|
+
else "This repository records no ownership, so file unassigned and say so. "
|
|
264
|
+
"Do not infer a team from a directory name."
|
|
265
|
+
)
|
|
266
|
+
return f"""Triage this run's findings and file what deserves to be filed.
|
|
267
|
+
|
|
268
|
+
Call `list_envelopes` with `fileable_only: true` to see what reached you.
|
|
269
|
+
Findings that failed the evidence or confidence gate are not in that list and are
|
|
270
|
+
not yours to file — they are already in the human review queue.
|
|
271
|
+
|
|
272
|
+
For each one, in this order:
|
|
273
|
+
|
|
274
|
+
1. `search_similar` and `get_occurrences` first. An existing ticket gets the new
|
|
275
|
+
evidence and an incremented count, not a second ticket.
|
|
276
|
+
2. Score severity against the rubric, by consequence to users.
|
|
277
|
+
3. {owners}
|
|
278
|
+
4. Compose the ticket in the house format, with FORGE's steps verbatim and the
|
|
279
|
+
failing test as the acceptance criterion.
|
|
280
|
+
5. Route by class. Security findings go to the restricted project.
|
|
281
|
+
6. `record` the defect in memory with its ticket key, so the next run dedupes
|
|
282
|
+
against it.
|
|
283
|
+
|
|
284
|
+
You may file at most {cap} tickets in this run. If you reach that, stop and
|
|
285
|
+
escalate rather than filing more."""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def proof(ticket_key: str, envelope: DefectEnvelope | None, branch: str) -> str:
|
|
289
|
+
repro = ""
|
|
290
|
+
if envelope:
|
|
291
|
+
steps = "\n".join(f" {i}. {s}" for i, s in enumerate(envelope.reproduction.steps, 1))
|
|
292
|
+
repro = f"""
|
|
293
|
+
The original finding:
|
|
294
|
+
|
|
295
|
+
title: {envelope.title}
|
|
296
|
+
failing test: {envelope.reproduction.failing_test or "(none recorded)"}
|
|
297
|
+
environment: {envelope.reproduction.environment.model_dump()}
|
|
298
|
+
steps:
|
|
299
|
+
{steps or " (none recorded)"}
|
|
300
|
+
"""
|
|
301
|
+
return f"""Verify the fix on ticket {ticket_key}, on branch `{branch}`.
|
|
302
|
+
{repro}
|
|
303
|
+
Bring up the patched build with `env_control`, using the same fixture and flags
|
|
304
|
+
as the original reproduction — a different environment proves nothing.
|
|
305
|
+
|
|
306
|
+
Run the original failing test first. It must now pass. Then run the regression
|
|
307
|
+
suite for the affected area, selected with `affected_tests` against the diff.
|
|
308
|
+
|
|
309
|
+
Return exactly one verdict — VERIFIED, NOT_FIXED or REGRESSED — and transition
|
|
310
|
+
the ticket accordingly. Say what you actually observed, including anything you
|
|
311
|
+
skipped or could not run."""
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def mender(
|
|
315
|
+
ticket_key: str,
|
|
316
|
+
envelope: DefectEnvelope | None,
|
|
317
|
+
config: SystemConfig | None = None,
|
|
318
|
+
) -> str:
|
|
319
|
+
"""The fix task. MENDER is Phase 3; the conductor's loop calls this once it exists."""
|
|
320
|
+
where = f"the application at `{_where(config)}`" if config and config.profile else "the target application"
|
|
321
|
+
detail = ""
|
|
322
|
+
if envelope:
|
|
323
|
+
steps = "\n".join(f" {i}. {s}" for i, s in enumerate(envelope.reproduction.steps, 1))
|
|
324
|
+
detail = f"""
|
|
325
|
+
title: {envelope.title}
|
|
326
|
+
summary: {envelope.summary}
|
|
327
|
+
location: {envelope.location.model_dump(exclude_none=True)}
|
|
328
|
+
failing test: {envelope.reproduction.failing_test or "(none recorded)"}
|
|
329
|
+
steps:
|
|
330
|
+
{steps or " (none recorded)"}
|
|
331
|
+
"""
|
|
332
|
+
return f"""Fix ticket {ticket_key} in {where}.
|
|
333
|
+
{detail}
|
|
334
|
+
Read the affected code with the system map for context, then write the smallest
|
|
335
|
+
change that makes the failing test pass. Add a regression test. Run the affected
|
|
336
|
+
suite locally before you open anything.
|
|
337
|
+
|
|
338
|
+
The failing test defines success and you may not edit it. If you believe the test
|
|
339
|
+
itself is wrong, that is an escalation, not a licence to change it.
|
|
340
|
+
|
|
341
|
+
Work on a `fix/*` branch. Open the pull request as a draft with a rollback note.
|
|
342
|
+
Never merge — merge is a human decision."""
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def arbiter(ticket_key: str, envelope: DefectEnvelope | None = None) -> str:
|
|
346
|
+
"""The adversarial review task. ARBITER is Phase 3."""
|
|
347
|
+
context = ""
|
|
348
|
+
if envelope:
|
|
349
|
+
context = (
|
|
350
|
+
f"\n\nThe defect it claims to fix: {envelope.title}\n"
|
|
351
|
+
f"The test that defines success: {envelope.reproduction.failing_test or '(none recorded)'}\n"
|
|
352
|
+
)
|
|
353
|
+
return f"""Review the fix for {ticket_key} as an adversarial reviewer.{context}
|
|
354
|
+
|
|
355
|
+
Does the change address the root cause or only the symptom? Is the diff minimal?
|
|
356
|
+
Does it break a contract, a schema, or a public API? Does it introduce a security
|
|
357
|
+
or performance regression? Are the regression tests real, or asserted to pass?
|
|
358
|
+
Is the rollback note viable?
|
|
359
|
+
|
|
360
|
+
Return APPROVE, REQUEST_CHANGES with specifics, or ESCALATE_TO_HUMAN. You have no
|
|
361
|
+
write access to code: your judgement is the deliverable."""
|
qaas/trace.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Reading the run ledger back: the timeline behind `qaas trace` and `qaas show`.
|
|
2
|
+
|
|
3
|
+
The ledger has always been the richest thing a run produces -- every dispatch,
|
|
4
|
+
every tool call, every guardrail refusal, every verdict, appended in order (§8).
|
|
5
|
+
Nothing could read it. `qaas show` looked at exactly one of the 28 kinds
|
|
6
|
+
(`denial`) and printed no cost, no mode, no duration, no verdicts. So the audit
|
|
7
|
+
trail existed and the audit did not.
|
|
8
|
+
|
|
9
|
+
This module is *read-only over an append-only file*. It never writes, and it
|
|
10
|
+
holds no opinion about what a run should have done -- it renders what the run
|
|
11
|
+
recorded. The one performance rule that shapes it: `RunStore.ledger(kind)` is a
|
|
12
|
+
full-file linear scan, so callers read the file **once** here and filter the
|
|
13
|
+
list in memory, rather than scanning it once per kind of interest.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any, Iterable, Sequence
|
|
21
|
+
|
|
22
|
+
from qaas.store import LedgerEntry, LedgerKind, RunStore
|
|
23
|
+
|
|
24
|
+
#: How much of a rendered detail line to keep before truncating. A `verdict`
|
|
25
|
+
#: carries paragraphs of observed behaviour and a `denial` carries the agent's
|
|
26
|
+
#: whole Bash command; a timeline that wraps for ten lines is not a timeline.
|
|
27
|
+
DETAIL_WIDTH = 110
|
|
28
|
+
|
|
29
|
+
#: Fields worth showing per kind, in the order they read best. Anything not
|
|
30
|
+
#: listed falls back to "every key, in insertion order" -- a new kind is still
|
|
31
|
+
#: legible before anyone teaches this table about it.
|
|
32
|
+
DETAIL_FIELDS: dict[LedgerKind, tuple[str, ...]] = {
|
|
33
|
+
LedgerKind.RUN_STARTED: ("mode", "agents", "budget_usd", "target_sha", "target_dirty"),
|
|
34
|
+
LedgerKind.RUN_FINISHED: ("agents_run", "cost_usd", "failed", "stopped_early"),
|
|
35
|
+
LedgerKind.AGENT_STARTED: ("model", "task_chars", "task_preview"),
|
|
36
|
+
LedgerKind.AGENT_FINISHED: ("subtype", "cost_usd", "num_turns", "envelopes", "error"),
|
|
37
|
+
LedgerKind.TOOL_CALL: ("tool", "allowed"),
|
|
38
|
+
LedgerKind.DENIAL: ("tool", "reason"),
|
|
39
|
+
LedgerKind.ENVELOPE: ("severity", "domain", "confidence", "envelope_id"),
|
|
40
|
+
LedgerKind.REPRODUCTION: ("status", "fileable", "flake_rate", "envelope_id"),
|
|
41
|
+
LedgerKind.TICKET: ("action", "key", "severity", "envelope_id"),
|
|
42
|
+
LedgerKind.VERDICT: ("ticket_key", "verdict", "observed"),
|
|
43
|
+
LedgerKind.REVIEW: ("ticket_key", "decision", "reasoning"),
|
|
44
|
+
LedgerKind.VERIFIED: ("ticket_key", "reopens"),
|
|
45
|
+
LedgerKind.REOPENED: ("ticket_key", "attempt"),
|
|
46
|
+
LedgerKind.REVIEW_ROUND_TRIP: ("ticket_key", "trip"),
|
|
47
|
+
LedgerKind.ESCALATION: ("reason",),
|
|
48
|
+
LedgerKind.SKIPPED: ("reason",),
|
|
49
|
+
LedgerKind.VCS: ("action", "branch", "path", "sha"),
|
|
50
|
+
LedgerKind.ENV: ("action", "services", "role", "fixture"),
|
|
51
|
+
LedgerKind.SYSTEM_MAP: ("version", "sections"),
|
|
52
|
+
LedgerKind.CONTRACT_TEST: ("endpoint", "path"),
|
|
53
|
+
LedgerKind.DEFECT_MEMORY: ("action", "ticket_key", "fingerprint"),
|
|
54
|
+
LedgerKind.AGENT_ERROR: ("error",),
|
|
55
|
+
LedgerKind.STOP_BLOCKED: ("missing",),
|
|
56
|
+
LedgerKind.CONTRACT_UNMET: ("missing", "reason"),
|
|
57
|
+
LedgerKind.SKILLS_MISSING: ("missing", "declared"),
|
|
58
|
+
LedgerKind.TOOL_ERROR: ("tool",),
|
|
59
|
+
LedgerKind.DRY_RUN: ("tool",),
|
|
60
|
+
LedgerKind.REGRESSION: ("fingerprint", "ticket_key"),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_ledger(store: RunStore) -> list[LedgerEntry]:
|
|
65
|
+
"""The whole ledger, in order, in one pass. Filter the result, not the file."""
|
|
66
|
+
return list(store.ledger())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_kinds(names: Iterable[str]) -> list[LedgerKind]:
|
|
70
|
+
"""Validate `--kind` arguments against the enum.
|
|
71
|
+
|
|
72
|
+
Raises ValueError naming the offender and the legal set, because "no output"
|
|
73
|
+
is what a mistyped filter used to look like and it is indistinguishable from
|
|
74
|
+
"this run has none of those".
|
|
75
|
+
"""
|
|
76
|
+
kinds: list[LedgerKind] = []
|
|
77
|
+
for name in names:
|
|
78
|
+
try:
|
|
79
|
+
kinds.append(LedgerKind(name))
|
|
80
|
+
except ValueError:
|
|
81
|
+
legal = ", ".join(sorted(k.value for k in LedgerKind))
|
|
82
|
+
raise ValueError(f"unknown ledger kind {name!r}. Known kinds: {legal}") from None
|
|
83
|
+
return kinds
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def select(
|
|
87
|
+
entries: Sequence[LedgerEntry],
|
|
88
|
+
*,
|
|
89
|
+
agent: str | None = None,
|
|
90
|
+
kinds: Sequence[LedgerKind] | None = None,
|
|
91
|
+
) -> list[LedgerEntry]:
|
|
92
|
+
"""Filter in memory. Agent match is case-insensitive; agent names are shouted."""
|
|
93
|
+
wanted = set(kinds) if kinds else None
|
|
94
|
+
name = agent.upper() if agent else None
|
|
95
|
+
return [
|
|
96
|
+
e for e in entries
|
|
97
|
+
if (wanted is None or e.kind in wanted)
|
|
98
|
+
and (name is None or (e.agent or "").upper() == name)
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _short(value: Any) -> str:
|
|
103
|
+
if isinstance(value, list):
|
|
104
|
+
return f"[{len(value)}]" if len(value) > 4 else ", ".join(str(v) for v in value)
|
|
105
|
+
if isinstance(value, float):
|
|
106
|
+
return f"{value:.4g}"
|
|
107
|
+
# Newlines are the reason a `verdict` used to be unprintable on one line.
|
|
108
|
+
text = " ".join(str(value).split())
|
|
109
|
+
# A dedupe fingerprint is 71 characters of hex that nobody reads in full; a
|
|
110
|
+
# prefix is still enough to see two entries carry the same one. `qaas trace
|
|
111
|
+
# --json` keeps the whole thing, which is where an exact match belongs.
|
|
112
|
+
if text.startswith("sha256:"):
|
|
113
|
+
return text[: len("sha256:") + 8] + "…"
|
|
114
|
+
return text
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def describe(entry: LedgerEntry, *, width: int = DETAIL_WIDTH) -> str:
|
|
118
|
+
"""One line of detail for one entry, truncated to stay in a column."""
|
|
119
|
+
detail = entry.detail
|
|
120
|
+
fields = DETAIL_FIELDS.get(entry.kind) or tuple(detail)
|
|
121
|
+
bits = []
|
|
122
|
+
for key in fields:
|
|
123
|
+
value = detail.get(key)
|
|
124
|
+
if value is None or value == "" or value == []:
|
|
125
|
+
continue # an absent field is noise; False and 0 are findings
|
|
126
|
+
bits.append(_short(value) if len(fields) == 1 else f"{key}={_short(value)}")
|
|
127
|
+
text = " ".join(bits)
|
|
128
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Row:
|
|
133
|
+
"""One printable line of the timeline.
|
|
134
|
+
|
|
135
|
+
`count` > 1 means consecutive identical-kind entries were folded together.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
at: datetime
|
|
139
|
+
offset_s: float
|
|
140
|
+
agent: str
|
|
141
|
+
kind: str
|
|
142
|
+
detail: str
|
|
143
|
+
cost_usd: float | None = None
|
|
144
|
+
count: int = 1
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _fold_tool_calls(run: list[LedgerEntry]) -> str:
|
|
148
|
+
tools: dict[str, int] = {}
|
|
149
|
+
for e in run:
|
|
150
|
+
tools[str(e.detail.get("tool", "?"))] = tools.get(str(e.detail.get("tool", "?")), 0) + 1
|
|
151
|
+
ranked = sorted(tools.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
152
|
+
shown = ", ".join(f"{t}×{n}" for t, n in ranked[:6])
|
|
153
|
+
return shown + (f", +{len(ranked) - 6} more" if len(ranked) > 6 else "")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def timeline(entries: Sequence[LedgerEntry], *, fold_tool_calls: bool = True) -> list[Row]:
|
|
157
|
+
"""Rows in run order, with cost accumulating.
|
|
158
|
+
|
|
159
|
+
A real run logs ~2400 `tool_call` lines against ~150 of everything else, so
|
|
160
|
+
printing one row each buries the dispatches, denials and verdicts that are
|
|
161
|
+
the point of looking. Consecutive tool calls by the same agent fold into a
|
|
162
|
+
single row that names the tools and how many -- folding only *consecutive*
|
|
163
|
+
runs, so an interleaved denial still lands in the right place and the order
|
|
164
|
+
stays honest. `--json` is exempt: an export must be faithful, not readable.
|
|
165
|
+
"""
|
|
166
|
+
if not entries:
|
|
167
|
+
return []
|
|
168
|
+
origin = entries[0].at
|
|
169
|
+
rows: list[Row] = []
|
|
170
|
+
running = 0.0
|
|
171
|
+
i = 0
|
|
172
|
+
while i < len(entries):
|
|
173
|
+
entry = entries[i]
|
|
174
|
+
span = 1
|
|
175
|
+
if fold_tool_calls and entry.kind == LedgerKind.TOOL_CALL:
|
|
176
|
+
while (
|
|
177
|
+
i + span < len(entries)
|
|
178
|
+
and entries[i + span].kind == LedgerKind.TOOL_CALL
|
|
179
|
+
and entries[i + span].agent == entry.agent
|
|
180
|
+
):
|
|
181
|
+
span += 1
|
|
182
|
+
cost = entry.detail.get("cost_usd") if entry.kind == LedgerKind.AGENT_FINISHED else None
|
|
183
|
+
if isinstance(cost, (int, float)):
|
|
184
|
+
running += float(cost)
|
|
185
|
+
rows.append(
|
|
186
|
+
Row(
|
|
187
|
+
at=entry.at,
|
|
188
|
+
offset_s=(entry.at - origin).total_seconds(),
|
|
189
|
+
agent=entry.agent or "-",
|
|
190
|
+
kind=str(entry.kind),
|
|
191
|
+
detail=describe(entry) if span == 1 else _fold_tool_calls(entries[i : i + span]),
|
|
192
|
+
cost_usd=running if cost is not None else None,
|
|
193
|
+
count=span,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
i += span
|
|
197
|
+
return rows
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class RunSummary:
|
|
202
|
+
"""The header facts about a run, all of them read back from the ledger.
|
|
203
|
+
|
|
204
|
+
Deliberately derived rather than stored: a run that was killed mid-flight
|
|
205
|
+
never wrote `run_finished`, and it is exactly that run someone needs to look
|
|
206
|
+
at. Everything here degrades to None instead of raising.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
run_id: str
|
|
210
|
+
mode: str | None = None
|
|
211
|
+
started: datetime | None = None
|
|
212
|
+
finished: datetime | None = None
|
|
213
|
+
target_sha: str | None = None
|
|
214
|
+
target_dirty: bool | None = None
|
|
215
|
+
budget_usd: float | None = None
|
|
216
|
+
agents: list[str] = field(default_factory=list)
|
|
217
|
+
cost_usd: float = 0.0
|
|
218
|
+
escalations: list[str] = field(default_factory=list)
|
|
219
|
+
#: ticket key -> its latest verdict, or None if PROOF never reached it.
|
|
220
|
+
tickets: dict[str, str | None] = field(default_factory=dict)
|
|
221
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
222
|
+
stopped_early: str | None = None
|
|
223
|
+
completed: bool = False
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def duration_s(self) -> float | None:
|
|
227
|
+
if self.started is None or self.finished is None:
|
|
228
|
+
return None
|
|
229
|
+
return (self.finished - self.started).total_seconds()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def summarise(store: RunStore, entries: Sequence[LedgerEntry] | None = None) -> RunSummary:
|
|
233
|
+
"""Fold a run's ledger into the header `qaas show` prints."""
|
|
234
|
+
entries = list(entries) if entries is not None else read_ledger(store)
|
|
235
|
+
summary = RunSummary(run_id=store.run_id)
|
|
236
|
+
if entries:
|
|
237
|
+
summary.started = entries[0].at
|
|
238
|
+
summary.finished = entries[-1].at
|
|
239
|
+
|
|
240
|
+
for entry in entries:
|
|
241
|
+
summary.counts[str(entry.kind)] = summary.counts.get(str(entry.kind), 0) + 1
|
|
242
|
+
detail = entry.detail
|
|
243
|
+
if entry.kind == LedgerKind.RUN_STARTED:
|
|
244
|
+
summary.mode = detail.get("mode")
|
|
245
|
+
summary.budget_usd = detail.get("budget_usd")
|
|
246
|
+
summary.agents = list(detail.get("agents") or [])
|
|
247
|
+
summary.target_sha = detail.get("target_sha")
|
|
248
|
+
summary.target_dirty = detail.get("target_dirty")
|
|
249
|
+
elif entry.kind == LedgerKind.RUN_FINISHED:
|
|
250
|
+
summary.completed = True
|
|
251
|
+
summary.stopped_early = detail.get("stopped_early")
|
|
252
|
+
elif entry.kind == LedgerKind.ESCALATION:
|
|
253
|
+
reason = detail.get("reason")
|
|
254
|
+
if reason:
|
|
255
|
+
summary.escalations.append(str(reason))
|
|
256
|
+
elif entry.kind == LedgerKind.TICKET and detail.get("key"):
|
|
257
|
+
summary.tickets.setdefault(str(detail["key"]), None)
|
|
258
|
+
elif entry.kind == LedgerKind.VERDICT and detail.get("ticket_key"):
|
|
259
|
+
# Last verdict wins, matching how the conductor itself reads these
|
|
260
|
+
# back (`_latest_verdict`); a reopened ticket is verdicted twice.
|
|
261
|
+
summary.tickets[str(detail["ticket_key"])] = detail.get("verdict")
|
|
262
|
+
elif entry.kind == LedgerKind.VERIFIED and detail.get("ticket_key"):
|
|
263
|
+
summary.tickets[str(detail["ticket_key"])] = "VERIFIED"
|
|
264
|
+
|
|
265
|
+
# Cost comes from the per-invocation result files, not from summing ledger
|
|
266
|
+
# lines: `put_result` writes one file per invocation precisely so repeated
|
|
267
|
+
# agents (FORGE, MENDER) are not under-counted, and this must agree with
|
|
268
|
+
# `qaas runs`.
|
|
269
|
+
summary.cost_usd = store.total_cost_usd()
|
|
270
|
+
return summary
|