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