qaas-python 0.0.1__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 (96) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1783 -0
  3. qaas/adapters/vcs.py +555 -0
  4. qaas/cli.py +1757 -0
  5. qaas/config.py +409 -0
  6. qaas/defaults/config/agents/api.yaml +18 -0
  7. qaas/defaults/config/agents/architect.yaml +21 -0
  8. qaas/defaults/config/agents/auditor.yaml +19 -0
  9. qaas/defaults/config/agents/browser.yaml +15 -0
  10. qaas/defaults/config/agents/dba.yaml +20 -0
  11. qaas/defaults/config/agents/fixer.yaml +55 -0
  12. qaas/defaults/config/agents/guide.yaml +23 -0
  13. qaas/defaults/config/agents/load.yaml +26 -0
  14. qaas/defaults/config/agents/mapper.yaml +19 -0
  15. qaas/defaults/config/agents/reporter.yaml +19 -0
  16. qaas/defaults/config/agents/reproducer.yaml +21 -0
  17. qaas/defaults/config/agents/reviewer.yaml +18 -0
  18. qaas/defaults/config/agents/socket.yaml +23 -0
  19. qaas/defaults/config/agents/triage.yaml +20 -0
  20. qaas/defaults/config/agents/verifier.yaml +20 -0
  21. qaas/defaults/config/system.yaml +64 -0
  22. qaas/discover.py +242 -0
  23. qaas/envelope.py +318 -0
  24. qaas/envfile.py +100 -0
  25. qaas/guardrails.py +589 -0
  26. qaas/mcp/__init__.py +0 -0
  27. qaas/mcp/context.py +78 -0
  28. qaas/mcp/contract_diff.py +1011 -0
  29. qaas/mcp/defect_memory.py +495 -0
  30. qaas/mcp/env_control.py +925 -0
  31. qaas/mcp/envelope_server.py +463 -0
  32. qaas/mcp/test_runner.py +842 -0
  33. qaas/mcp/tracker.py +420 -0
  34. qaas/mcp/vcs.py +501 -0
  35. qaas/paths.py +317 -0
  36. qaas/plugin/.claude-plugin/plugin.json +9 -0
  37. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  38. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  39. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  40. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  41. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  42. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  43. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  44. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  45. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  46. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  47. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  48. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  49. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  50. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  51. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  52. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  53. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  54. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  55. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  56. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  57. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  58. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  59. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  60. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  61. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  62. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  63. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  64. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  65. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  66. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  67. qaas/prompts/API.md +44 -0
  68. qaas/prompts/ARCHITECT.md +80 -0
  69. qaas/prompts/AUDITOR.md +62 -0
  70. qaas/prompts/BROWSER.md +46 -0
  71. qaas/prompts/DBA.md +59 -0
  72. qaas/prompts/FIXER.md +55 -0
  73. qaas/prompts/GUIDE.md +94 -0
  74. qaas/prompts/LOAD.md +109 -0
  75. qaas/prompts/MAPPER.md +46 -0
  76. qaas/prompts/REPORTER.md +61 -0
  77. qaas/prompts/REPRODUCER.md +43 -0
  78. qaas/prompts/REVIEWER.md +53 -0
  79. qaas/prompts/SOCKET.md +100 -0
  80. qaas/prompts/TRIAGE.md +45 -0
  81. qaas/prompts/VERIFIER.md +41 -0
  82. qaas/prompts/_shared.md +45 -0
  83. qaas/registry.py +496 -0
  84. qaas/router.py +581 -0
  85. qaas/runner.py +210 -0
  86. qaas/scorecard.py +448 -0
  87. qaas/sdk_compat.py +52 -0
  88. qaas/store.py +323 -0
  89. qaas/target.py +287 -0
  90. qaas/tasks.py +438 -0
  91. qaas/trace.py +342 -0
  92. qaas_python-0.0.1.dist-info/METADATA +429 -0
  93. qaas_python-0.0.1.dist-info/RECORD +96 -0
  94. qaas_python-0.0.1.dist-info/WHEEL +4 -0
  95. qaas_python-0.0.1.dist-info/entry_points.txt +2 -0
  96. qaas_python-0.0.1.dist-info/licenses/LICENSE +21 -0
qaas/cli.py ADDED
@@ -0,0 +1,1757 @@
1
+ """qaas — command line for the multi-agent QA system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ from qaas import trace as trace_mod
18
+ from qaas.paths import Workspace, package_root, packaged_prompts, project_root
19
+ from qaas.config import MAX_MCP_SERVERS_PER_AGENT, load_config, target_files
20
+ from qaas.store import DEFAULT_ROOT, RunStore, SystemMapStore, list_runs
21
+
22
+ #: Colour by what a line *means*, not by which subsystem wrote it: a refusal and
23
+ #: a regression should catch the eye at the same speed in a 2000-line timeline.
24
+ KIND_STYLE = {
25
+ "run_started": "bold", "run_finished": "bold",
26
+ "agent_started": "cyan", "agent_finished": "cyan",
27
+ "denial": "yellow", "stop_blocked": "yellow", "contract_unmet": "yellow",
28
+ "skipped": "dim", "tool_call": "dim", "dry_run": "dim",
29
+ "escalation": "red", "agent_error": "red", "tool_error": "red",
30
+ "regression": "red", "reopened": "red",
31
+ "envelope": "magenta", "reproduction": "magenta", "ticket": "magenta",
32
+ "verdict": "green", "verified": "green", "review": "green",
33
+ }
34
+
35
+ #: Where skills are found, in precedence order. This used to be
36
+ #: `Path(__file__).resolve().parents[2] / ".claude" / "skills"` -- a climb that
37
+ #: lands on the repo root from a source checkout and on
38
+ #: `site-packages/../..` from an install. So `qaas validate` failed for every
39
+ #: pip user (it checks all 30 skills exist), and agents ran with no skills at
40
+ #: all, silently, because a missing skill is an empty listing rather than an
41
+ #: error. Resolved through the workspace now, which searches the project first
42
+ #: and the packaged copy last.
43
+ def _skill_dirs() -> tuple[Path, ...]:
44
+ return Workspace.resolve().skill_dirs
45
+
46
+
47
+ def _skill_path(name: str) -> Path | None:
48
+ for d in _skill_dirs():
49
+ if (d / name / "SKILL.md").is_file():
50
+ return d / name
51
+ return None
52
+
53
+ def _activate_target(system_yaml_text: str, target_name: str) -> str:
54
+ """Set `target:` in a system.yaml, preserving every comment around it.
55
+
56
+ A regex rather than a YAML round-trip because PyYAML discards comments, and
57
+ this file is more comment than configuration -- the comments are what make
58
+ it editable by someone who has never read the source.
59
+ """
60
+ import re
61
+
62
+ if re.search(r"^target:.*$", system_yaml_text, re.M):
63
+ return re.sub(r"^target:.*$", f"target: {target_name}", system_yaml_text, count=1, flags=re.M)
64
+ return f"target: {target_name}\n" + system_yaml_text
65
+
66
+
67
+ def _ledger_path(cfg) -> Path | None:
68
+ """The golden ledger for this target, if it has one.
69
+
70
+ `profile.ledger` has existed since the schema was written; this used to be
71
+ hardcoded to `<target>/defects.yaml`. Most targets have no ledger at all --
72
+ a golden ledger is a property of a *calibration* target, not of every
73
+ application -- so None is the ordinary answer, not a failure.
74
+ """
75
+ profile = getattr(cfg, "profile", None)
76
+ declared = getattr(profile, "ledger", None) if profile else None
77
+ root = cfg.target_root()
78
+ if declared:
79
+ return root / declared
80
+ fallback = root / "defects.yaml"
81
+ return fallback if fallback.exists() else None
82
+
83
+
84
+ def _system_yaml(config_dir: Path | str | None) -> Path:
85
+ """The system.yaml actually in force, for messages that name it."""
86
+ if config_dir is not None:
87
+ return Path(config_dir) / "system.yaml"
88
+ ws = Workspace.resolve()
89
+ found = ws.config_file("system.yaml")
90
+ return found or (ws.state_root / "config" / "system.yaml")
91
+
92
+
93
+ def _writable_targets_dir(config_dir: Path | str | None) -> Path:
94
+ """Where `qaas init` and `qaas run --repo` write a generated profile.
95
+
96
+ The project (`.qaas/config/targets/`), never an installed package: `qaas
97
+ init` must not try to write inside site-packages. Reading does NOT come
98
+ through here -- profiles layer across every config directory, see
99
+ `_target_files`.
100
+ """
101
+ if config_dir is not None:
102
+ return Path(config_dir) / "targets"
103
+ return Workspace.resolve().state_root / "config" / "targets"
104
+
105
+
106
+ def _target_files(config_dir: Path | str | None) -> dict[str, Path]:
107
+ """Every target profile visible, by name, nearest config layer winning.
108
+
109
+ Reading used to be "the first config layer that has a `targets/` directory",
110
+ which is not layering at all: the moment a generated profile landed in
111
+ `.qaas/config/targets/`, every profile in `<project>/config/targets/`
112
+ disappeared from `qaas targets` and from `--target`. Profiles union by
113
+ filename, exactly as agents and skills do.
114
+ """
115
+ dirs = [Path(config_dir)] if config_dir is not None else list(Workspace.resolve().config_dirs)
116
+ return target_files(dirs)
117
+
118
+
119
+ def _load_target(name: str, config_dir: Path | str | None):
120
+ """One profile by name, from wherever the layers put it."""
121
+ from qaas.target import load_target
122
+
123
+ found = _target_files(config_dir)
124
+ if name not in found:
125
+ raise typer.BadParameter(
126
+ f"no target profile '{name}'. Available: {', '.join(sorted(found)) or 'none'}. "
127
+ "Create one with `qaas init <path-to-repo>`."
128
+ )
129
+ return load_target(name, found[name].parent)
130
+
131
+
132
+ #: A repo argument that is a URL rather than a directory. `git@` has no scheme,
133
+ #: so this cannot be a urlparse.
134
+ _REPO_URL = re.compile(r"^(https?://|git@|ssh://)")
135
+
136
+
137
+ def _clone_root(clone_to: Path | str | None) -> Path:
138
+ """Where a cloned target goes.
139
+
140
+ Under `.qaas/targets/`, never into the caller's source tree. `qaas init`
141
+ used to default to a bare `targets/` -- relative to the process cwd -- so
142
+ pointing the tool at a URL from inside your own repository dropped a foreign
143
+ checkout in the middle of it. Run state belongs in the state directory, and
144
+ a clone is run state.
145
+ """
146
+ if clone_to is not None:
147
+ return Path(clone_to).expanduser()
148
+ return Workspace.resolve().state_root / "targets"
149
+
150
+
151
+ def _slug(text: str) -> str:
152
+ """A target name: lowercase, sluggified, bounded. Also names the clone dir."""
153
+ return re.sub(r"[^a-z0-9-]+", "-", text.lower()).strip("-")[:40] or "target"
154
+
155
+
156
+ def _materialise_repo(repo: str, clone_to: Path | str | None) -> tuple[Path, str | None]:
157
+ """A repo argument -> (local directory, origin url or None), cloning a URL.
158
+
159
+ Shared by `qaas init` and `qaas run --repo` so a URL means exactly the same
160
+ thing to both: one clone, in one place, reused on the next invocation. A
161
+ second implementation of this would be a second set of rules about where
162
+ someone else's code lands on your disk.
163
+ """
164
+ if not _REPO_URL.match(repo):
165
+ root = Path(repo).expanduser()
166
+ if not root.is_dir():
167
+ console.print(f"[red]not a directory:[/red] {root}")
168
+ raise typer.Exit(1)
169
+ return root, None
170
+
171
+ slug = _slug(re.sub(r"\.git$", "", repo.rstrip("/").split("/")[-1]))
172
+ root = _clone_root(clone_to) / slug
173
+ if root.exists():
174
+ console.print(f"[dim]using existing clone at {root}[/dim]")
175
+ return root, repo
176
+
177
+ root.parent.mkdir(parents=True, exist_ok=True)
178
+ console.print(f"cloning {repo} -> {root}")
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
192
+ if result.returncode != 0:
193
+ shutil.rmtree(root, ignore_errors=True)
194
+ console.print(f"[red]clone failed:[/red] {result.stderr.strip()[:400]}")
195
+ raise typer.Exit(1)
196
+ return root, repo
197
+
198
+
199
+ def _default_branch(root: Path) -> str:
200
+ head = subprocess.run(
201
+ ["git", "-C", str(root), "rev-parse", "--abbrev-ref", "HEAD"],
202
+ capture_output=True, text=True,
203
+ )
204
+ return head.stdout.strip() if head.returncode == 0 and head.stdout.strip() else "main"
205
+
206
+
207
+ def _profile_root_value(root: Path) -> str:
208
+ """How a generated profile should spell its `root`.
209
+
210
+ Relative when the target sits inside the qaas project (portable, and what a
211
+ committed profile wants), absolute otherwise. `build_profile` records
212
+ whatever path it was handed, which may be `../thing` or `./thing` -- and a
213
+ target root that means different things from different working directories
214
+ is not acceptable, because it is what the write-path allowlist is anchored
215
+ on.
216
+ """
217
+ resolved = root.resolve()
218
+ base = project_root()
219
+ return (
220
+ resolved.relative_to(base).as_posix()
221
+ if resolved.is_relative_to(base)
222
+ else str(resolved)
223
+ )
224
+
225
+
226
+ def _write_profile(profile, out: Path) -> None:
227
+ """Persist a generated profile."""
228
+ import yaml as _yaml
229
+
230
+ payload = profile.model_dump(exclude_none=True, exclude_defaults=False)
231
+ payload.pop("ledger", None)
232
+ payload["root"] = _profile_root_value(Path(profile.root))
233
+ out.parent.mkdir(parents=True, exist_ok=True)
234
+ out.write_text(
235
+ "# Target profile. Everything here was guessed by inspection — review it.\n"
236
+ "# Credentials never belong in this file: reference environment variables.\n\n"
237
+ + _yaml.safe_dump(payload, sort_keys=False, width=88)
238
+ , encoding="utf-8")
239
+
240
+
241
+ def _provision_target(
242
+ repo: str,
243
+ *,
244
+ name: str | None = None,
245
+ api_url: str | None = None,
246
+ web_url: str | None = None,
247
+ clone_to: Path | str | None = None,
248
+ config_dir: Path | str | None = None,
249
+ force: bool = False,
250
+ reuse_existing: bool = False,
251
+ ) -> tuple[Any, str, Path, list[str], bool]:
252
+ """Make sure a target profile exists for `repo`, cloning it if it is a URL.
253
+
254
+ Returns `(profile, target_name, profile_path, notes, wrote)`.
255
+
256
+ `reuse_existing` is the whole difference between the two callers. `qaas
257
+ init` is a setup command and refuses to clobber a profile you may have spent
258
+ time correcting; `qaas run --repo <url>` has to be idempotent, because
259
+ pointing it at the same URL twice should run twice rather than fail the
260
+ second time. Both go through here so a URL, a clone location and a target
261
+ name mean one thing in this system rather than two.
262
+ """
263
+ from qaas.discover import build_profile
264
+ from qaas.target import Environment, load_target
265
+
266
+ root, repo_url = _materialise_repo(repo, clone_to)
267
+ target_name = _slug(name or root.resolve().name)
268
+ out = _writable_targets_dir(config_dir) / f"{target_name}.yaml"
269
+
270
+ # Any layer, not just the writable one: a profile the user hand-wrote in
271
+ # `<project>/config/targets/` is exactly the kind that must not be clobbered.
272
+ existing = _target_files(config_dir).get(target_name)
273
+ if existing is not None and not force:
274
+ if reuse_existing:
275
+ return load_target(target_name, existing.parent), target_name, existing, [], False
276
+ console.print(f"[red]{existing} already exists.[/red] Use --force to overwrite.")
277
+ raise typer.Exit(1)
278
+
279
+ profile, notes = build_profile(
280
+ target_name, root, repo_url=repo_url, default_branch=_default_branch(root)
281
+ )
282
+ if api_url or web_url:
283
+ profile = profile.model_copy(
284
+ update={"environment": Environment(mode="external", api_url=api_url, web_url=web_url)}
285
+ )
286
+ _write_profile(profile, out)
287
+ # Re-read it: the file is what every later command loads, and a profile that
288
+ # round-trips differently from the one in memory is a bug that only shows up
289
+ # on the *next* invocation.
290
+ return load_target(target_name, out.parent), target_name, out, notes, True
291
+
292
+
293
+ app = typer.Typer(add_completion=False, help="Multi-agent QA & remediation system.")
294
+ console = Console()
295
+
296
+
297
+ @app.callback()
298
+ def _bootstrap() -> None:
299
+ """Runs before every command. Loads credentials from `.env`, if there is one.
300
+
301
+ Credentials come from the environment and never from `config/`, which is
302
+ committed -- that rule stands. This only makes it liveable: four exports in
303
+ every new shell is how a real token ends up pasted into a config file. An
304
+ already-exported variable always wins, so nothing here can override what an
305
+ operator typed on the command line.
306
+ """
307
+ from qaas.envfile import load_env_file
308
+
309
+ load_env_file()
310
+
311
+ #: None means "let the workspace decide" -- an explicit --config, then the
312
+ #: project, then the defaults that shipped in the wheel. A literal "config"
313
+ #: default meant every command outside this repo died on a missing directory.
314
+ ConfigDir = typer.Option(None, "--config", "-c", help="Config directory.")
315
+ Root = typer.Option(DEFAULT_ROOT, "--root", help="Runtime state directory.")
316
+
317
+
318
+ @app.command()
319
+ def init(
320
+ repo: str = typer.Argument(..., help="Path to a local repository, or a git URL to clone."),
321
+ name: str = typer.Option(None, "--name", "-n", help="Target name. Defaults to the directory name."),
322
+ api_url: str = typer.Option(None, "--api-url", help="Base URL of a running API, if there is one."),
323
+ web_url: str = typer.Option(None, "--web-url", help="Base URL of a running UI, if there is one."),
324
+ clone_to: Path = typer.Option(None, "--clone-to", help="Where to clone, for a git URL. Default: <state>/targets/."),
325
+ config_dir: Path | None = ConfigDir,
326
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing profile."),
327
+ ) -> None:
328
+ """Point this system at a repository by writing a target profile.
329
+
330
+ Everything it writes is a guess you are expected to review. Nothing runs and
331
+ nothing is called until you do.
332
+ """
333
+ profile, target_name, out, notes, _ = _provision_target(
334
+ repo,
335
+ name=name,
336
+ api_url=api_url,
337
+ web_url=web_url,
338
+ clone_to=clone_to,
339
+ config_dir=config_dir,
340
+ force=force,
341
+ )
342
+
343
+ # Activate the profile. This used to be step 3 of a printed checklist --
344
+ # "set `target: x` in config/system.yaml" -- which was impossible outside
345
+ # this repo, because there was no system.yaml to edit and no way to make
346
+ # one. A setup step that ends by asking the human to go and edit a file has
347
+ # not set anything up.
348
+ project_config = out.parent.parent
349
+ system_yaml = project_config / "system.yaml"
350
+ if not system_yaml.exists():
351
+ shipped = Workspace.resolve().config_file("system.yaml")
352
+ base = shipped.read_text(encoding="utf-8") if shipped else "project: qaas\n"
353
+ system_yaml.write_text(
354
+ _activate_target(base, target_name)
355
+ if shipped
356
+ else f"project: qaas\ntarget: {target_name}\n"
357
+ , encoding="utf-8")
358
+ wrote_system = True
359
+ else:
360
+ system_yaml.write_text(_activate_target(system_yaml.read_text(encoding="utf-8"), target_name))
361
+ wrote_system = False
362
+
363
+ # `.qaas/` now holds a user's committed config next to their disposable run
364
+ # state, so the obvious `.gitignore` line for `.qaas/` would drop the
365
+ # configuration too. Spell out which half is which.
366
+ gitignore = project_config.parent / ".gitignore"
367
+ if not gitignore.exists():
368
+ gitignore.write_text(
369
+ "# Run state: regenerated every run, never worth committing.\n"
370
+ "runs/\ntickets/\ngenerated/\nsystem-map/\nmemory.db\nartifacts/\n"
371
+ "\n# config/ is NOT ignored -- it is yours, and it is the point.\n"
372
+ , encoding="utf-8")
373
+
374
+ console.print(f"\n[green]wrote {out}[/green]")
375
+ console.print(
376
+ f"[green]{'wrote' if wrote_system else 'updated'} {system_yaml}[/green] "
377
+ f"[dim](target: {target_name})[/dim]\n"
378
+ )
379
+ table = Table(header_style="bold", show_header=True)
380
+ table.add_column("detected")
381
+ table.add_column("value")
382
+ for label, value in (
383
+ ("backend", ", ".join(profile.layout.backend) or "-"),
384
+ ("frontend", ", ".join(profile.layout.frontend) or "-"),
385
+ ("tests", ", ".join(profile.layout.tests) or "-"),
386
+ ("api spec", profile.layout.spec or "-"),
387
+ ("ownership", profile.layout.ownership or "-"),
388
+ ("environment", profile.environment.mode),
389
+ ):
390
+ table.add_row(label, value)
391
+ console.print(table)
392
+
393
+
394
+ for note in notes:
395
+ console.print(f"[yellow]note:[/yellow] {note}")
396
+
397
+ console.print(
398
+ f"\n[bold]next[/bold]\n"
399
+ f" 1. Read {out} and correct anything wrong.\n"
400
+ f" 2. If the app runs somewhere, set environment.mode and the URLs, and fill in auth.\n"
401
+ f" 3. `qaas doctor` to check readiness, then `qaas run --mode pr-check --dry-run`.\n"
402
+ )
403
+
404
+
405
+ @app.command()
406
+ def targets(config_dir: Path | None = ConfigDir) -> None:
407
+ """List the target profiles this system knows about."""
408
+ from qaas.target import load_target
409
+
410
+ found = _target_files(config_dir)
411
+ if not found:
412
+ console.print("[dim]no targets yet — run `qaas init <path-to-repo>`[/dim]")
413
+ return
414
+ active = load_config(config_dir).target
415
+ table = Table(header_style="bold")
416
+ for col in ("target", "root", "environment", "scored"):
417
+ table.add_column(col)
418
+ for n in sorted(found):
419
+ p = load_target(n, found[n].parent)
420
+ table.add_row(
421
+ f"[bold]{n}[/bold] (active)" if n == active else n,
422
+ p.root,
423
+ p.environment.mode,
424
+ "yes" if p.ledger else "no",
425
+ )
426
+ console.print(table)
427
+
428
+
429
+ @app.command()
430
+ def doctor(
431
+ config_dir: Path | None = ConfigDir,
432
+ target: str = typer.Option(None, "--target", "-t", help="Check this profile instead of the active one."),
433
+ ) -> None:
434
+ """Check whether a target is ready to run against."""
435
+ cfg = load_config(config_dir, target=target)
436
+ profile = _load_target(target, config_dir) if target else cfg.profile
437
+ if profile is None:
438
+ console.print("[red]no target profile loaded[/red]")
439
+ raise typer.Exit(1)
440
+
441
+ console.print(f"[bold]{profile.name}[/bold] {profile.root}")
442
+ if profile.description:
443
+ console.print(f"[dim]{profile.description.strip()}[/dim]")
444
+
445
+ caps = profile.capabilities()
446
+ table = Table(header_style="bold")
447
+ table.add_column("capability")
448
+ table.add_column("", justify="center")
449
+ table.add_column("meaning")
450
+ meanings = {
451
+ "static_analysis": "read the code, schema and spec",
452
+ "spec_diff": "compare the implementation against a declared contract",
453
+ "live_api": "call the API and observe real responses",
454
+ "live_ui": "drive the UI in a browser",
455
+ "reset_state": "seed and reset between checks",
456
+ "impersonate": "act as different roles",
457
+ "scored": "measure recall against a golden ledger",
458
+ }
459
+ for cap, ok in caps.items():
460
+ table.add_row(cap, "[green]yes[/green]" if ok else "[dim]no[/dim]", meanings[cap])
461
+ console.print(table)
462
+
463
+ usable = [name for name, spec in sorted(cfg.agents.items()) if _agent_usable(spec, caps)]
464
+ blocked = [n for n in sorted(cfg.agents) if n not in usable]
465
+ console.print(f"\nagents that can work here: [green]{', '.join(usable)}[/green]")
466
+ if blocked:
467
+ console.print(f"agents that cannot: [yellow]{', '.join(blocked)}[/yellow]")
468
+
469
+ problems = profile.readiness()
470
+ if problems:
471
+ console.print("\n[red]not ready:[/red]")
472
+ for p in problems:
473
+ console.print(f" - {p}")
474
+ raise typer.Exit(1)
475
+ console.print("\n[green]ready[/green]")
476
+
477
+
478
+ def _agent_usable(spec, caps: dict[str, bool]) -> bool:
479
+ """Delegates to `target.agent_usable`, which the router also uses.
480
+
481
+ Two copies of this rule meant `qaas doctor` could report an agent unusable
482
+ while a run dispatched it anyway.
483
+ """
484
+ from qaas.target import agent_usable
485
+
486
+ return agent_usable(spec.name, caps)
487
+
488
+
489
+ @app.command()
490
+ def validate(config_dir: Path | None = ConfigDir) -> None:
491
+ """Check config, prompts, and tool allowlists without calling the API."""
492
+ try:
493
+ cfg = load_config(config_dir)
494
+ except Exception as exc:
495
+ console.print(f"[red]config invalid:[/red] {exc}")
496
+ raise typer.Exit(1)
497
+
498
+ # Every prompt layer, not just the first. This used to check `prompt_dirs[0]`
499
+ # alone, so a project that overrode one prompt -- putting a `.qaas/prompts/`
500
+ # directory at the head of the search path -- made `qaas validate` report the
501
+ # other seven as missing, when they resolve perfectly well from the package.
502
+ from qaas.registry import SHARED_PROMPT
503
+
504
+ ws = Workspace.resolve()
505
+ problems: list[str] = []
506
+ notes: list[str] = []
507
+ if ws.prompt_file(SHARED_PROMPT) is None:
508
+ problems.append(f"no {SHARED_PROMPT} on the prompt search path")
509
+ for name, spec in sorted(cfg.agents.items()):
510
+ if ws.prompt_file(spec.prompt) is None:
511
+ problems.append(f"{name}: missing prompt file {spec.prompt}")
512
+ if not spec.mcp_servers and not spec.builtin_tools:
513
+ problems.append(f"{name}: has no tools at all")
514
+ if spec.policy.may_create_tickets and spec.policy.max_tickets_per_run <= 0:
515
+ problems.append(f"{name}: may create tickets but has no per-run cap")
516
+ for skill in spec.skills:
517
+ if _skill_path(skill) is None:
518
+ problems.append(f"{name}: names skill '{skill}' with no SKILL.md")
519
+ for tool in spec.must_call:
520
+ if tool.startswith("mcp__") and tool.split("__")[1] not in spec.mcp_servers:
521
+ problems.append(f"{name}: must_call '{tool}' but lacks that server")
522
+
523
+ # A mode whose agents cannot fit inside its cap is a mode that stops
524
+ # partway through, every time, and looks like it worked: agents run,
525
+ # findings reach the ledger, nothing errors. `pr-check` shipped that way --
526
+ # $6 cap against a $15 roster, so discovery spent $6.09 and REPRODUCER and TRIAGE
527
+ # never dispatched. The mode meant for every pull request could not file a
528
+ # ticket. It took a live run to notice; this check makes it free.
529
+ for mode_name, mode in sorted(cfg.run_modes.items()):
530
+ # Only meaningful when both sides declare a cap. The shipped config
531
+ # declares none, so this check simply does not fire there.
532
+ agent_caps = [
533
+ cfg.agents[a].max_budget_usd for a in mode.agents
534
+ if a in cfg.agents and cfg.agents[a].max_budget_usd is not None
535
+ ]
536
+ needed = sum(agent_caps)
537
+ if mode.max_budget_usd is not None and agent_caps and needed > mode.max_budget_usd:
538
+ missing = [a for a in mode.agents if a in cfg.agents][-1]
539
+ problems.append(
540
+ f"mode '{mode_name}': the agents' caps exceed the mode's cap, so "
541
+ f"its cap, so the run stops before it reaches "
542
+ f"{missing} and files nothing. Raise max_budget_usd or drop an agent"
543
+ )
544
+
545
+ # Skills nobody uses are a note, not a problem. Thirty skills ship in the
546
+ # wheel; someone running a two-agent roster would otherwise see twenty
547
+ # "orphans" and a non-zero exit from `qaas validate` on a fresh install --
548
+ # which is the exact failure this whole exercise exists to remove. An agent
549
+ # naming a skill that is NOT on disk stays a hard error, above.
550
+ referenced = {s for spec in cfg.agents.values() for s in spec.skills}
551
+ on_disk = {name for d in _skill_dirs() for p in d.glob("*/SKILL.md") for name in [p.parent.name]}
552
+ orphans = on_disk - referenced
553
+ if orphans:
554
+ notes.append(f"{len(orphans)} skill(s) on disk that no agent in this config uses")
555
+
556
+ table = Table(title="Agents", header_style="bold")
557
+ for col in ("agent", "layer", "model", "servers", "skills", "must call", "writes"):
558
+ table.add_column(col)
559
+ for name, spec in sorted(cfg.agents.items()):
560
+ writes = "read-only" if spec.policy.read_only else _describe_writes(spec)
561
+ table.add_row(
562
+ name,
563
+ spec.layer,
564
+ spec.model,
565
+ f"{len(spec.mcp_servers)}/{MAX_MCP_SERVERS_PER_AGENT}",
566
+ str(len(spec.skills)),
567
+ ", ".join(t.rsplit("__", 1)[-1] for t in spec.must_call) or "-",
568
+ writes,
569
+ )
570
+ console.print(table)
571
+
572
+ # Show every subprocess a run would spawn, and every URL it would reach.
573
+ #
574
+ # Declaring a server grants nothing on its own -- an agent receives one only
575
+ # by naming it in its own `mcp_servers:` list -- but once it does, the tools
576
+ # of that server are allowed wholesale: `build_allowed_tools` grants
577
+ # `mcp__<server>` and the guardrail's only question is whether the agent
578
+ # declared it. qaas cannot police what a third-party server's tools do. So
579
+ # the least this command can do is print what will run, before it runs.
580
+ if cfg.mcp_servers:
581
+ spawn = Table(title="Declared MCP servers", header_style="bold")
582
+ for col in ("name", "kind", "what it runs", "used by"):
583
+ spawn.add_column(col)
584
+ for name, decl in sorted(cfg.mcp_servers.items()):
585
+ users = [a for a, sp in sorted(cfg.agents.items()) if name in sp.mcp_servers]
586
+ if decl.type == "stdio":
587
+ what = " ".join([decl.command, *decl.args])
588
+ else:
589
+ what = decl.url
590
+ spawn.add_row(name, decl.type, what, ", ".join(users) or "[dim]nobody[/dim]")
591
+ console.print(spawn)
592
+ console.print(
593
+ "[dim]These run with this process's environment. A server's tools are "
594
+ "allowed wholesale once an agent names it.[/dim]"
595
+ )
596
+
597
+ for mode, rm in sorted(cfg.run_modes.items()):
598
+ filing = "" if rm.files_tickets else " [dim](no filing)[/dim]"
599
+ console.print(
600
+ f"[bold]{mode}[/bold]: {', '.join(rm.agents)} "
601
+ f"[dim]{rm.max_wall_clock_s}s[/dim]{filing}"
602
+ )
603
+
604
+ if notes:
605
+ console.print("\n[dim]notes:[/dim]")
606
+ for note in notes:
607
+ console.print(f" [dim]{note}[/dim]")
608
+
609
+ if problems:
610
+ console.print("\n[red]problems:[/red]")
611
+ for p in problems:
612
+ console.print(f" - {p}")
613
+ raise typer.Exit(1)
614
+ console.print("\n[green]config ok[/green]")
615
+
616
+
617
+ def _describe_writes(spec) -> str:
618
+ bits = []
619
+ if spec.policy.write_paths:
620
+ bits.append("paths:" + ",".join(spec.policy.write_paths))
621
+ if spec.policy.branch_patterns:
622
+ bits.append("branch:" + ",".join(spec.policy.branch_patterns))
623
+ if spec.policy.may_create_tickets:
624
+ bits.append(f"tickets<={spec.policy.max_tickets_per_run}")
625
+ if spec.policy.may_transition_tickets:
626
+ bits.append("transition")
627
+ if spec.policy.may_open_pr:
628
+ bits.append("open-pr")
629
+ return " ".join(bits)
630
+
631
+
632
+ # -- prompts ----------------------------------------------------------------
633
+ #
634
+ # A prompt is where an agent's judgement is set, and it is the first thing a
635
+ # real user wants to change. Before this group the only way to do that after a
636
+ # `pip install` was to edit site-packages: invisible to git, lost on the next
637
+ # upgrade, and impossible to diff. These three commands make the same edit a
638
+ # file in the project, and `diff` makes an upgrade's divergence visible instead
639
+ # of silent.
640
+
641
+ prompts_app = typer.Typer(
642
+ add_completion=False,
643
+ help="Inspect and override the agent prompts.",
644
+ no_args_is_help=True,
645
+ )
646
+ app.add_typer(prompts_app, name="prompts")
647
+
648
+ #: The name `_shared.md` answers to on the command line.
649
+ SHARED_LABEL = "_shared"
650
+
651
+
652
+ def _prompt_origin(path: Path, ws: Workspace) -> str:
653
+ """Which layer a resolved prompt came from, for a human reading a table."""
654
+ parent = path.resolve().parent
655
+ if parent.is_relative_to(package_root()):
656
+ return "packaged"
657
+ if ws.project and parent.is_relative_to(ws.project.resolve()):
658
+ return "project"
659
+ return "override"
660
+
661
+
662
+ def _eject_dir(ws: Workspace) -> Path:
663
+ """Where `eject` writes. Never inside the installed package.
664
+
665
+ Enforcement, not advice. `state_root` comes from QAAS_HOME, the project, or
666
+ the cwd, and nothing else stops one of those from landing in site-packages
667
+ -- where an edit would survive exactly until the next `pip install
668
+ --upgrade` and then vanish with no trace of ever having been made.
669
+ """
670
+ dest = (ws.state_root / "prompts").resolve()
671
+ if dest.is_relative_to(package_root()):
672
+ console.print(
673
+ f"[red]refusing to write inside the installed package:[/red] {dest}\n"
674
+ "[dim]run this from your project, or set QAAS_HOME.[/dim]"
675
+ )
676
+ raise typer.Exit(1)
677
+ return dest
678
+
679
+
680
+ def _prompt_index(cfg) -> dict[str, str]:
681
+ """Name -> prompt filename, for every agent plus the shared house rules."""
682
+ index = {name: spec.prompt for name, spec in sorted(cfg.agents.items())}
683
+ from qaas.registry import SHARED_PROMPT
684
+
685
+ index[SHARED_LABEL] = SHARED_PROMPT
686
+ return index
687
+
688
+
689
+ def _select_prompts(cfg, agent: str | None) -> list[tuple[str, str]]:
690
+ """Resolve a command-line name to (label, filename) pairs. Everything if None."""
691
+ index = _prompt_index(cfg)
692
+ if agent is None:
693
+ return list(index.items())
694
+ wanted = agent[:-3] if agent.endswith(".md") else agent
695
+ for label, filename in index.items():
696
+ if label.lower() == wanted.lower():
697
+ return [(label, filename)]
698
+ console.print(
699
+ f"[red]unknown prompt '{agent}'[/red] — known: {', '.join(index)}"
700
+ )
701
+ raise typer.Exit(1)
702
+
703
+
704
+ @prompts_app.command("list")
705
+ def prompts_list(config_dir: Path | None = ConfigDir) -> None:
706
+ """Show which prompt file each agent is actually given, and from where."""
707
+ from qaas.registry import SHARED_PROMPT, append_name, append_paths, build_system_prompt
708
+
709
+ cfg = load_config(config_dir)
710
+ ws = Workspace.resolve()
711
+
712
+ table = Table(title="Prompts in force", header_style="bold")
713
+ for col in ("agent", "file", "source", "appended", "total chars"):
714
+ table.add_column(col)
715
+ for name, spec in sorted(cfg.agents.items()):
716
+ found = ws.prompt_file(spec.prompt)
717
+ if found is None:
718
+ table.add_row(name, spec.prompt, "[red]missing[/red]", "-", "-")
719
+ continue
720
+ appends = append_paths(ws.prompt_dirs, spec.prompt)
721
+ table.add_row(
722
+ name,
723
+ spec.prompt,
724
+ _prompt_origin(found, ws),
725
+ append_name(spec.prompt) if appends else "-",
726
+ str(len(build_system_prompt(spec, ws.prompt_dirs))),
727
+ )
728
+ shared = ws.prompt_file(SHARED_PROMPT)
729
+ table.add_row(
730
+ "[dim]every agent[/dim]",
731
+ SHARED_PROMPT,
732
+ _prompt_origin(shared, ws) if shared else "[red]missing[/red]",
733
+ "-",
734
+ str(len(shared.read_text(encoding="utf-8"))) if shared else "-",
735
+ )
736
+ console.print(table)
737
+
738
+ for i, d in enumerate(ws.prompt_dirs):
739
+ console.print(f"[dim]{'*' if i == 0 else ' '} {d}[/dim]")
740
+ console.print(
741
+ "\n[dim]`qaas prompts eject <AGENT>` to edit one outright, or drop a "
742
+ "`<AGENT>.append.md` beside it to add lines without forking the file.[/dim]"
743
+ )
744
+
745
+
746
+ @prompts_app.command("eject")
747
+ def prompts_eject(
748
+ agent: str = typer.Argument(None, help=f"Agent name, or {SHARED_LABEL}. Omit with --all."),
749
+ all_prompts: bool = typer.Option(False, "--all", help="Eject every prompt."),
750
+ force: bool = typer.Option(False, "--force", help="Overwrite a file already there."),
751
+ config_dir: Path | None = ConfigDir,
752
+ ) -> None:
753
+ """Copy a packaged prompt into the project so it can be edited."""
754
+ if agent is None and not all_prompts:
755
+ console.print("[red]name an agent, or pass --all[/red]")
756
+ raise typer.Exit(1)
757
+ if agent is not None and all_prompts:
758
+ console.print("[red]name an agent or pass --all, not both[/red]")
759
+ raise typer.Exit(1)
760
+
761
+ cfg = load_config(config_dir)
762
+ ws = Workspace.resolve()
763
+ dest_dir = _eject_dir(ws)
764
+ selected = _select_prompts(cfg, agent)
765
+
766
+ written: list[Path] = []
767
+ skipped: list[Path] = []
768
+ for _label, filename in selected:
769
+ # The *packaged* bytes, deliberately: eject means "give me the house
770
+ # version to edit". Copying whatever already won the search would make
771
+ # a second eject a no-op that looks like it did something.
772
+ src = packaged_prompts() / filename
773
+ if not src.is_file():
774
+ console.print(f"[red]nothing to eject: {src} does not exist[/red]")
775
+ raise typer.Exit(1)
776
+ dest = dest_dir / filename
777
+ if dest.exists() and not force:
778
+ skipped.append(dest)
779
+ continue
780
+ dest.parent.mkdir(parents=True, exist_ok=True)
781
+ dest.write_text(src.read_text(encoding="utf-8"))
782
+ written.append(dest)
783
+
784
+ for path in written:
785
+ console.print(f"[green]wrote[/green] {path}")
786
+ for path in skipped:
787
+ console.print(f"[yellow]exists, left alone:[/yellow] {path}")
788
+
789
+ if skipped and not written:
790
+ console.print("[dim]use --force to overwrite.[/dim]")
791
+ # One named prompt that was refused is a failed command; --all skipping the
792
+ # files you already ejected is the normal, successful case.
793
+ if skipped and agent is not None:
794
+ raise typer.Exit(1)
795
+ if written:
796
+ console.print(
797
+ "\n[dim]edit them, then `qaas prompts diff` to see what you changed.[/dim]"
798
+ )
799
+
800
+
801
+ @prompts_app.command("diff")
802
+ def prompts_diff(
803
+ agent: str = typer.Argument(None, help="Agent name, or omit for all of them."),
804
+ config_dir: Path | None = ConfigDir,
805
+ ) -> None:
806
+ """Show local prompt edits against the bytes that shipped.
807
+
808
+ Run it after an upgrade: a forked prompt does not conflict, it just quietly
809
+ stops tracking the package, and this is the only place that shows it.
810
+ """
811
+ import difflib
812
+
813
+ from qaas.registry import append_name, append_paths
814
+
815
+ cfg = load_config(config_dir)
816
+ ws = Workspace.resolve()
817
+ changed = 0
818
+
819
+ for label, filename in _select_prompts(cfg, agent):
820
+ in_force = ws.prompt_file(filename)
821
+ packaged = packaged_prompts() / filename
822
+ # Only agent prompts take an addendum; `_shared.append.md` is composed
823
+ # by nothing, so reporting one would describe a file that has no effect.
824
+ appends = [] if label == SHARED_LABEL else append_paths(ws.prompt_dirs, filename)
825
+
826
+ if in_force is not None and packaged.is_file() and in_force != packaged.resolve():
827
+ diff = list(
828
+ difflib.unified_diff(
829
+ packaged.read_text(encoding="utf-8").splitlines(),
830
+ in_force.read_text(encoding="utf-8").splitlines(),
831
+ fromfile=f"packaged/{filename}",
832
+ tofile=str(in_force),
833
+ lineterm="",
834
+ )
835
+ )
836
+ if diff:
837
+ changed += 1
838
+ console.print(f"\n[bold]{label}[/bold]")
839
+ for line in diff:
840
+ style = None
841
+ if line.startswith("+") and not line.startswith("+++"):
842
+ style = "green"
843
+ elif line.startswith("-") and not line.startswith("---"):
844
+ style = "red"
845
+ elif line.startswith("@@"):
846
+ style = "cyan"
847
+ console.print(line, style=style, markup=False, highlight=False)
848
+
849
+ for path in appends:
850
+ changed += 1
851
+ console.print(f"\n[bold]{label}[/bold] [dim]+ {append_name(filename)}[/dim]")
852
+ console.print(f"--- {path}", markup=False, highlight=False)
853
+ for line in path.read_text(encoding="utf-8").splitlines():
854
+ console.print(f"+{line}", style="green", markup=False, highlight=False)
855
+
856
+ if not changed:
857
+ console.print("[dim]no local prompt edits — every prompt is the packaged one[/dim]")
858
+
859
+
860
+ @app.command()
861
+ def runs(root: Path = Root, limit: int = 10) -> None:
862
+ """List recent runs with their cost and finding count."""
863
+ ids = list_runs(root)[:limit]
864
+ if not ids:
865
+ console.print("[dim]no runs yet[/dim]")
866
+ return
867
+ table = Table(header_style="bold")
868
+ for col in ("run", "envelopes", "agents"):
869
+ table.add_column(col)
870
+ for run_id in ids:
871
+ store = RunStore(run_id, root, create=False)
872
+ results = store.results()
873
+ table.add_row(
874
+ run_id,
875
+ str(len(store.envelopes())),
876
+ str(len(results)),
877
+ )
878
+ console.print(table)
879
+
880
+
881
+ #: A verdict is the answer to "did the fix work"; colour it like one.
882
+ VERDICT_STYLE = {
883
+ "VERIFIED": "green",
884
+ "NOT_FIXED": "yellow",
885
+ "REGRESSED": "red",
886
+ }
887
+
888
+
889
+ @app.command()
890
+ def show(run_id: str, root: Path = Root) -> None:
891
+ """Show one run's findings and ledger: cost, duration, tickets, escalations."""
892
+ store = RunStore(run_id, root, create=False)
893
+ # One pass over the ledger, shared by the header and the denial list -- each
894
+ # `store.ledger(kind)` call is a full-file scan of a file that reaches tens
895
+ # of thousands of lines on a real run.
896
+ entries = trace_mod.read_ledger(store)
897
+ summary = trace_mod.summarise(store, entries)
898
+
899
+ console.print(f"[bold]{summary.run_id}[/bold]")
900
+ header = [f"mode {summary.mode or '?'}"]
901
+ if summary.started:
902
+ header.append(f"started {summary.started:%Y-%m-%d %H:%M:%S}Z")
903
+ if summary.duration_s is not None:
904
+ header.append(f"duration {summary.duration_s:.0f}s")
905
+ console.print(" " + " ".join(header))
906
+ if summary.target_sha:
907
+ dirty = " [yellow](dirty tree)[/yellow]" if summary.target_dirty else ""
908
+ console.print(f" target {summary.target_sha[:12]}{dirty}")
909
+ else:
910
+ # Not a warning: `environment.mode: none` targets and non-git checkouts
911
+ # are supported, and runs recorded before this was added have no sha.
912
+ console.print(" [dim]target commit not recorded[/dim]")
913
+ if not summary.completed:
914
+ console.print(" [yellow]no run_finished — this run did not complete[/yellow]")
915
+ if summary.stopped_early:
916
+ console.print(f" [yellow]stopped early: {summary.stopped_early}[/yellow]")
917
+
918
+ envelopes = store.envelopes()
919
+ if envelopes:
920
+ console.print(f"\n[bold]findings ({len(envelopes)})[/bold]")
921
+ for env in envelopes:
922
+ ok, reason = env.is_fileable()
923
+ gate = "[green]fileable[/green]" if ok else f"[yellow]held: {reason}[/yellow]"
924
+ console.print(
925
+ f"[bold]{env.severity.value:8s}[/bold] {env.domain.value:12s} "
926
+ f"{env.title} [dim]({env.discovered_by}, conf {env.confidence:.2f})[/dim] {gate}"
927
+ )
928
+
929
+ if summary.tickets:
930
+ console.print(f"\n[bold]tickets filed ({len(summary.tickets)})[/bold]")
931
+ for key, verdict in summary.tickets.items():
932
+ if verdict is None:
933
+ console.print(f" {key} [dim]no verdict[/dim]")
934
+ else:
935
+ style = VERDICT_STYLE.get(verdict, "white")
936
+ console.print(f" {key} [{style}]{verdict}[/{style}]")
937
+
938
+ if summary.escalations:
939
+ console.print(f"\n[bold red]escalations ({len(summary.escalations)})[/bold red]")
940
+ for reason in summary.escalations:
941
+ console.print(f" {reason}")
942
+
943
+ denials = [e for e in entries if e.kind == "denial"]
944
+ if denials:
945
+ console.print(f"\n[bold]guardrail denials ({len(denials)})[/bold]")
946
+ for d in denials:
947
+ console.print(f" {d.agent}: {d.detail.get('tool')} — {d.detail.get('reason')}")
948
+
949
+ console.print(f"\n[dim]{len(entries)} ledger entries — qaas trace {run_id}[/dim]")
950
+
951
+
952
+ def _follow(store, *, agent: str | None, kinds, as_json: bool, quiet: bool = False) -> None:
953
+ """Stream a live run's ledger until it finishes or the operator stops.
954
+
955
+ Deliberately line-by-line rather than a redrawn table: a run lasts minutes,
956
+ the interesting lines are denials and verdicts, and they must survive being
957
+ scrolled past, piped, and pasted into a bug report. A `rich.Live` view that
958
+ repaints would lose all three.
959
+ """
960
+ from qaas.store import LedgerKind
961
+
962
+ wanted = set(kinds) if kinds else None
963
+ name = agent.upper() if agent else None
964
+ console.print(f"[dim]following {store.run_id} — ctrl-c to stop[/dim]")
965
+ hidden = trace_mod.QUIET_KINDS if quiet else frozenset()
966
+ seen = 0
967
+ try:
968
+ for entry in trace_mod.tail(store):
969
+ if entry.kind in hidden:
970
+ continue
971
+ if wanted is not None and entry.kind not in wanted:
972
+ continue
973
+ if name is not None and (entry.agent or "").upper() != name:
974
+ continue
975
+ seen += 1
976
+ if as_json:
977
+ # Newline-delimited, flushed per line: `--follow --json` exists
978
+ # to be piped into something that reacts, and a buffered pipe
979
+ # that only speaks at the end is not a live feed.
980
+ typer.echo(json.dumps(entry.model_dump(mode="json")), nl=True)
981
+ sys.stdout.flush()
982
+ continue
983
+ style = KIND_STYLE.get(str(entry.kind), "white")
984
+ console.print(
985
+ f"[dim]{entry.at.strftime('%H:%M:%S')}[/dim] "
986
+ f"[cyan]{(entry.agent or '-'):<12}[/cyan] "
987
+ f"[{style}]{str(entry.kind):<16}[/] {trace_mod.describe(entry)}"
988
+ )
989
+ if entry.kind == LedgerKind.RUN_FINISHED:
990
+ console.print("[dim]run finished[/dim]")
991
+ except KeyboardInterrupt:
992
+ console.print(f"\n[dim]stopped following after {seen} entries[/dim]")
993
+
994
+
995
+ @app.command()
996
+ def trace(
997
+ run_id: str,
998
+ root: Path = Root,
999
+ agent: str | None = typer.Option(None, "--agent", "-a", help="Only this agent's entries."),
1000
+ kind: list[str] = typer.Option(None, "--kind", "-k", help="Only these ledger kinds (repeatable)."),
1001
+ as_json: bool = typer.Option(False, "--json", help="Emit the filtered entries as JSON."),
1002
+ follow: bool = typer.Option(False, "--follow", "-f", help="Stream new entries as the run produces them."),
1003
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Drop tool_call lines and show only what an agent decided."),
1004
+ ) -> None:
1005
+ """Print one run's ledger as a timeline: dispatches, tools, denials, verdicts, cost."""
1006
+ store = RunStore(run_id, root, create=False)
1007
+ if not store.ledger_path.exists() and not follow:
1008
+ console.print(f"[red]no ledger for run {run_id}[/red] — try `qaas runs`")
1009
+ raise typer.Exit(1)
1010
+
1011
+ try:
1012
+ kinds = trace_mod.parse_kinds(kind or [])
1013
+ except ValueError as exc:
1014
+ console.print(f"[red]{exc}[/red]")
1015
+ raise typer.Exit(2) from None
1016
+
1017
+ if follow:
1018
+ _follow(store, agent=agent, kinds=kinds, as_json=as_json, quiet=quiet)
1019
+ return
1020
+
1021
+ entries = trace_mod.select(trace_mod.read_ledger(store), agent=agent, kinds=kinds, quiet=quiet)
1022
+
1023
+ if as_json:
1024
+ # Plain stdout, not `console.print_json`: rich soft-wraps at the console
1025
+ # width, which puts newlines inside long string values and hands the
1026
+ # caller JSON that no parser will accept. `--json` exists to be piped.
1027
+ typer.echo(json.dumps([e.model_dump(mode="json") for e in entries], indent=2))
1028
+ return
1029
+
1030
+ if not entries:
1031
+ console.print("[dim]no ledger entries match[/dim]")
1032
+ return
1033
+
1034
+ table = Table(header_style="bold", box=None, pad_edge=False)
1035
+ table.add_column("t+", justify="right", style="dim")
1036
+ table.add_column("agent", style="cyan")
1037
+ table.add_column("kind")
1038
+ table.add_column("detail", overflow="fold")
1039
+ for row in trace_mod.timeline(entries):
1040
+ label = f"{row.kind} ×{row.count}" if row.count > 1 else row.kind
1041
+ table.add_row(
1042
+ f"{row.offset_s:.0f}s",
1043
+ row.agent,
1044
+ f"[{KIND_STYLE.get(row.kind, 'white')}]{label}[/]",
1045
+ row.detail,
1046
+ )
1047
+ console.print(table)
1048
+ console.print(f"\n[dim]{len(entries)} entries[/dim]")
1049
+
1050
+
1051
+ @app.command()
1052
+ def map(root: Path = Root, version: str | None = None) -> None:
1053
+ """Show the system map Mapper produced."""
1054
+ maps = SystemMapStore(root)
1055
+ payload = maps.get(version)
1056
+ if payload is None:
1057
+ console.print("[dim]no system map yet — run MAPPER[/dim]")
1058
+ raise typer.Exit(1)
1059
+ console.print(f"[bold]version[/bold] {version or maps.latest_version()}")
1060
+ console.print_json(data=payload)
1061
+
1062
+
1063
+ @app.command()
1064
+ def run(
1065
+ mode: str = typer.Option(..., "--mode", "-m", help="Run mode from system.yaml."),
1066
+ config_dir: Path | None = ConfigDir,
1067
+ root: Path = Root,
1068
+ only: list[str] = typer.Option(None, "--only", help="Restrict the run to these agents."),
1069
+ target: str = typer.Option(None, "--target", "-t", help="Target profile to run against. Overrides system.yaml."),
1070
+ repo: str = typer.Option(None, "--repo", help="A local path or git URL to run against directly. Clones and profiles it if needed."),
1071
+ clone_to: Path = typer.Option(None, "--clone-to", help="Where to clone, for a git URL. Default: <state>/targets/."),
1072
+ run_id: str = typer.Option(None, "--run-id", help="Continue an existing run rather than starting one."),
1073
+ ticket: list[str] = typer.Option(None, "--ticket", help="Restrict a fix-cycle to these tickets."),
1074
+ dry_run: bool = typer.Option(False, "--dry-run", help="Render the plan without calling the API."),
1075
+ force: bool = typer.Option(False, "--force", help="With --repo: regenerate the target profile instead of reusing it."),
1076
+ ) -> None:
1077
+ """Execute a run. Costs real money unless --dry-run."""
1078
+ import asyncio
1079
+
1080
+ from qaas.router import Router
1081
+ from qaas.registry import describe
1082
+
1083
+ if repo and target:
1084
+ console.print("[red]--repo and --target name two different targets.[/red] Pass one.")
1085
+ raise typer.Exit(1)
1086
+
1087
+ # `--repo` is sugar over `--target`, not a second way to run. It provisions
1088
+ # a profile the same way `qaas init` does and then falls into the ordinary
1089
+ # path, so a URL gets exactly the guardrails, readiness checks and target
1090
+ # root that a hand-written profile gets. It deliberately does NOT rewrite
1091
+ # system.yaml: a one-off run against someone else's repository is not a
1092
+ # decision to repoint the whole installation at it.
1093
+ #
1094
+ # Before the config load, because provisioning is what decides which target
1095
+ # this run is about -- and a stale `target:` in system.yaml naming a profile
1096
+ # that no longer exists would otherwise kill the run inside `load_config`,
1097
+ # before the override just typed on the command line was ever read.
1098
+ profile = None
1099
+ if repo:
1100
+ profile, target, path, notes, wrote = _provision_target(
1101
+ repo,
1102
+ clone_to=clone_to,
1103
+ config_dir=config_dir,
1104
+ force=force,
1105
+ reuse_existing=True,
1106
+ )
1107
+ for note in notes:
1108
+ console.print(f"[yellow]note:[/yellow] {note}")
1109
+ console.print(
1110
+ f"[green]wrote {path}[/green]" if wrote
1111
+ else f"[dim]reusing the existing profile at {path} (--force to regenerate)[/dim]"
1112
+ )
1113
+
1114
+ cfg = load_config(config_dir, target=target)
1115
+ if target:
1116
+ # The profile object we already hold, rather than a second lookup by
1117
+ # name: `_provision_target` may have written into the writable config
1118
+ # layer (`.qaas/config/targets/`) while `load_config` resolves profiles
1119
+ # from a different one, and this run must be about the repository the
1120
+ # operator named, not a same-named profile from another layer.
1121
+ cfg = cfg.model_copy(
1122
+ update={
1123
+ "target": target,
1124
+ "profile": profile or _load_target(target, config_dir),
1125
+ }
1126
+ )
1127
+ if cfg.profile:
1128
+ problems = cfg.profile.readiness()
1129
+ blocking = [p for p in problems if "does not exist" in p or "not a directory" in p]
1130
+ if blocking:
1131
+ console.print(f"[red]target '{cfg.target}' is not usable:[/red]")
1132
+ for p in blocking:
1133
+ console.print(f" - {p}")
1134
+ raise typer.Exit(1)
1135
+ for p in problems:
1136
+ console.print(f"[yellow]warning:[/yellow] {p}")
1137
+ console.print(f"[dim]target: {cfg.target} ({cfg.profile.environment.mode})[/dim]")
1138
+ if only:
1139
+ wanted = {a.upper() for a in only}
1140
+ unknown = wanted - set(cfg.agents)
1141
+ if unknown:
1142
+ console.print(f"[red]unknown agents: {', '.join(sorted(unknown))}[/red]")
1143
+ raise typer.Exit(1)
1144
+ mode_cfg = cfg.run_modes[mode]
1145
+ cfg = cfg.model_copy(
1146
+ update={
1147
+ "run_modes": {
1148
+ **cfg.run_modes,
1149
+ mode: mode_cfg.model_copy(
1150
+ update={"agents": [a for a in mode_cfg.agents if a in wanted]}
1151
+ ),
1152
+ }
1153
+ }
1154
+ )
1155
+ specs = cfg.enabled_agents(mode)
1156
+ rm = cfg.run_modes[mode]
1157
+ console.print(
1158
+ f"[bold]{mode}[/bold] — {len(specs)} agents, "
1159
+ f"concurrency {rm.max_concurrency}"
1160
+ )
1161
+
1162
+ if dry_run:
1163
+ # The same search path the run itself would use, so `prompt: N chars`
1164
+ # counts any override rather than always reporting the packaged bytes.
1165
+ prompt_dirs = Workspace.resolve().prompt_dirs
1166
+ for spec in specs:
1167
+ d = describe(spec, prompt_dirs)
1168
+ console.print(
1169
+ f" [bold]{spec.name:14s}[/bold] {spec.model:18s} effort={spec.effort:7s} "
1170
+ f"turns<={spec.max_turns}"
1171
+ )
1172
+ console.print(f" tools: {', '.join(d['allowed_tools'])}")
1173
+ console.print(f" prompt: {d['prompt_chars']} chars")
1174
+ return
1175
+
1176
+ # Before the first agent, not after the first ticket: the board is what
1177
+ # someone watches a run *on*, and one that appears at the end is a report.
1178
+ board_info = _ensure_board(cfg)
1179
+
1180
+ def on_event(kind: str, detail: dict) -> None:
1181
+ if kind == "agent_started":
1182
+ console.print(f"[dim]->[/dim] {detail.get('agent')}")
1183
+ elif kind == "finished":
1184
+ console.print(
1185
+ f"[dim]<-[/dim] {detail.get('agent')} "
1186
+ f"[dim]{detail.get('envelopes', 0)} findings[/dim]"
1187
+ )
1188
+ elif kind == "stopped":
1189
+ console.print(f"[yellow]stopped: {detail.get('reason')}[/yellow]")
1190
+
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))
1193
+
1194
+ console.print()
1195
+ console.print_json(data=report.summary())
1196
+ console.print(f"\n[dim]watch it back:[/dim] qaas trace {report.run_id}")
1197
+ if board_info is not None and board_info.url:
1198
+ console.print(f"[dim]board:[/dim] {board_info.url}")
1199
+ if report.failed or report.stopped_early:
1200
+ raise typer.Exit(1)
1201
+
1202
+
1203
+ @app.command()
1204
+ def score(
1205
+ run_id: str = typer.Argument(None, help="Run to score. Defaults to the most recent."),
1206
+ config_dir: Path | None = ConfigDir,
1207
+ root: Path = Root,
1208
+ phase: int = typer.Option(1, help="Score against defects seeded for this phase and earlier."),
1209
+ domains: list[str] = typer.Option(
1210
+ None, "--domain", help="Restrict scoring to these domains. Use it when a run covered only part of the surface."
1211
+ ),
1212
+ target: str = typer.Option(
1213
+ None, "--target", "-t", help="Score against this profile's ledger rather than the active one's."
1214
+ ),
1215
+ ) -> None:
1216
+ """Score a run against the golden ledger. This is the honest number."""
1217
+ from qaas.scorecard import GoldenLedger, score as score_run
1218
+
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)})
1227
+ ledger_path = _ledger_path(cfg)
1228
+ if ledger_path is None or not ledger_path.exists():
1229
+ console.print(
1230
+ "[yellow]this target has no golden ledger, so there is nothing to score against.[/yellow]\n"
1231
+ "[dim]A golden ledger lists known defects with their expected domain and severity, and\n"
1232
+ "`qaas score` measures recall and precision against it. It is a property of a\n"
1233
+ "calibration target, not of an ordinary application -- most targets will never\n"
1234
+ "have one. Set `ledger:` in the target profile if yours does; the bundled demo app\n"
1235
+ "ships in the project's git repository, not in the wheel.[/dim]"
1236
+ )
1237
+ raise typer.Exit(1)
1238
+
1239
+ if run_id is None:
1240
+ ids = list_runs(root)
1241
+ if not ids:
1242
+ console.print("[dim]no runs to score[/dim]")
1243
+ raise typer.Exit(1)
1244
+ run_id = ids[0]
1245
+
1246
+ store = RunStore(run_id, root, create=False)
1247
+ card = score_run(
1248
+ store.envelopes(),
1249
+ GoldenLedger.load(ledger_path),
1250
+ phase=phase,
1251
+ domains=set(domains) if domains else None,
1252
+ cost_usd=store.total_cost_usd(),
1253
+ )
1254
+ s = card.summary()
1255
+
1256
+ console.print(f"[bold]{run_id}[/bold]")
1257
+ table = Table(header_style="bold")
1258
+ table.add_column("metric")
1259
+ table.add_column("value", justify="right")
1260
+ table.add_row("found", f"{s['found']} of {s['of']}")
1261
+ table.add_row("recall", f"{s['recall']:.0%}")
1262
+ table.add_row("precision", f"{s['precision']:.0%}")
1263
+ table.add_row("false positives", f"{s['false_positives']} ({s['false_positive_rate']:.0%})")
1264
+ table.add_row("duplicates", f"{s['duplicates']} ({s['duplicate_rate']:.0%})")
1265
+ table.add_row("severity agreement", f"{s['severity_agreement']:.0%}")
1266
+ console.print(table)
1267
+
1268
+ if card.matches:
1269
+ console.print("\n[bold]found[/bold]")
1270
+ for m in card.matches:
1271
+ flag = "" if abs(m.severity_delta) <= 1 else f" [yellow]severity off by {abs(m.severity_delta)}[/yellow]"
1272
+ console.print(f" [green]{m.golden_id}[/green] (match {m.score}){flag}")
1273
+ if card.missed:
1274
+ console.print(f"\n[bold]missed[/bold]: {', '.join(card.missed)}")
1275
+ if card.regressions_on_planted:
1276
+ console.print("\n[red]reported deliberately-correct behaviour as a defect[/red]")
1277
+ for env_id, planted in card.regressions_on_planted:
1278
+ console.print(f" {planted} [dim]({env_id})[/dim]")
1279
+
1280
+
1281
+ @app.command()
1282
+ def sweep(
1283
+ mode: str = typer.Option("nightly", "--mode", "-m"),
1284
+ config_dir: Path | None = ConfigDir,
1285
+ root: Path = Root,
1286
+ min_precision: float = typer.Option(
1287
+ 0.70, help="Quality gate. §11 stops the rollout below 70% accepted."
1288
+ ),
1289
+ ) -> None:
1290
+ """Run, then score, then gate. This is the command to put in cron.
1291
+
1292
+ Exits non-zero when precision falls below the gate, so a scheduled sweep
1293
+ that starts producing noise fails loudly instead of quietly filling a
1294
+ backlog nobody reads.
1295
+ """
1296
+ import asyncio
1297
+
1298
+ from qaas.router import Router
1299
+ from qaas.scorecard import GoldenLedger, score as score_run
1300
+
1301
+ cfg = load_config(config_dir)
1302
+ router = Router(cfg, root=root)
1303
+ report = asyncio.run(router.run(mode))
1304
+ console.print_json(data=report.summary())
1305
+
1306
+ ledger_path = _ledger_path(cfg)
1307
+ if ledger_path is None or not ledger_path.exists():
1308
+ console.print("[yellow]no golden ledger for this target; ran without scoring[/yellow]")
1309
+ return
1310
+
1311
+ store = RunStore(report.run_id, root)
1312
+ card = score_run(
1313
+ store.envelopes(), GoldenLedger.load(ledger_path), cost_usd=store.total_cost_usd()
1314
+ )
1315
+ console.print_json(data=card.summary())
1316
+
1317
+ if card.precision < min_precision:
1318
+ console.print(
1319
+ f"[red]precision {card.precision:.0%} is below the {min_precision:.0%} gate[/red] — "
1320
+ "tune before adding agents (§11)"
1321
+ )
1322
+ raise typer.Exit(1)
1323
+ console.print(f"[green]precision {card.precision:.0%}, above the gate[/green]")
1324
+
1325
+
1326
+ # -- tracker-check ----------------------------------------------------------
1327
+ # Everything below exists so that the first live run is not also the first time
1328
+ # anyone finds out whether the configuration works. It makes read-only calls
1329
+ # only: an operator must be able to run it against the team's real board
1330
+ # without wondering what it left behind.
1331
+
1332
+ #: Every environment variable the Jira backend reads, and what breaks without
1333
+ #: it. The order is the order they are needed in.
1334
+ _JIRA_ENV_HELP: dict[str, str] = {
1335
+ "JIRA_BASE_URL": "site root, e.g. https://acme.atlassian.net (no /jira, no /rest path)",
1336
+ "JIRA_EMAIL": "the bot account's Atlassian email — the one the token was minted for",
1337
+ "JIRA_API_TOKEN": "an API token, not a password",
1338
+ "JIRA_PROJECT_KEY": "default project for ordinary findings",
1339
+ "JIRA_SECURITY_PROJECT_KEY": "restricted project; without it security findings are refused",
1340
+ "JIRA_ISSUE_TYPE": "issue type to create (default: Bug)",
1341
+ }
1342
+
1343
+ #: Never rendered, ever, in any form but a four-character tail.
1344
+ _JIRA_SECRET_ENV = frozenset({"JIRA_API_TOKEN"})
1345
+
1346
+ #: House statuses this system actually drives. An unmapped one here is a real
1347
+ #: failure: VERIFIER asks for 'closed', nothing in the workflow matches, and the
1348
+ #: ticket stays open while the run reports a clean close. The rest of `STATUSES`
1349
+ #: are human dispositions — worth reporting, not worth failing on.
1350
+ _DRIVEN_STATUSES = ("open", "in_progress", "resolved", "closed")
1351
+
1352
+ #: What `--dry-run-ticket` renders. A realistic TRIAGE ticket rather than a
1353
+ #: placeholder, because the point is to see the ADF, the labels and the
1354
+ #: fingerprint an engineer will actually receive.
1355
+ _SAMPLE_TICKET: dict[str, object] = {
1356
+ "title": "Refund endpoint accepts any authenticated user",
1357
+ "body": (
1358
+ "## Repro\n"
1359
+ "\n"
1360
+ "- authenticate as an ordinary customer account\n"
1361
+ "- POST /v1/orders/{order_id}/refund for an order owned by a different account\n"
1362
+ "\n"
1363
+ "```bash\n"
1364
+ "curl -X POST -H \"Authorization: Bearer $CUSTOMER_TOKEN\" \\\n"
1365
+ " https://api.example.com/v1/orders/9001/refund\n"
1366
+ "```\n"
1367
+ "\n"
1368
+ "## Impact\n"
1369
+ "\n"
1370
+ "Any authenticated user can refund any order. Money moves.\n"
1371
+ "\n"
1372
+ "## Acceptance criteria\n"
1373
+ "\n"
1374
+ "- the endpoint returns 403 when the caller does not own the order\n"
1375
+ "- a regression test covers the cross-account case\n"
1376
+ ),
1377
+ "labels": ["agent-found"],
1378
+ "severity": "critical",
1379
+ "envelope_id": "env-sample-0001",
1380
+ "fingerprint": "sha256:" + "ab12cd34" * 8,
1381
+ "reporter": "TRIAGE",
1382
+ }
1383
+
1384
+
1385
+ def _env_display(name: str, value: str | None, required: tuple[str, ...]) -> str:
1386
+ """One environment row. A credential's value never appears here.
1387
+
1388
+ The last four characters of a token are enough to tell two tokens apart
1389
+ when you have both in front of you, and useless to anyone who does not.
1390
+ """
1391
+ text = (value or "").strip()
1392
+ if not text:
1393
+ return "[red]MISSING[/red]" if name in required else "[dim]unset[/dim]"
1394
+ if name in _JIRA_SECRET_ENV:
1395
+ if len(text) < 12:
1396
+ return "[green]set[/green] (too short to show a tail safely)"
1397
+ return f"[green]set[/green] (ends ...{text[-4:]})"
1398
+ return f"[green]set[/green] ({text})"
1399
+
1400
+
1401
+ def _print_checks(rows: list[tuple[str, bool, str]]) -> None:
1402
+ table = Table(title="connection", header_style="bold")
1403
+ table.add_column("check")
1404
+ table.add_column("", justify="center")
1405
+ table.add_column("detail")
1406
+ for label, passed, detail in rows:
1407
+ table.add_row(label, "[green]ok[/green]" if passed else "[red]fail[/red]", detail)
1408
+ console.print(table)
1409
+
1410
+
1411
+ def _check_jira_project(
1412
+ tracker, key: str, role: str
1413
+ ) -> tuple[list[tuple[str, bool, str]], list[str], list[Table]]:
1414
+ """Verify one project: it exists, this account may write to it, the issue
1415
+ type is available, and the house statuses map onto its workflow.
1416
+
1417
+ Returns its rows, its problems and any table to render, rather than
1418
+ printing, so the caller controls the order the report reads in.
1419
+ """
1420
+ from qaas.adapters.tracker import TrackerError
1421
+
1422
+ rows: list[tuple[str, bool, str]] = []
1423
+ problems: list[str] = []
1424
+ tables: list[Table] = []
1425
+
1426
+ try:
1427
+ info = tracker.project_info(key)
1428
+ except TrackerError as exc:
1429
+ rows.append((f"project {key}", False, str(exc)))
1430
+ problems.append(f"the {role} project '{key}' could not be read. {exc}")
1431
+ return rows, problems, tables
1432
+ rows.append((f"project {key}", True, f"{info.get('name') or '?'} ({role})"))
1433
+
1434
+ try:
1435
+ held = tracker.project_permissions(key)
1436
+ except TrackerError as exc:
1437
+ rows.append((f"permissions {key}", False, str(exc)))
1438
+ problems.append(f"could not read this account's permissions on '{key}'. {exc}")
1439
+ else:
1440
+ lacking = [name for name, granted in held.items() if not granted]
1441
+ rows.append(
1442
+ (
1443
+ f"permissions {key}",
1444
+ not lacking,
1445
+ "Browse, Create, Transition, Link"
1446
+ if not lacking
1447
+ else f"missing: {', '.join(lacking)}",
1448
+ )
1449
+ )
1450
+ if lacking:
1451
+ problems.append(
1452
+ f"the account lacks {', '.join(lacking)} on '{key}'. Grant them to this "
1453
+ "account's project role, or point the key at a project where it has them; "
1454
+ "Browse reads an issue back, Create files, Transition closes, Link dedupes."
1455
+ )
1456
+
1457
+ try:
1458
+ statuses = tracker.project_statuses(key)
1459
+ except TrackerError as exc:
1460
+ rows.append((f"workflow {key}", False, str(exc)))
1461
+ problems.append(f"could not read the workflow of '{key}'. {exc}")
1462
+ return rows, problems, tables
1463
+
1464
+ wanted = tracker.issue_type.strip().lower()
1465
+ matched = next((name for name in statuses if name.strip().lower() == wanted), None)
1466
+ if matched is None:
1467
+ rows.append(
1468
+ (
1469
+ f"issue type {key}",
1470
+ False,
1471
+ f"'{tracker.issue_type}' not in {', '.join(sorted(statuses)) or 'none'}",
1472
+ )
1473
+ )
1474
+ problems.append(
1475
+ f"'{key}' has no issue type called '{tracker.issue_type}'. It offers: "
1476
+ f"{', '.join(sorted(statuses)) or 'nothing this account can see'}. Set "
1477
+ "JIRA_ISSUE_TYPE to one of those."
1478
+ )
1479
+ return rows, problems, tables
1480
+ rows.append((f"issue type {key}", True, f"'{matched}' exists in {key}"))
1481
+
1482
+ mapping = tracker.map_house_statuses(statuses[matched])
1483
+ table = Table(title=f"workflow — {key} / {matched}", header_style="bold")
1484
+ table.add_column("house status")
1485
+ table.add_column("maps onto")
1486
+ for house, target in mapping.items():
1487
+ driven = house in _DRIVEN_STATUSES
1488
+ if target:
1489
+ table.add_row(house, target)
1490
+ else:
1491
+ table.add_row(
1492
+ f"[red]{house}[/red]" if driven else house,
1493
+ "[red]no match[/red]" if driven else "[yellow]no match[/yellow]",
1494
+ )
1495
+ tables.append(table)
1496
+
1497
+ unmapped = [h for h in _DRIVEN_STATUSES if mapping[h] is None]
1498
+ if unmapped:
1499
+ problems.append(
1500
+ f"'{key}' has no status matching the house status(es) {', '.join(unmapped)}. "
1501
+ "A transition to one of those will fail at the moment a ticket should close, "
1502
+ f"which is the point at which nobody is watching. This project's statuses are: "
1503
+ f"{', '.join(statuses[matched]) or 'none'}. Either rename a workflow status, or "
1504
+ "use a project whose workflow speaks these words."
1505
+ )
1506
+ return rows, problems, tables
1507
+
1508
+
1509
+ def _tracker_check_jira(dry_run_ticket: bool) -> tuple[list[str], list[str]]:
1510
+ """Every read-only Jira check. Returns (problems, warnings)."""
1511
+ import os
1512
+
1513
+ from qaas.adapters.tracker import JiraTracker, TrackerConfigError, TrackerError
1514
+
1515
+ problems: list[str] = []
1516
+ warnings: list[str] = []
1517
+
1518
+ table = Table(title="environment", header_style="bold")
1519
+ table.add_column("variable")
1520
+ table.add_column("status")
1521
+ table.add_column("what it does")
1522
+ for name, purpose in _JIRA_ENV_HELP.items():
1523
+ table.add_row(name, _env_display(name, os.environ.get(name), JiraTracker.REQUIRED_ENV), purpose)
1524
+ console.print(table)
1525
+
1526
+ missing = [n for n in JiraTracker.REQUIRED_ENV if not (os.environ.get(n) or "").strip()]
1527
+ if missing:
1528
+ for name in missing:
1529
+ problems.append(f"{name} is unset or empty — {_JIRA_ENV_HELP[name]}. Export it.")
1530
+ problems.append(
1531
+ "nothing was contacted: the variables above are read at construction, so there "
1532
+ "was nothing to connect with. These are credentials — export them in the shell "
1533
+ "that runs qaas, never in config/, which is committed."
1534
+ )
1535
+ return problems, warnings
1536
+
1537
+ try:
1538
+ tracker = JiraTracker()
1539
+ except TrackerConfigError as exc:
1540
+ problems.append(str(exc))
1541
+ return problems, warnings
1542
+
1543
+ if tracker.security_project is None:
1544
+ warnings.append(
1545
+ "JIRA_SECURITY_PROJECT_KEY is unset. Security-relevant findings will be REFUSED "
1546
+ "rather than filed — deliberately, because a vulnerability in a project the "
1547
+ "company can read is a disclosure with no undo (§4.12, §10). They will be "
1548
+ "escalated to a human instead. Set it to a project with restricted visibility "
1549
+ "if you want them filed."
1550
+ )
1551
+
1552
+ rows: list[tuple[str, bool, str]] = []
1553
+ try:
1554
+ who = tracker.whoami()
1555
+ except TrackerError as exc:
1556
+ rows.append(("auth", False, str(exc)))
1557
+ _print_checks(rows)
1558
+ problems.append(f"authentication failed, so no other check could run. {exc}")
1559
+ return problems, warnings
1560
+
1561
+ account = who.get("displayName") or who.get("emailAddress") or "unknown account"
1562
+ rows.append(("auth", True, f"authenticated as {account}"))
1563
+
1564
+ projects = [(tracker.default_project, "default")]
1565
+ if tracker.security_project:
1566
+ projects.append((tracker.security_project, "restricted"))
1567
+
1568
+ # Collected before anything is printed so the connection summary comes
1569
+ # first and the workflow detail it refers to comes after it.
1570
+ workflows: list[Table] = []
1571
+ for key, role in projects:
1572
+ extra_rows, extra_problems, extra_tables = _check_jira_project(tracker, key, role)
1573
+ rows.extend(extra_rows)
1574
+ problems.extend(extra_problems)
1575
+ workflows.extend(extra_tables)
1576
+ _print_checks(rows)
1577
+ for workflow in workflows:
1578
+ console.print(workflow)
1579
+
1580
+ if tracker.security_project:
1581
+ warnings.append(
1582
+ f"whether '{tracker.security_project}' is actually restricted cannot be checked "
1583
+ "over the API — Jira exposes no read-only view of a project's issue-level "
1584
+ "security scheme. Open it in a browser and confirm that people outside the "
1585
+ "security group cannot see its issues before filing anything real."
1586
+ )
1587
+
1588
+ if dry_run_ticket:
1589
+ console.print("\n[bold]dry-run ticket[/bold] — this JSON would be POSTed to /rest/api/3/issue")
1590
+ payload = tracker.create_payload(project=tracker.default_project, **_SAMPLE_TICKET) # type: ignore[arg-type]
1591
+ console.print_json(data=payload)
1592
+ console.print("[dim]nothing was sent.[/dim]")
1593
+
1594
+ return problems, warnings
1595
+
1596
+
1597
+ def _ensure_board(cfg, *, create: bool = True):
1598
+ """Find or create the Jira board for this run's target. Never fatal.
1599
+
1600
+ Called at the top of every Jira-backed run so that a person told to "watch
1601
+ the board" has one to watch before the first ticket lands, rather than
1602
+ after. Returns a `BoardInfo`, or None when there is nothing to do or Jira
1603
+ could not be reached.
1604
+
1605
+ Failures are printed and swallowed. A run that found nine defects and could
1606
+ not create a board has still done its job; a run that refused to start
1607
+ because of a board has thrown the findings away.
1608
+ """
1609
+ from qaas.adapters.tracker import JiraTracker, TrackerError, repo_label
1610
+ from qaas.mcp.tracker import dry_run_enabled
1611
+
1612
+ if cfg.tracker != "jira" or not cfg.target:
1613
+ return None
1614
+
1615
+ # The rehearsal rail sends nothing. A board is a container rather than a
1616
+ # ticket, but "QAAS_TRACKER_DRY_RUN=1 wrote to my Jira" is exactly the
1617
+ # sentence that rail exists to make impossible.
1618
+ if create and dry_run_enabled():
1619
+ console.print("[dim]dry run: no board was created[/dim]")
1620
+ create = False
1621
+
1622
+ label = repo_label(cfg.target)
1623
+ if label is None:
1624
+ console.print(
1625
+ f"[yellow]note:[/yellow] target name '{cfg.target}' cannot be a Jira label, so "
1626
+ "tickets will not be grouped onto a per-repository board."
1627
+ )
1628
+ return None
1629
+
1630
+ try:
1631
+ tracker = JiraTracker()
1632
+ except TrackerError as exc:
1633
+ console.print(f"[yellow]no board:[/yellow] {exc}")
1634
+ return None
1635
+
1636
+ if not create:
1637
+ from qaas.adapters.tracker import BoardInfo
1638
+
1639
+ return BoardInfo(
1640
+ slug=cfg.target,
1641
+ label=label,
1642
+ jql=f'project = "{tracker.default_project}" AND labels = "{label}" ORDER BY created DESC',
1643
+ )
1644
+
1645
+ try:
1646
+ info = tracker.ensure_repo_board(cfg.target)
1647
+ except TrackerError as exc:
1648
+ console.print(f"[yellow]could not provision a board:[/yellow] {exc}")
1649
+ return None
1650
+
1651
+ what = "board" if info.board_id and info.url != info.filter_url else "filter"
1652
+ verb = "created" if (info.created_board or info.created_filter) else "reused"
1653
+ console.print(f"[bold]{what} {verb}[/bold] — {info.url}")
1654
+ console.print(f"[dim]every ticket from this run carries the label {info.label}[/dim]")
1655
+ if info.note:
1656
+ console.print(f"[dim]{info.note}[/dim]")
1657
+ return info
1658
+
1659
+
1660
+ @app.command()
1661
+ def board(
1662
+ config_dir: Path | None = ConfigDir,
1663
+ target: str = typer.Option(None, "--target", "-t", help="Target profile. Defaults to the configured one."),
1664
+ create: bool = typer.Option(True, "--create/--no-create", help="Create the filter and board if they are missing."),
1665
+ ) -> None:
1666
+ """Show — or create — the Jira board that collects this repository's defects.
1667
+
1668
+ One board per repository, inside one Jira project. The board is a saved
1669
+ filter over the label `repo-<target>`, which every ticket the system files
1670
+ carries. That is why it needs no administrator rights: creating a Jira
1671
+ *project* per repository does, creating a filter does not.
1672
+ """
1673
+ cfg = load_config(config_dir, target=target)
1674
+ if target:
1675
+ cfg = cfg.model_copy(update={"target": target, "profile": _load_target(target, config_dir)})
1676
+
1677
+ if cfg.tracker != "jira":
1678
+ console.print(
1679
+ f"[yellow]tracker is '{cfg.tracker}', not 'jira'[/yellow] — boards are a Jira "
1680
+ "feature. Tickets are written to .qaas/tickets/ instead. Set QAAS_TRACKER=jira "
1681
+ "(and the JIRA_* variables) to file into Jira."
1682
+ )
1683
+ raise typer.Exit(1)
1684
+ if not cfg.target:
1685
+ console.print("[red]no target configured[/red] — run `qaas init <repo>` first.")
1686
+ raise typer.Exit(1)
1687
+
1688
+ info = _ensure_board(cfg, create=create)
1689
+ if info is None:
1690
+ raise typer.Exit(1)
1691
+
1692
+ table = Table(header_style="bold", box=None, pad_edge=False)
1693
+ table.add_column("field", style="dim")
1694
+ table.add_column("value", overflow="fold")
1695
+ table.add_row("target", cfg.target)
1696
+ table.add_row("label", info.label)
1697
+ table.add_row("jql", info.jql)
1698
+ table.add_row("filter", str(info.filter_id or "not created"))
1699
+ table.add_row("board", str(info.board_id or "not created"))
1700
+ table.add_row("url", info.url or "-")
1701
+ console.print(table)
1702
+ if info.note:
1703
+ console.print(f"\n[dim]{info.note}[/dim]")
1704
+
1705
+
1706
+ @app.command("tracker-check")
1707
+ def tracker_check(
1708
+ config_dir: Path | None = ConfigDir,
1709
+ root: Path = Root,
1710
+ dry_run_ticket: bool = typer.Option(
1711
+ False, "--dry-run-ticket", help="Also render the JSON that would be POSTed for a sample finding."
1712
+ ),
1713
+ ) -> None:
1714
+ """Validate the tracker configuration without creating anything.
1715
+
1716
+ Read-only: it authenticates, reads the projects, their permissions and their
1717
+ workflows, and says what would break. Run it before the first live run, when
1718
+ the alternative is discovering a wrong project key by watching real tickets
1719
+ appear in front of real people.
1720
+ """
1721
+ cfg = load_config(config_dir)
1722
+ console.print(
1723
+ f"[bold]tracker backend[/bold]: {cfg.tracker} "
1724
+ f"[dim](tracker: in {_system_yaml(config_dir)})[/dim]\n"
1725
+ )
1726
+
1727
+ if cfg.tracker == "local":
1728
+ tickets = Path(root) / "tickets"
1729
+ count = len(list(tickets.glob("*.json"))) if tickets.is_dir() else 0
1730
+ console.print(f"tickets are written as JSON under [bold]{tickets}[/bold] ({count} so far)")
1731
+ console.print(
1732
+ "[dim]no credentials are needed and nothing leaves this machine. Read what it "
1733
+ "files there before switching to tracker: jira.[/dim]"
1734
+ )
1735
+ if dry_run_ticket:
1736
+ console.print(
1737
+ "\n[yellow]--dry-run-ticket renders the Jira REST payload[/yellow], which the "
1738
+ "local backend does not use — it writes the house Issue model straight to "
1739
+ "disk. Set tracker: jira to preview it."
1740
+ )
1741
+ console.print("\n[green]ready[/green]")
1742
+ return
1743
+
1744
+ problems, warnings = _tracker_check_jira(dry_run_ticket)
1745
+
1746
+ for warning in warnings:
1747
+ console.print(f"\n[yellow]warning:[/yellow] {warning}")
1748
+ if problems:
1749
+ console.print("\n[red]not ready:[/red]")
1750
+ for problem in problems:
1751
+ console.print(f" - {problem}")
1752
+ raise typer.Exit(1)
1753
+ console.print("\n[green]ready[/green] — nothing was created by this check.")
1754
+
1755
+
1756
+ if __name__ == "__main__":
1757
+ app()