qaas-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/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()
|