qaas-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/conductor.py ADDED
@@ -0,0 +1,527 @@
1
+ """CONDUCTOR — the run state machine.
2
+
3
+ Deliberately not an LLM. §4.1 wants its reasoning shallow (routing, not analysis)
4
+ and §10 makes it the enforcement point for budget and concurrency — and a model
5
+ cannot enforce a budget it is itself spending. Everything here is ordinary code:
6
+ dispatch, phase ordering, concurrency limits, the spend governor, the §8.3 loop
7
+ breakers, retries and escalation.
8
+
9
+ The phases exist because the dependencies are real, not for tidiness:
10
+
11
+ map -> discover -> reproduce -> file
12
+
13
+ Discovery cannot start without the map. Triage cannot start without findings.
14
+ Within a phase, agents are independent and run concurrently up to the mode's cap.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import subprocess
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Callable
25
+
26
+ from qaas.config import AgentSpec, SystemConfig, load_config
27
+ from qaas.envelope import DefectEnvelope
28
+ from qaas.mcp.context import ToolContext
29
+ from qaas.runner import RunOutcome, run_agent
30
+ from qaas.store import RunStore, SystemMapStore
31
+ from qaas import tasks
32
+
33
+
34
+ class BudgetExceeded(RuntimeError):
35
+ """The run hit its spend or wall-clock cap. Not an error — a control working."""
36
+
37
+
38
+ def target_revision(root: Path | str | None) -> dict[str, Any]:
39
+ """What commit of the target this run is looking at, for `run_started`.
40
+
41
+ Without this a run is not pinned to any code: the ledger said which agents
42
+ ran and what they spent, but nothing said *what they read*, so a finding
43
+ could never be replayed against the tree that produced it. Now `qaas show`
44
+ can name the commit.
45
+
46
+ `dirty` is not decoration — a run against an edited working tree is not
47
+ pinned by its sha either, and that has to be visible rather than implied.
48
+
49
+ Never raises. A target that is not a git checkout (or has no git at all) is
50
+ an ordinary, supported state: the fields come back None and the run
51
+ proceeds. Provenance is worth recording, never worth failing a run for.
52
+ """
53
+ if root is None:
54
+ return {"target_root": None, "target_sha": None, "target_dirty": None}
55
+ root = Path(root)
56
+ info: dict[str, Any] = {"target_root": str(root), "target_sha": None, "target_dirty": None}
57
+
58
+ def git(*args: str) -> str | None:
59
+ try:
60
+ proc = subprocess.run(
61
+ ["git", "-C", str(root), *args],
62
+ capture_output=True, text=True, timeout=10, check=False,
63
+ )
64
+ except (OSError, subprocess.SubprocessError):
65
+ return None
66
+ return proc.stdout if proc.returncode == 0 else None
67
+
68
+ sha = git("rev-parse", "HEAD")
69
+ if sha is None:
70
+ return info # not a repo, no git, or an empty repo with no commits yet
71
+ info["target_sha"] = sha.strip()
72
+ status = git("status", "--porcelain")
73
+ if status is not None:
74
+ info["target_dirty"] = bool(status.strip())
75
+ branch = git("rev-parse", "--abbrev-ref", "HEAD")
76
+ if branch:
77
+ info["target_branch"] = branch.strip()
78
+ return info
79
+
80
+
81
+ @dataclass
82
+ class RunReport:
83
+ run_id: str
84
+ mode: str
85
+ outcomes: list[RunOutcome] = field(default_factory=list)
86
+ escalations: list[str] = field(default_factory=list)
87
+ stopped_early: str | None = None
88
+
89
+ @property
90
+ def cost_usd(self) -> float:
91
+ return sum(o.result.cost_usd for o in self.outcomes)
92
+
93
+ @property
94
+ def failed(self) -> list[str]:
95
+ return [o.result.agent for o in self.outcomes if not o.ok]
96
+
97
+ def summary(self) -> dict[str, Any]:
98
+ return {
99
+ "run_id": self.run_id,
100
+ "mode": self.mode,
101
+ "agents_run": len(self.outcomes),
102
+ "failed": self.failed,
103
+ "cost_usd": round(self.cost_usd, 4),
104
+ "escalations": self.escalations,
105
+ "stopped_early": self.stopped_early,
106
+ }
107
+
108
+
109
+ class Budget:
110
+ """The spend and wall-clock governor. Checked before every dispatch."""
111
+
112
+ def __init__(self, max_usd: float, max_seconds: int, *, already_spent: float = 0.0):
113
+ self.max_usd = max_usd
114
+ self.max_seconds = max_seconds
115
+ #: What this run has already cost, including earlier invocations.
116
+ #:
117
+ #: A resumed run (`qaas run --run-id <existing>`) used to start the
118
+ #: counter at zero, so the cap was per *invocation*, not per run --
119
+ #: resume three times against a $20 mode and you could spend $60 while
120
+ #: every individual pass reported itself within budget. One real run
121
+ #: shows the effect: `cost $32.90 of $20.00 budget`. The wall clock is
122
+ #: deliberately NOT carried across: it measures this process, and a run
123
+ #: resumed the next morning has not been running all night.
124
+ self.spent = already_spent
125
+ self.started = time.monotonic()
126
+
127
+ @property
128
+ def elapsed(self) -> float:
129
+ return time.monotonic() - self.started
130
+
131
+ @property
132
+ def remaining_usd(self) -> float:
133
+ return max(0.0, self.max_usd - self.spent)
134
+
135
+ def spend(self, amount: float) -> None:
136
+ self.spent += amount
137
+
138
+ def check(self) -> None:
139
+ if self.spent >= self.max_usd:
140
+ raise BudgetExceeded(f"spend cap reached: ${self.spent:.2f} of ${self.max_usd:.2f}")
141
+ if self.elapsed >= self.max_seconds:
142
+ raise BudgetExceeded(
143
+ f"wall-clock cap reached: {self.elapsed:.0f}s of {self.max_seconds}s"
144
+ )
145
+
146
+ def allowance(self, spec: AgentSpec) -> float:
147
+ """What this agent may spend: its own cap, or what the run has left."""
148
+ return max(0.01, min(spec.max_budget_usd, self.remaining_usd))
149
+
150
+
151
+ class Conductor:
152
+ """Owns one run from trigger to report."""
153
+
154
+ def __init__(
155
+ self,
156
+ config: SystemConfig,
157
+ target_root: Path | None = None,
158
+ *,
159
+ root: Path | str = ".qaas",
160
+ on_event: Callable[[str, dict[str, Any]], None] | None = None,
161
+ tickets: list[str] | None = None,
162
+ ):
163
+ self.config = config
164
+ #: The application under test. Defaults to whatever the active profile
165
+ #: says, which is the answer every caller wants; an explicit path is for
166
+ #: tests and for a run pointed at a clone that has no profile yet.
167
+ self.target_root = Path(target_root) if target_root is not None else config.target_root()
168
+ self.maps = SystemMapStore(root)
169
+ self.root = Path(root)
170
+ self.on_event = on_event
171
+ #: When set, a fix cycle works only these tickets. Verifying ten tickets
172
+ #: costs ten times as much as verifying one, and during development you
173
+ #: almost always want one.
174
+ self.tickets = set(tickets) if tickets else None
175
+
176
+ def _target_root(self) -> Path | None:
177
+ """The checkout under examination, or None when nothing is configured.
178
+
179
+ Deliberately the same value the agents' tools are pointed at, so the
180
+ commit recorded in the ledger is the commit they actually read. This
181
+ used to compute `self.repo_root / config.target_app`; both of those are
182
+ gone -- `repo_root` conflated the qaas project with the target, and
183
+ `target_app` was the demo-shaped default that made the conflation look
184
+ like it worked.
185
+ """
186
+ return self.target_root
187
+
188
+ def _emit(self, kind: str, **detail: Any) -> None:
189
+ if self.on_event:
190
+ self.on_event(kind, detail)
191
+
192
+ def _context(self, store: RunStore, spec: AgentSpec, map_version: str | None) -> ToolContext:
193
+ return ToolContext(
194
+ store=store,
195
+ maps=self.maps,
196
+ config=self.config,
197
+ agent=spec,
198
+ target_root=self.target_root,
199
+ map_version=map_version,
200
+ )
201
+
202
+ # -- the run ----------------------------------------------------------
203
+
204
+ async def run(self, mode: str, *, run_id: str | None = None) -> RunReport:
205
+ specs = {s.name: s for s in self.config.enabled_agents(mode)}
206
+ run_mode = self.config.run_modes[mode]
207
+ store = RunStore(run_id, self.root) if run_id else RunStore.new(self.root)
208
+ # Carry forward what this run id has already spent, so a cap survives a
209
+ # resumption instead of resetting with it.
210
+ budget = Budget(
211
+ run_mode.max_budget_usd,
212
+ run_mode.max_wall_clock_s,
213
+ already_spent=store.total_cost_usd() if run_id else 0.0,
214
+ )
215
+ report = RunReport(run_id=store.run_id, mode=mode)
216
+
217
+ store.log(
218
+ "run_started",
219
+ mode=mode,
220
+ agents=sorted(specs),
221
+ budget_usd=run_mode.max_budget_usd,
222
+ wall_clock_s=run_mode.max_wall_clock_s,
223
+ **target_revision(self._target_root()),
224
+ )
225
+ self._emit("run_started", run_id=store.run_id, mode=mode, agents=sorted(specs))
226
+
227
+ try:
228
+ map_version = await self._phase_map(specs, store, budget, report)
229
+ await self._phase_discover(specs, store, budget, report, mode, map_version)
230
+ await self._phase_reproduce(specs, store, budget, report, map_version)
231
+ if run_mode.files_tickets:
232
+ await self._phase_file(specs, store, budget, report, map_version)
233
+ else:
234
+ store.log("skipped", reason="mode does not file tickets", mode=mode)
235
+ await self._phase_verify(specs, store, budget, report, map_version)
236
+ except BudgetExceeded as exc:
237
+ report.stopped_early = str(exc)
238
+ report.escalations.append(str(exc))
239
+ store.log("escalation", reason=str(exc))
240
+ self._emit("stopped", reason=str(exc))
241
+
242
+ store.log("run_finished", **report.summary())
243
+ self._emit("run_finished", **report.summary())
244
+ return report
245
+
246
+ # -- phases -----------------------------------------------------------
247
+
248
+ async def _phase_map(self, specs, store, budget, report) -> str | None:
249
+ """Publish the map first. Everything downstream reads it."""
250
+ spec = specs.get("CARTOGRAPHER")
251
+ if spec is None:
252
+ return self.maps.latest_version()
253
+
254
+ budget.check()
255
+ before = self.maps.latest_version()
256
+ outcome = await self._dispatch(spec, store, budget, report, tasks.cartographer(self.config), None)
257
+
258
+ version = self.maps.latest_version()
259
+ if version == before or version is None:
260
+ # Everything downstream reads the map. A stale one is a worse failure
261
+ # than a missing one, so say plainly which we are running on.
262
+ note = "CARTOGRAPHER published no map" + (
263
+ f"; continuing on the previous map {before}" if before else "; no map exists"
264
+ )
265
+ report.escalations.append(note)
266
+ store.log("escalation", agent="CARTOGRAPHER", reason=note)
267
+ return version or before
268
+
269
+ async def _phase_discover(self, specs, store, budget, report, mode, map_version) -> None:
270
+ """Discovery agents are independent. Run them concurrently, bounded."""
271
+ discovery = [s for name, s in specs.items() if s.layer == "discovery"]
272
+ if not discovery:
273
+ return
274
+
275
+ builders = {
276
+ "CONDUIT": lambda: tasks.conduit(self.config, mode),
277
+ "SURFACE": lambda: tasks.surface(self.config, mode),
278
+ }
279
+ jobs = [
280
+ (spec, builders[spec.name]())
281
+ for spec in discovery
282
+ if spec.name in builders
283
+ ]
284
+ unknown = [s.name for s in discovery if s.name not in builders]
285
+ if unknown:
286
+ store.log("skipped", reason="no task builder", agents=unknown)
287
+
288
+ await self._gather(jobs, store, budget, report, map_version, self.config.run_modes[mode].max_concurrency)
289
+
290
+ async def _phase_reproduce(self, specs, store, budget, report, map_version) -> None:
291
+ """One FORGE invocation per finding.
292
+
293
+ Separate contexts on purpose: reproducing finding B should not inherit
294
+ whatever FORGE talked itself into while working on finding A.
295
+ """
296
+ spec = specs.get("FORGE")
297
+ if spec is None:
298
+ return
299
+
300
+ drafts = [e for e in store.envelopes() if e.reproduction.status.value == "unattempted"]
301
+ if not drafts:
302
+ store.log("skipped", agent="FORGE", reason="no findings to reproduce")
303
+ return
304
+
305
+ cap = self.config.thresholds.max_findings_per_agent_run
306
+ if len(drafts) > cap:
307
+ note = f"{len(drafts)} findings exceed the per-run cap of {cap}; triaging the most severe"
308
+ report.escalations.append(note)
309
+ store.log("escalation", agent="FORGE", reason=note)
310
+ drafts = sorted(drafts, key=lambda e: (e.severity.rank, -e.confidence))[:cap]
311
+
312
+ jobs = [
313
+ (spec, tasks.forge(draft, self.config, self.config.thresholds.flake_runs))
314
+ for draft in drafts
315
+ ]
316
+ await self._gather(jobs, store, budget, report, map_version, concurrency=2)
317
+
318
+ async def _phase_file(self, specs, store, budget, report, map_version) -> None:
319
+ spec = specs.get("CLERK")
320
+ if spec is None:
321
+ return
322
+ fileable = [
323
+ e for e in store.envelopes()
324
+ if e.is_fileable(self.config.thresholds.min_confidence_to_file)[0]
325
+ ]
326
+ if not fileable:
327
+ store.log("skipped", agent="CLERK", reason="nothing passed the gates")
328
+ return
329
+ cap = min(spec.policy.max_tickets_per_run, self.config.thresholds.max_tickets_per_run)
330
+ await self._dispatch(spec, store, budget, report, tasks.clerk(self.config, cap), map_version)
331
+
332
+ async def _phase_verify(self, specs, store, budget, report, map_version) -> None:
333
+ spec = specs.get("PROOF")
334
+ if spec is None:
335
+ return
336
+ pending = [e for e in store.envelopes() if e.jira.key]
337
+ if self.tickets:
338
+ pending = [e for e in pending if e.jira.key in self.tickets]
339
+ unknown = self.tickets - {e.jira.key for e in store.envelopes() if e.jira.key}
340
+ if unknown:
341
+ store.log("skipped", reason="unknown tickets", tickets=sorted(unknown))
342
+ if not pending:
343
+ store.log("skipped", agent="PROOF", reason="no tickets to verify")
344
+ return
345
+ for envelope in pending:
346
+ budget.check()
347
+ await self._verify_loop(envelope, specs, store, budget, report, map_version)
348
+
349
+ async def _verify_loop(self, envelope, specs, store, budget, report, map_version) -> None:
350
+ """PROOF -> NOT_FIXED -> remediate -> PROOF, bounded by §8.3.
351
+
352
+ The bound is the point. Without `max_proof_reopens` a fix that keeps
353
+ missing the defect cycles until the budget is gone, and the run ends with
354
+ no verdict and no money left to reach one. Escalating after one reopen
355
+ costs a human five minutes; not escalating costs the whole run.
356
+ """
357
+ ticket = envelope.jira.key
358
+ max_reopens = self.config.thresholds.max_proof_reopens
359
+ reopens = 0
360
+
361
+ # The envelope names the *repro* branch, which by construction carries a
362
+ # failing test and no fix -- it is written before any fix exists. Sending
363
+ # PROOF back there after a remediation round made this loop unable to
364
+ # ever reach VERIFIED: MENDER would fix, ARBITER approve, and PROOF
365
+ # re-verify the unfixed branch it had just failed on, burn a reopen and
366
+ # escalate. A live run recorded exactly that ("this branch cannot carry
367
+ # a fix"). Where MENDER put the fix is only knowable after the fact, so
368
+ # it is read back out of the ledger below.
369
+ repro_branch = envelope.reproduction.environment.branch or "main"
370
+ fix_branch: str | None = None
371
+
372
+ while True:
373
+ await self._dispatch(
374
+ specs["PROOF"], store, budget, report,
375
+ tasks.proof(ticket, envelope, branch=fix_branch or repro_branch), map_version,
376
+ )
377
+ verdict = self._latest_verdict(store, ticket)
378
+
379
+ if verdict is None:
380
+ self._escalate(report, store, "PROOF",
381
+ f"{ticket}: PROOF returned no verdict; the ticket stays in review")
382
+ return
383
+ if verdict == "VERIFIED":
384
+ store.log("verified", agent="PROOF", ticket_key=ticket, reopens=reopens)
385
+ return
386
+ if verdict == "REGRESSED":
387
+ self._escalate(report, store, "PROOF",
388
+ f"{ticket}: REGRESSED — the fix broke something else; blocking for a human")
389
+ return
390
+
391
+ # NOT_FIXED from here.
392
+ if reopens >= max_reopens:
393
+ self._escalate(report, store, "PROOF",
394
+ f"{ticket}: still NOT_FIXED after {reopens} reopen(s), the limit. "
395
+ "Escalating rather than cycling further")
396
+ return
397
+
398
+ reopens += 1
399
+ store.log("reopened", agent="PROOF", ticket_key=ticket, attempt=reopens)
400
+ mark = len(list(store.ledger("vcs")))
401
+ if not await self._remediate(envelope, specs, store, budget, report, map_version):
402
+ return
403
+ # Keep the previous branch if this round wrote nothing: a re-verify
404
+ # of the last fix beats silently falling back to the repro branch.
405
+ fix_branch = self._branch_written_since(store, mark) or fix_branch
406
+
407
+ async def _remediate(self, envelope, specs, store, budget, report, map_version) -> bool:
408
+ """MENDER -> ARBITER, bounded. Returns whether a fix is ready to re-verify.
409
+
410
+ Phase 3 agents. In a Phase 1 roster neither exists, so a NOT_FIXED
411
+ verdict escalates to a human immediately — which is correct, and much
412
+ better than the loop silently re-running PROOF against unchanged code.
413
+ """
414
+ ticket = envelope.jira.key
415
+ mender, arbiter = specs.get("MENDER"), specs.get("ARBITER")
416
+
417
+ if mender is None:
418
+ self._escalate(report, store, "PROOF",
419
+ f"{ticket}: NOT_FIXED and no MENDER in this run's roster. "
420
+ "Nothing here can produce a fix; a human takes it from here")
421
+ return False
422
+
423
+ for trip in range(1, self.config.thresholds.max_mender_arbiter_round_trips + 1):
424
+ budget.check()
425
+ await self._dispatch(mender, store, budget, report,
426
+ tasks.mender(ticket, envelope), map_version)
427
+ if arbiter is None:
428
+ return True
429
+
430
+ await self._dispatch(arbiter, store, budget, report,
431
+ tasks.arbiter(ticket, envelope), map_version)
432
+ review = self._latest_review(store, ticket)
433
+ if review == "APPROVE":
434
+ return True
435
+ if review == "ESCALATE_TO_HUMAN":
436
+ self._escalate(report, store, "ARBITER", f"{ticket}: ARBITER escalated the fix")
437
+ return False
438
+ store.log("review_round_trip", agent="ARBITER", ticket_key=ticket, trip=trip)
439
+
440
+ self._escalate(report, store, "ARBITER",
441
+ f"{ticket}: {self.config.thresholds.max_mender_arbiter_round_trips} "
442
+ "MENDER/ARBITER round trips without approval; escalating")
443
+ return False
444
+
445
+ @staticmethod
446
+ def _branch_written_since(store, mark: int) -> str | None:
447
+ """The branch MENDER actually wrote to during one remediation round.
448
+
449
+ Scoped to the ledger entries added since `mark` rather than searched
450
+ run-wide, because a run verifies several tickets against one ledger and
451
+ an earlier ticket's `fix/*` branch is the wrong answer here. The last
452
+ write wins: MENDER ends a successful round on `push` or `open_pr`.
453
+ """
454
+ for entry in reversed(list(store.ledger("vcs"))[mark:]):
455
+ if entry.agent != "MENDER":
456
+ continue
457
+ branch = entry.detail.get("branch")
458
+ if branch:
459
+ return str(branch)
460
+ return None
461
+
462
+ @staticmethod
463
+ def _latest_verdict(store, ticket_key: str) -> str | None:
464
+ """PROOF's verdict is a typed ledger entry, never parsed from prose."""
465
+ verdicts = [e for e in store.ledger("verdict") if e.detail.get("ticket_key") == ticket_key]
466
+ return verdicts[-1].detail.get("verdict") if verdicts else None
467
+
468
+ @staticmethod
469
+ def _latest_review(store, ticket_key: str) -> str | None:
470
+ reviews = [e for e in store.ledger("review") if e.detail.get("ticket_key") == ticket_key]
471
+ return reviews[-1].detail.get("decision") if reviews else None
472
+
473
+ def _escalate(self, report, store, agent: str, note: str) -> None:
474
+ report.escalations.append(note)
475
+ store.log("escalation", agent=agent, reason=note)
476
+ self._emit("escalation", agent=agent, reason=note)
477
+
478
+ # -- dispatch ---------------------------------------------------------
479
+
480
+ async def _gather(self, jobs, store, budget, report, map_version, concurrency: int) -> None:
481
+ """Run jobs concurrently, but stop dispatching once the budget is gone.
482
+
483
+ The semaphore bounds how many run at once; the budget check inside each
484
+ slot means a run that blows its cap stops starting new work rather than
485
+ letting everything already queued through.
486
+ """
487
+ if not jobs:
488
+ return
489
+ sem = asyncio.Semaphore(max(1, concurrency))
490
+ stopped: list[str] = []
491
+
492
+ async def one(spec: AgentSpec, task: str) -> None:
493
+ async with sem:
494
+ if stopped:
495
+ return
496
+ try:
497
+ budget.check()
498
+ except BudgetExceeded as exc:
499
+ stopped.append(str(exc))
500
+ return
501
+ await self._dispatch(spec, store, budget, report, task, map_version)
502
+
503
+ await asyncio.gather(*(one(spec, task) for spec, task in jobs))
504
+ if stopped:
505
+ raise BudgetExceeded(stopped[0])
506
+
507
+ async def _dispatch(self, spec, store, budget, report, task, map_version) -> RunOutcome:
508
+ ctx = self._context(store, spec, map_version)
509
+ allowance = budget.allowance(spec)
510
+
511
+ self._emit("agent_started", agent=spec.name, budget=allowance)
512
+ outcome = await run_agent(
513
+ spec, ctx, task, max_budget_usd=allowance, on_event=self.on_event
514
+ )
515
+
516
+ budget.spend(outcome.result.cost_usd)
517
+ report.outcomes.append(outcome)
518
+ if not outcome.ok:
519
+ note = f"{spec.name} failed: {outcome.result.error or outcome.result.subtype}"
520
+ report.escalations.append(note)
521
+ store.log("escalation", agent=spec.name, reason=note)
522
+ return outcome
523
+
524
+
525
+ def build(config_dir: Path | str = "config", root: Path | str = ".qaas") -> Conductor:
526
+ config = load_config(config_dir)
527
+ return Conductor(config, root=root)