kaloko 0.14.0 → 0.15.0

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.
@@ -39,28 +39,35 @@ sharpen each purpose and add the criteria the page must really meet.
39
39
  ## Check packs
40
40
 
41
41
  `packs: [seo]` or `packs: [seo, usability]` on a node adds a fixed set of
42
- criteria (ids `AC1xx` for `seo`, `AC2xx` for `usability`) at load time:
42
+ health checks (ids `HC1xx` for `seo`, `HC2xx` for `usability`; acceptance
43
+ criteria are `AC`, health checks `HC`, and the number is the check's: the
44
+ same check kept in acceptance or adopted is `AC103` for `HC103`) at load time:
43
45
  `seo` checks one H1, title and description length, canonical, hreflang for
44
46
  every locale, Open Graph, indexing, viewport, `html lang`, overflow and
45
47
  console; `usability` checks unlabeled inputs and link texts and asks the
46
- evaluator questions that fit the step's page type. A node's own criterion with the
47
- same id wins. Pack criteria are marked `pack` on the canvas and do not count
48
- as drift in `--coverage`; the plan may still mention them by id. Use packs
49
- for public pages and landings; task flows keep their own criteria.
48
+ evaluator questions that fit the step's page type. Pack checks are health,
49
+ not acceptance: they are evaluated on every walk but never decide a step's
50
+ verdict (see [evaluation.md#health](evaluation.md#health)). The `design` pack
51
+ always counts for acceptance; `health: { acceptance: [seo] }` on the scenario
52
+ keeps another pack in acceptance, which only the person decides. A node's own
53
+ criterion with the same id wins and counts for acceptance (that is what
54
+ `kaloko health adopt` writes). Pack checks do not count as drift in
55
+ `--coverage`; the plan lists only what the brief asks for. Use packs for
56
+ public pages and landings; task flows keep their own criteria.
50
57
 
51
58
  Set `page` on every step (or once on the scenario) so the usability pack
52
59
  asks the right questions:
53
60
 
54
61
  | `page` | for | asks |
55
62
  | --- | --- | --- |
56
- | `landing` | public and marketing pages | purpose in the first screen (`AC201`), exactly one primary call to action (`AC202`) |
57
- | `work` | app screens where people work: overview, canvas, detail, inbox item | the heading says what the screen is for (`AC205`) |
58
- | `list` | lists and tables: runs, projects, inbox | `AC205`, each item says what it is and how to open it (`AC207`) |
59
- | `settings` | settings, billing, tokens | `AC205`, each setting says what it changes and how it is saved (`AC208`) |
60
- | `form` | sign-up, sign-in, a form to fill in | `AC205`, how to send it and what happens next (`AC206`) |
61
- | `doc` | documentation, guides, terms | `AC201`, headings and short paragraphs (`AC209`) |
62
-
63
- Every type also gets `AC203` and `AC204`. Without `page` a step with the
63
+ | `landing` | public and marketing pages | purpose in the first screen (`HC201`), exactly one primary call to action (`HC202`) |
64
+ | `work` | app screens where people work: overview, canvas, detail, inbox item | the heading says what the screen is for (`HC205`) |
65
+ | `list` | lists and tables: runs, projects, inbox | `HC205`, each item says what it is and how to open it (`HC207`) |
66
+ | `settings` | settings, billing, tokens | `HC205`, each setting says what it changes and how it is saved (`HC208`) |
67
+ | `form` | sign-up, sign-in, a form to fill in | `HC205`, how to send it and what happens next (`HC206`) |
68
+ | `doc` | documentation, guides, terms | `HC201`, headings and short paragraphs (`HC209`) |
69
+
70
+ Every type also gets `HC203` and `HC204`. Without `page` a step with the
64
71
  `seo` pack, or any step of a scenario allowed on production, is `landing`;
65
72
  everything else is `work`. Do not put `landing` on signed-in screens: "one
66
73
  primary call to action" is a marketing rule, and a work screen rightly offers
@@ -78,34 +85,35 @@ When a scenario walks several locales, a text check (`text_matches`,
78
85
  reads in one language only (it looks for diacritics and common Czech or
79
86
  English words; one plain word such as a brand name is left alone).
80
87
 
81
- - `a11y` (`AC3xx`): images with alt, links and buttons with a name, labelled
88
+ - `a11y` (`HC3xx`): images with alt, links and buttons with a name, labelled
82
89
  fields, heading levels not skipped, WCAG AA text contrast, a `main`
83
- landmark, unique ids (`AC301`–`AC307`, read from the page). After the
84
- screenshot the walk also presses Tab through the page: `AC308` tab order
90
+ landmark, unique ids (`HC301`–`HC307`, read from the page). After the
91
+ screenshot the walk also presses Tab through the page: `HC308` tab order
85
92
  (hidden focus, big jumps back, positive `tabindex`, controls left out or
86
- mouse-only), `AC309` visible focus with at least 3 : 1 contrast (each stop
87
- screenshotted focused and unfocused), `AC310` no keyboard trap (a modal
88
- dialog may keep focus), `AC311` a skip link or main content within three
89
- stops, `AC312` names from Chrome's accessibility tree (what a screen
90
- reader announces). `AC313` landmarks (one `main`, labelled repeats) and
91
- `AC314` ARIA (unknown roles and attributes, references to missing ids,
93
+ mouse-only), `HC309` visible focus with at least 3 : 1 contrast (each stop
94
+ screenshotted focused and unfocused), `HC310` no keyboard trap (a modal
95
+ dialog may keep focus), `HC311` a skip link or main content within three
96
+ stops, `HC312` names from Chrome's accessibility tree (what a screen
97
+ reader announces). `HC313` landmarks (one `main`, labelled repeats) and
98
+ `HC314` ARIA (unknown roles and attributes, references to missing ids,
92
99
  `aria-hidden` over focusable content) are read from the page. The first
93
100
  five focus stops are kept as screenshots and the canvas can draw the tab
94
101
  order on the capture. The walk only moves focus: nothing is clicked or
95
102
  typed, and the page is left unfocused at the top. Stack steps walk the
96
103
  keyboard on their first five pages. Still a subset of WCAG 2.2 AA; it
97
104
  does not replace an audit with a screen reader.
98
- - `perf` (`AC4xx`): LCP ≤ 2.5 s, CLS ≤ 0.1, TTFB ≤ 800 ms, page weight ≤ 3 MB,
105
+ - `perf` (`HC4xx`): LCP ≤ 2.5 s, CLS ≤ 0.1, TTFB ≤ 800 ms, page weight ≤ 3 MB,
99
106
  measured in this walk's browser since the page load (lab values: the
100
107
  machine and network of the walk matter; compare runs, not absolutes).
101
- Override a limit with an own criterion of the same id, e.g.
108
+ Override a limit with an own criterion with the check's number as an AC
109
+ id (`AC401` for `HC401`), e.g.
102
110
  `{ id: AC401, text: LCP within 4 s, check: { type: deterministic, assert: perf_lcp, max: 4000 } }`.
103
- - `console` (`AC501`): the evaluator reads the step's console errors with its
111
+ - `console` (`HC501`): the evaluator reads the step's console errors with its
104
112
  purpose and decides whether they touch what the step accepts (pass: only
105
113
  third-party noise; needs review: unsure; fail: relevant). Without errors it
106
114
  passes without a call. Keep `no_console_errors` where any error must fail.
107
115
 
108
- - `errors` (`AC6xx`) is for error pages. It checks:
116
+ - `errors` (`HC6xx`) is for error pages. It checks:
109
117
  - a real 4xx/5xx status (a "soft 404" answering 200 fails);
110
118
  - a link to the home page and navigation or search;
111
119
  - no stack trace, SQL error or framework debug page;
@@ -120,7 +128,7 @@ step with `packs: [errors]`:
120
128
  - **404:** use `path: /no-such-page-{random}`. `{random}` becomes a new
121
129
  token on every walk, so no cache or an accidentally created page makes it
122
130
  pass. `kaloko draft` adds this step on its own.
123
- - **Exact status:** override AC601, for example
131
+ - **Exact status:** override HC601 with your own AC601, for example
124
132
  `{ id: AC601, text: Answers 410, check: { type: deterministic, assert: http_status, value: 410 } }`.
125
133
  `http_status` also takes `min`/`max`; the default is 200–299.
126
134
  - **Every step:** each capture records the document's HTTP status. The
@@ -192,7 +200,8 @@ When the work has a repository and an issue number, make them visible:
192
200
  - scenario `id` carries the ticket (`TASK-1273-club-activation`, `WEB-12-checkout`); the CLI derives the issue number from it or from the branch (`feat/1273-…`) and links it to the repository's origin (`acme/shop#1273`);
193
201
  - set `issue: { number, repo?, url? }` explicitly when the id or branch cannot carry it (for example a Plane item);
194
202
  - the project is the repository name unless `project:` in `kaloko.config.yml` says otherwise; a single flow may sort itself elsewhere with `project: <slug>` in its scenario (optional). Projects are records of the organization: `kaloko projects` or MCP `list_projects` show the slugs to use; admins create them in Settings → Projects or assign a scenario in the overview, which wins over the scenario file;
195
- - the canvas header, the organization overview and `kaloko feedback` show `repo#number · title`, so name the scenario `title` for humans, not for the ticket.
203
+ - the canvas header, the organization overview and `kaloko feedback` show `repo#number · title`, so name the scenario `title` for humans, not for the ticket;
204
+ - requirement IDs from the team's tracker or QMS go into `refs:` on the scenario, a step or a criterion (`refs: [URS-12, FS-3.4]`, free text, never invented); they flow into the acceptance record and the trace matrix ([evidence.md](evidence.md)).
196
205
 
197
206
  ## Session and consent
198
207
 
@@ -216,7 +216,7 @@ dialog, webview and group.
216
216
  - `app_no_truncation`: texts ending in "…";
217
217
  - `app_no_crash`: FATAL EXCEPTION or ANR in logcat, a "keeps stopping"
218
218
  dialog, or the iOS app no longer running.
219
- - **Pack `app` (AC7xx):** those four checks, plus a semantic question about
219
+ - **Pack `app` (HC7xx):** those four checks, plus a semantic question about
220
220
  the screen's purpose and next action.
221
221
  - **Web-only checks** (`no_overflow`, `http_status`, meta, `a11y_contrast`,
222
222
  `perf_*`, `url_*`, `style_*`, `js_equals`): these are "not evaluated" on
@@ -353,7 +353,8 @@ Turn on `packs: [design]` (usually `[design, a11y]`) for the steps in the implem
353
353
  | AC802 | structure: headings, landmarks, actions and form fields as in the reference |
354
354
  | AC803 | tokens: authored colors, fonts, sizes, spacing and radii come from the token set |
355
355
 
356
- Contrast and the other accessibility basics come from the `a11y` pack: `packs: [design, a11y]`.
356
+ The design checks count for acceptance. Contrast and the other accessibility basics come from the `a11y` pack
357
+ (`packs: [design, a11y]`) as health: advisory, reported apart (see [evaluation.md#health](evaluation.md#health)).
357
358
 
358
359
  `kaloko evaluate` fetches the design reference (of the design branch linked to the git branch, if any) and
359
360
  fills the checks. Findings name what differs and the token to use; fix the implementation, not the reference.
@@ -68,6 +68,46 @@ evaluators:
68
68
  Report the reason with each `needs_review` or failing criterion; when the
69
69
  evaluator saw the screenshot, say so.
70
70
 
71
+ ## Health
72
+
73
+ The checks of packs (`seo`, `a11y`, `perf`, `usability`, `console`,
74
+ `errors`, `app`) are the run's **health** (CS *kondice*): advisory, stored in
75
+ `node.health` and `summary.health` of `run.json`, never in `criteria`. They
76
+ do not change a step's status, the run verdict, the review queue, carried
77
+ approvals, required approvers, the acceptance record or exit codes. The
78
+ `design` pack and packs listed in `health.acceptance` stay acceptance
79
+ criteria. Runs shared before the split keep their stored verdicts; their pack
80
+ criteria show as health.
81
+
82
+ - **Check it before you hand over.** `kaloko walk` and `kaloko evaluate`
83
+ print one health line; `kaloko health` lists the findings by pack with
84
+ severity (error: people or search engines are shut out; warning: the rest;
85
+ info: the evaluator was unsure), what is new or fixed since the previous run
86
+ of the scenario, and a fix for each check. `--json` for the structure,
87
+ `--remote` (or MCP `get_health`) for a shared run.
88
+ - **Fix what your change can fix**, walk the step again and check that the
89
+ finding is gone. Leave the rest and say so.
90
+ - **Never hide health.** Do not remove a pack, raise a limit, narrow a
91
+ selector or add `environments:` to make a finding disappear. A limit or a
92
+ check that does not fit the project is the person's call; name it in the
93
+ report.
94
+ - **Ids.** Health checks are `HC<n>` (`home:HC103`), acceptance criteria
95
+ `AC<n>`. Runs from before 7 Oct 2026 say `AC103` for the health check;
96
+ read it as `HC103`, and both ids work in commands and MCP.
97
+ - **Adopting is the team's decision.** `kaloko health adopt <step>:<check>
98
+ [--text "…"]` writes the check into the scenario as an acceptance
99
+ criterion of the step (`home:HC103` becomes `AC103` with `from: HC103`),
100
+ so it counts for acceptance from the next walk. Do it only when
101
+ the person asks. Checks people adopted on the canvas (or through
102
+ `adopt_health_check`) wait on the service until `kaloko health pull` writes
103
+ them into the scenario; pulling them is routine, then walk those steps.
104
+ - **Report it apart from the verdicts**: "Health: 2 errors (1 new), 5
105
+ warnings; fixed HC108 on home". Never present a health finding as a
106
+ failing criterion, or a clean health as acceptance.
107
+ - `kaloko health --fail-on error` exits 1 on errors; use it in CI only where
108
+ the team asked for that gate. Hosted runs and CI runs alert on new errors
109
+ by themselves (inbox, mail, channels, webhook `health.worse`).
110
+
71
111
  ## Triage findings (experimental)
72
112
 
73
113
  When the organization switched the experiment on, `kaloko evaluate` also asks
@@ -111,7 +151,8 @@ turns them off; they do not count against the hosted allowance.
111
151
 
112
152
  After `kaloko build`, report: the summary line, each failing or
113
153
  `needs_review` criterion with step, locale, viewport and evidence, blocked
114
- requests if any, and the preview path. Do not claim a criterion passed when
154
+ requests if any, the health counts (apart from the verdicts), and the preview
155
+ path. Do not claim a criterion passed when
115
156
  it was not evaluated.
116
157
 
117
158
  `needs_review` is not a failure: look at the screenshot and state your
@@ -0,0 +1,80 @@
1
+ # Evidence for your own documentation (regulated work)
2
+
3
+ Use this when a team's own repository turns Kaloko runs into its documents: test protocols, reports, trace
4
+ matrices, release summaries for a quality system. Kaloko provides the evidence and a way to check it; the team's
5
+ own templates and skills write the documents. Kaloko never formats anything for a particular QMS, and you do not put
6
+ the team's SOP structure into Kaloko.
7
+
8
+ The background for people (what a record proves, the two signing paths, known gaps) is in Kaloko's help, page
9
+ *Kaloko in regulated work (GxP)*. Evidence is a Business feature.
10
+
11
+ ## The contract
12
+
13
+ Everything below is `qawalk.evidence.v1` (`schemas/evidence.v1.json` in the `kaloko` package, every field
14
+ described). Within v1 fields are only added; read the fields you need and ignore the rest.
15
+
16
+ - `acceptance`: `none`, `accepted` or `returned`. Write your document from this field, never from the presence of
17
+ others.
18
+ - `signing`: `in_kaloko` when a signature made in Kaloko stands, `none` otherwise. With `none` the approval, if any,
19
+ is signed elsewhere; your document says so ("executed in Kaloko; approval recorded in <your eQMS>").
20
+ - `record.sha256` and `record.kind` (`acceptance` or `execution`): the signed record the evidence came from. Put the
21
+ hash into your document so a reader can tie it to the package.
22
+ - `scenario` (`version`, `file`, `sha256`, `refs`), `environment`, `git`, `tools` (CLI, Node.js, OS, browsers,
23
+ service version).
24
+ - `steps[].criteria[]`: the criterion text as walked, `refs`, and `results[]` per locale and viewport with
25
+ `assessed_by` (`person`, `deterministic`, `ai`, `none`) and `assessor` (an e-mail or a model id). `advisory: true`
26
+ means an AI result no person decided: never present it as a person's verdict.
27
+ - `steps[].decision`, `verdict`, `approvals`: people's decisions apart from tokens' and agents'
28
+ (`counts_as_person`).
29
+ - `signatures[]` (name, e-mail, `signed_at`, `time_zone`, `local_time`, `meaning`, `auth`, `withdrawn`) and
30
+ `decision_changes[]` with reasons.
31
+ - `audit[]`: the run's audit trail; `trace[]`: the trace matrix, one row per requirement, criterion, step, locale and
32
+ viewport (columns in `TRACE_COLUMNS` order, the same as `trace.csv`).
33
+ - A release (`kind: release`) has `runs[]` (each a run evidence), `missing[]` with reasons and one `trace[]`.
34
+
35
+ ## Getting it
36
+
37
+ ```bash
38
+ npx kaloko evidence <run-id> --json > evidence.json # one run, any shared run
39
+ npx kaloko evidence --release 2.4.0 --product lims --json > release.json
40
+ npx kaloko evidence <run-id> --csv > trace.csv # the trace matrix only
41
+ npx kaloko signoff <run-id> --package --out evidence/ # the signed zip, checked on download
42
+ npx kaloko signoff --verify evidence/kaloko-evidence-<run-id>.zip # check it again later, no token needed
43
+ ```
44
+
45
+ Over the API with a read token: `GET /api/v1/orgs/<org>/runs/<id>/evidence`, `…/evidence.zip`, `…/trace.csv`,
46
+ `…/audit.csv`, and `GET /api/v1/orgs/<org>/evidence?product=<p>&version=<v>`. Over MCP: `get_evidence` with `run_id`, or
47
+ `product` and `version`.
48
+
49
+ ## When it is ready
50
+
51
+ An organization admin adds a webhook with the event `evidence.ready` (Settings → Integrations → Webhooks). It fires
52
+ when a run is shared, accepted or returned, and when a release is recorded or updated. It is signed like every Kaloko
53
+ webhook (`Kaloko-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">`); check the signature and the age of `t`
54
+ before you trust it. The body:
55
+
56
+ ```json
57
+ { "id": "evt_…", "type": "evidence.ready", "created_at": "2026-10-07T10:00:00.000Z", "org": "acme",
58
+ "data": { "kind": "run", "reason": "accepted", "run": { "id": "2026-10-07-batch-x1y2z3", "scenario": "BATCH", "environment": "validation", "project": "lims", "verdict": "accepted" },
59
+ "acceptance": "accepted", "signing": "none", "schema": "qawalk.evidence.v1",
60
+ "evidence_url": "https://kaloko.app/api/v1/orgs/acme/runs/…/evidence", "package_url": "…/evidence.zip", "trace_csv_url": "…/trace.csv" } }
61
+ ```
62
+
63
+ The URLs need a token (`Authorization: Bearer …`); the webhook never carries the evidence itself.
64
+
65
+ A neutral client-side job, for example a small service or a CI job triggered by the webhook:
66
+
67
+ 1. Check `Kaloko-Signature` with the webhook's secret; drop events older than five minutes.
68
+ 2. Download `package_url` with a read token into your own storage.
69
+ 3. Run `npx kaloko signoff --verify <zip>`; stop and alert on any failure.
70
+ 4. Read `evidence.json` from the zip and hand it to your generator with your templates.
71
+ 5. Store the generated document next to the package and the record's SHA-256; your eQMS takes it from there.
72
+
73
+ ## Rules for an agent
74
+
75
+ - Never accept, approve or sign for a person. Tokens and agents cannot sign; where an organization signs in Kaloko,
76
+ only a person does, in the app.
77
+ - Never present `advisory` results as decisions, and never drop the AI model name from a result.
78
+ - Do not turn the signature policy on or off; that is an admin's decision in Settings → Security.
79
+ - Add `refs:` to scenarios, steps and criteria when the team gives you requirement IDs; do not invent them.
80
+ - Keep the package as you got it. To change anything, generate a new document; the package stays the evidence.
@@ -34,7 +34,7 @@ Ask before filing issues in bulk; one issue per real problem, not per criterion.
34
34
 
35
35
  Slack and Teams channels are chosen per project by an admin, with the events
36
36
  each wants (new run, returned, accepted, comment, mention, release recorded,
37
- waiting for approval) and quiet hours. Nothing to do from the CLI: sharing,
37
+ waiting for approval, health got worse) and quiet hours. Nothing to do from the CLI: sharing,
38
38
  returning and recording a release reach the right channels. Restricted projects
39
39
  reach only channels that name them.
40
40
 
@@ -72,6 +72,11 @@ kaloko schedule hook <id> # URL + secret that start the run after a deploy
72
72
  script that only calls `page.goto`). Steps that click or type are left out and
73
73
  named in the answer. Criteria decided: `exists`, `absent`, `count`, `text_*`,
74
74
  `attr_*`, `title_matches`, `meta_matches`; the rest shows "not evaluated".
75
+ - Health alerts: a hosted run (and a run shared from CI) compares its health
76
+ with the previous run of the scenario and alerts the followers, the channels
77
+ and the `health.worse` webhook on a new error-level finding. Each schedule
78
+ sets it to `error` (default), `warning` or `off` on kaloko.app or with
79
+ `kaloko schedule add --health-alert`; changing it is the person's call.
75
80
  - Does not work for: native apps, local or private addresses (localhost, IPs,
76
81
  `.local`), header credentials, base URLs per locale.
77
82
  - For those, or for the full walk on a schedule, write a GitHub Actions
@@ -49,6 +49,8 @@ is null. Projects and products come complete, without pages.
49
49
  | `get_started` | where the organization stands (`empty`, `sample`, `active`), its sample run, the next steps and a prompt; call it in a new project, see [first-run.md](first-run.md) |
50
50
  | `add_sample_run` | adds the sample run (or returns the one there is); needs an upload or full token and the Developer / Tester role |
51
51
  | `search_screens` | the screen library: newest screenshot of every step across the organization's flows (`q` words matched against titles, ids, paths, URLs, page title and H1; `project`, `product`, `module`, `scenario`, `locale`, `viewport`, `device`, `status`, `environment`; `source: design` or `all` for design versions; `group: none` for every locale and viewport; `cursor` from `next`), each with a signed image and the canvas link |
52
+ | `get_health` | the advisory health of a run (`run_id`), the newest run of a scenario (`scenario`) or the trend of a project (`project`): findings by severity, pack and step with evidence and a fix, and what is new or fixed since the previous run; never a verdict, see [evaluation.md#health](evaluation.md#health) |
53
+ | `adopt_health_check` | turns a health check into an acceptance criterion of the scenario (`run_id`, `step`, `check` such as `HC103`, `text` to reword it; it becomes `AC103` with `from: HC103`); Developer / Tester role. Only when the person asks; `kaloko health pull` then writes it into the scenario file |
52
54
  | `get_affected_steps` | which steps of a scenario changed files reach, with the reason per step (`scenario`, `changed_files`, `run_id`, `upstream`); route files are matched against the step paths and URLs of the newest run. Imports and `affects:` globs need the repository: `kaloko affected` follows them |
53
55
 
54
56
  ## Comment tools
@@ -63,6 +65,7 @@ is null. Projects and products come complete, without pages.
63
65
  | `answer_finding` | answers a finding (`run_id`, `node`, `locale`, `viewport`, `finding`, `kind`, `answer`: right, wrong or comment with the `comment_id` from `add_comment`); scope comment |
64
66
  | `get_triage_report` | the organization's Clef numbers of the last 90 days (read-only): findings shown and answered, precision and calibration per kind, findings turned into comments, median hours to the first review with and without findings, triage requests per useful finding. Report them as they are; the go/no-go is a person's |
65
67
  | `get_signoff_record` | the signed acceptance record of an accepted run (Business): approvals with provenance, criteria, capture hashes, signature |
68
+ | `get_evidence` | the evidence of a run (any shared run) or a release (Business, `qawalk.evidence.v1`): acceptance and signing state, criteria as walked with `refs`, who assessed each result (AI advisory), screenshots with SHA-256, decisions, signatures, audit trail, trace matrix ([evidence.md](evidence.md)) |
66
69
  | `resolve_comment` | close a thread with a `note` that says what changed (it becomes the last reply) |
67
70
  | `reopen_comment`, `edit_comment`, `delete_comment` | reopen; edit or delete your own comment |
68
71
  | `create_issue` | a Jira or Linear issue from a step (`run_id`, `step`, `to`: jira or linear, optional `criterion`, `title`, `note`, `locale`, `viewport`, `target`): screenshot, criterion, notes and a link back; the step's thread resolves when the issue is done. Developer / Tester, scope comment; see [integrations.md](integrations.md) |
@@ -29,7 +29,7 @@ step script only to prepare each page, for example to dismiss a dialog
29
29
  kind: stack
30
30
  title: Magazine articles
31
31
  purpose: Every article reads well and is findable.
32
- packs: [seo] # the pack criteria apply to every page
32
+ packs: [seo] # health checks of every page (advisory)
33
33
  items:
34
34
  sitemap: /sitemap.xml # or urls / file / crawl / script (exactly one)
35
35
  match: '^/clanky/' # regex on the path with its query; exclude: '…' drops pages
package/src/cli.mjs CHANGED
@@ -19,7 +19,7 @@ import { rememberEnvironment } from './lib/secrets.mjs'
19
19
  import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
20
20
  import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
21
21
  import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
22
- import { signoffCmd } from './lib/signoff.mjs'
22
+ import { signoffCmd, evidenceCmd } from './lib/signoff.mjs'
23
23
  import { localized, readJson, UserError, UsageError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG, EXIT, parseDuration } from './lib/util.mjs'
24
24
  import { noteLegacy, legacyHint, resetLegacy, legacyInUse } from './lib/legacy.mjs'
25
25
  import { packageRoot } from './lib/schema.mjs'
@@ -35,6 +35,7 @@ import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
35
35
  import { expandPacks } from './lib/packs.mjs'
36
36
  import { upsertPrComment } from './lib/pr.mjs'
37
37
  import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
38
+ import { healthCmd } from './lib/health.mjs'
38
39
  import { htmlViewAllowed } from './lib/htmlview.mjs'
39
40
  import { service, serviceUrl } from './lib/service.mjs'
40
41
  import { parseArgs, helpFor, checkOptions, closest } from './lib/cli-args.mjs'
@@ -235,6 +236,16 @@ Results
235
236
  review [--run id] [--scenario id] [--json]
236
237
  What a person still has to decide on a shared run (failed, unsure, changed, returned,
237
238
  commented steps), approvals carried over unchanged steps, and the review-mode link
239
+ health [--run id] [--scenario id] [--remote] [--fail-on error|warning] [--json]
240
+ Health of a run, advisory and never part of a verdict: the checks of packs (SEO,
241
+ accessibility, performance, usability, console, error pages) by severity and pack,
242
+ and what is new or fixed since the previous run of the scenario (the current local
243
+ run; --remote, a shared run id or --scenario ask kaloko.app); --fail-on exits 1
244
+ when there are findings at that level
245
+ health adopt <step>:<check> [--text "…"]
246
+ Make a health check an acceptance criterion of the step in the scenario file, as is
247
+ or with your text; it counts for the step's verdict from the next walk
248
+ health pull [--scenario id] Write the checks people adopted on kaloko.app into the scenario file
238
249
  comment [--step id | --flow] --note "…" [--status rejected|approved] [--run id] [--scenario id]
239
250
  comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
240
251
  Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
@@ -274,8 +285,18 @@ Results
274
285
  The signed acceptance record of an accepted run (Business): JSON and PDF with who
275
286
  approved what and when, criteria results and capture hashes, checked against the
276
287
  service's public key
277
- signoff --verify <file.json|file.pdf> [--key file|url] [--json]
278
- Check a record someone handed over (no token needed); exit 1 when it does not verify
288
+ signoff <run-id> --package [--out dir] [--lang cs|en] [--json]
289
+ The evidence package of any shared run, accepted or not (Business): the signed
290
+ record (JSON, PDF), evidence.json, the trace matrix (CSV, JSON), the audit trail,
291
+ the screenshots and a signed MANIFEST.json; checked after download
292
+ signoff --verify <file.json|file.pdf|file.zip> [--key file|url] [--json]
293
+ Check a record or an evidence package someone handed over (no token needed); exit 1
294
+ when it does not verify
295
+ evidence <run-id> [--csv] [--out file] [--json]
296
+ evidence --release <version> [--product p] [--csv] [--out file] [--json]
297
+ The evidence of a run or a release (Business, schema qawalk.evidence.v1): acceptance
298
+ and signing state, criteria as walked with requirement refs, results, screenshots
299
+ with SHA-256, decisions, signatures, audit trail; --csv prints the trace matrix
279
300
  stability --pull [--scenario id] [--json]
280
301
  Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
281
302
  region) go into the scenario's ignore: of their steps; walk them again after
@@ -296,9 +317,12 @@ Jira and Linear (connected by an admin in Settings → Integrations)
296
317
 
297
318
  Hosted runs (read-only scenarios on Kaloko's browsers, Business plan)
298
319
  schedule add --scenario <file> --env <name> [--every day|week|manual] [--at 5] [--weekday mon] [--account key]
320
+ [--health-alert off|error|warning]
299
321
  Kaloko opens the scenario's pages every day or week (hour in UTC) and shares the
300
322
  run as you: public pages, HTTP Basic from the environment, or a saved session
301
- (--account). Steps that click or type are left out; they need the full walk
323
+ (--account). Steps that click or type are left out; they need the full walk.
324
+ When a walk's health is worse than the previous one, the team hears about it:
325
+ new errors (default), new errors or warnings, or off
302
326
  schedule list | run <id> | pause <id> | resume <id> | remove <id> [--json]
303
327
  schedule hook <id> [--remove] [--json]
304
328
  A URL and secret that start the hosted run from a deploy (Vercel, Netlify) or
@@ -424,7 +448,9 @@ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
424
448
  const t0 = Date.now()
425
449
  await prefetch.done()
426
450
  const report = await evaluateRun({ run, scenario, config, root })
427
- const fails = report.findings.filter((f) => f.verdict === 'fail'); const unsure = report.findings.filter((f) => f.verdict === 'needs_review')
451
+ // health checks (advisory) are counted apart; they never make a step fail
452
+ const health = report.findings.filter((f) => f.health); const accept = report.findings.filter((f) => !f.health)
453
+ const fails = accept.filter((f) => f.verdict === 'fail'); const unsure = accept.filter((f) => f.verdict === 'needs_review')
428
454
  say(`\nEvaluated ${report.evaluated} capture(s) in ${Math.round((Date.now() - t0) / 100) / 10} s: ${fails.length ? `${fails.length} failing` : 'nothing failing'}${unsure.length ? `, ${unsure.length} for a person to look at` : ''}.`)
429
455
  const line = (f) => `${f.node}${f.item ? `/${f.item}` : ''} ${f.locale}/${f.viewport} ${f.id}${f.why ? `: ${String(f.why).slice(0, 140)}` : ''}`
430
456
  // pack criteria say how to fix them; a fix repeated across steps and locales is printed once
@@ -433,6 +459,16 @@ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
433
459
  if (fails.length) say(` ✕ ${fails.slice(0, 15).map(withFix).join('\n ✕ ')}${fails.length > 15 ? `\n … ${fails.length - 15} more (kaloko build lists them all)` : ''}`)
434
460
  if (unsure.length) say(` ? ${unsure.slice(0, 8).map(line).join('\n ? ')}${unsure.length > 8 ? `\n … ${unsure.length - 8} more` : ''}`)
435
461
  if (report.skipped.length) say(` Semantic criteria not evaluated: ${[...new Set(report.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}`)
462
+ if (health.length) say(healthLine(health))
463
+ }
464
+
465
+ /** One line on the health findings of an evaluation: counts by severity (one per check and step), never a verdict. */
466
+ function healthLine (findings) {
467
+ const worst = new Map()
468
+ const rank = { error: 3, warning: 2, info: 1 }
469
+ for (const f of findings) { const k = `${f.node}:${f.id}`; if ((rank[f.severity] || 0) > (rank[worst.get(k)] || 0)) worst.set(k, f.severity) }
470
+ const n = (s) => [...worst.values()].filter((x) => x === s).length
471
+ return `Health (advisory, not part of any verdict): ${n('error')} error(s), ${n('warning')} warning(s)${n('info') ? `, ${n('info')} unsure` : ''}. Fix what you can before review; kaloko health lists them.`
436
472
  }
437
473
 
438
474
  const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
@@ -1842,11 +1878,12 @@ const commands = {
1842
1878
  for (const x of report.skipped) { const [where, ...why] = x.split(': '); const k = why.join(': '); byReason.set(k, [...(byReason.get(k) || []), where]) }
1843
1879
  say(`Semantic criteria not evaluated:\n - ${[...byReason].slice(0, 8).map(([why, where]) => `${where.length > 1 ? `${where.length} captures (${where.slice(0, 3).join(', ')}${where.length > 3 ? ', …' : ''})` : where[0]}: ${why}`).join('\n - ')}`)
1844
1880
  }
1881
+ if ((report.findings || []).some((f) => f.health)) say(healthLine(report.findings.filter((f) => f.health)))
1845
1882
  if (report.outdated.length) say(`Criteria changed after capture of ${report.outdated.join(', ')}: not evaluated. Run "kaloko evaluate --recheck --steps ${report.outdated.join(',')}": it rechecks the stored captures against the new criteria, deterministic and semantic, without walking again.`)
1846
1883
  // --json: the counts and every verdict a person or agent has to act on (fail, needs_review)
1847
1884
  if (args.json) {
1848
1885
  const pick = (f) => ({ step: f.node, ...(f.item ? { item: f.item } : {}), locale: f.locale, viewport: f.viewport, criterion: f.id, verdict: f.verdict, why: f.why || null, fix: f.fix || null })
1849
- say(JSON.stringify({ run: run.meta.id, evaluated: report.evaluated, semantic: { model: report.model, calls: report.semantic_calls, cached: report.semantic_cached, via: report.via.qawalk ? 'kaloko' : report.via['own-key'] ? 'own-key' : null }, vision: report.vision || null, triage: report.triage || null, findings: (report.findings || []).filter((f) => f.verdict === 'fail' || f.verdict === 'needs_review').map(pick), skipped: report.skipped, outdated: report.outdated }, null, 2))
1886
+ say(JSON.stringify({ run: run.meta.id, evaluated: report.evaluated, semantic: { model: report.model, calls: report.semantic_calls, cached: report.semantic_cached, via: report.via.qawalk ? 'kaloko' : report.via['own-key'] ? 'own-key' : null }, vision: report.vision || null, triage: report.triage || null, findings: (report.findings || []).filter((f) => !f.health && (f.verdict === 'fail' || f.verdict === 'needs_review')).map(pick), health: (report.findings || []).filter((f) => f.health).map((f) => ({ ...pick(f), pack: f.pack, severity: f.severity })), skipped: report.skipped, outdated: report.outdated }, null, 2))
1850
1887
  }
1851
1888
  },
1852
1889
 
@@ -1858,7 +1895,7 @@ const commands = {
1858
1895
  const why = attentionLines(manifest)
1859
1896
  // --json: the run id, its summary, the preview file and the AI verdicts to act on
1860
1897
  if (args.json) { say(JSON.stringify({ run: manifest.id, summary: s, preview: file, manifest: join(run.dir, 'run.json'), attention: why.map((l) => l.trim()) }, null, 2)); return file }
1861
- say(`${manifest.id}: ${s.pass} pass · ${s.partial} partial · ${s.fail} fail · ${s.skip} not checked\nPreview: ${file}`)
1898
+ say(`${manifest.id}: ${s.pass} pass · ${s.partial} partial · ${s.fail} fail · ${s.skip} not checked${s.health ? `\nHealth (advisory): ${s.health.error} error(s), ${s.health.warning} warning(s) in ${s.health.checks} checks (kaloko health)` : ''}\nPreview: ${file}`)
1862
1899
  if (why.length) say(`AI verdicts to act on (score and why):\n${why.join('\n')}`)
1863
1900
  return file
1864
1901
  },
@@ -2099,6 +2136,9 @@ const commands = {
2099
2136
  // how many steps still wait for a person (review mode); an older service answers 404 and the line is left out
2100
2137
  const queue = await client.request('GET', `/orgs/${config.org}/review-queue?run=${encodeURIComponent(data.run.id)}`).catch(() => null)
2101
2138
  if (queue?.counts) say(` review: ${queue.counts.pending} of ${queue.counts.queue} step(s) wait for a person${queue.counts.carried ? `, ${queue.counts.carried} unchanged kept their approval` : ''} (kaloko review; review mode: ${queue.review_url})`)
2139
+ // health checks people adopted into acceptance on kaloko.app (an older service leaves the field out)
2140
+ const adopted = data.health_adoptions || []
2141
+ if (adopted.length) say(` adopted into acceptance, not in the scenario file yet (kaloko health pull writes them):\n${adopted.map((a) => ` + ${a.step}:${a.check} "${a.text}" — ${a.created_by}${a.created_via === 'agent' ? ' (agent)' : ''}`).join('\n')}`)
2102
2142
  if (!data.items.length && !flow.length) { say(' No open rejections or comments.'); return }
2103
2143
  // returned steps first: a reviewer said what should change
2104
2144
  const returned = data.items.filter((r) => r.status === 'rejected')
@@ -2141,10 +2181,30 @@ const commands = {
2141
2181
  const { config, root } = await context(args)
2142
2182
  let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
2143
2183
  if (!runId) { try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id } catch {} }
2144
- if (!runId) throw new UsageError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
2184
+ if (!runId) throw new UsageError('Usage: kaloko signoff <run-id> [--package] [--out dir] | kaloko signoff --verify <file.json|file.pdf|file.zip>')
2145
2185
  return signoffCmd({ config, args, say, client: service(config), runId })
2146
2186
  },
2147
2187
 
2188
+ /** The evidence of a run or a release (qawalk.evidence.v1) for a client's own generator. */
2189
+ async evidence (args) {
2190
+ const { config, root } = await context(args)
2191
+ let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
2192
+ if (!runId && typeof args.release !== 'string') { try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id } catch {} }
2193
+ return evidenceCmd({ config, args, say, client: service(config), runId })
2194
+ },
2195
+
2196
+ /** Health (theme K): advisory checks of the packs, adopting one into acceptance, pulling adoptions (health.mjs). */
2197
+ async health (args) {
2198
+ return healthCmd({
2199
+ args, say,
2200
+ deps: {
2201
+ context, runContext, buildManifest, service,
2202
+ localRunExists: async (a) => { try { const { root, config } = await context(a); await resolveRun({ root, output: config.output, run: a.run }); return true } catch { return false } },
2203
+ sharedRunId: async (a) => { const { root, config } = await context(a); const r = await resolveRun({ root, output: config.output }); if (!r.meta.shared) throw new UserError('The current run is not shared yet: run kaloko health without --remote for the local one, or kaloko share first.'); return r.meta.id }
2204
+ }
2205
+ })
2206
+ },
2207
+
2148
2208
  async review (args) {
2149
2209
  const { config, root } = await context(args)
2150
2210
  const client = service(config)
package/src/lib/build.mjs CHANGED
@@ -25,7 +25,7 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
25
25
  const mailKeys = viewportNames.filter((k) => variantOf(meta.scope, k).browser === 'chromium')
26
26
  const nodes = []
27
27
  for (const node of scenario.nodes) {
28
- if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane }, captures, attachments })); continue }
28
+ if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane, ...(node.refs?.length ? { refs: node.refs } : {}) }, captures, attachments })); continue }
29
29
  const caps = []
30
30
  // pages of a stack live in its items index; normal steps take their own captures only
31
31
  for (const c of captures.filter((x) => x.node === node.id && !x.item)) {
@@ -74,6 +74,8 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
74
74
  app_version: meta.app_version ?? meta.version ?? null,
75
75
  environment: meta.environment,
76
76
  executor: meta.executor,
77
+ // what walked it: the CLI, Node.js, the OS and the browser engines seen in the captures (acceptance records)
78
+ tools: toolsOf(meta, nodes),
77
79
  started_at: meta.started_at,
78
80
  finished_at: new Date().toISOString(),
79
81
  scope: meta.scope,
@@ -91,6 +93,13 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
91
93
  }
92
94
  }
93
95
 
96
+ /** The tools of a run: kaloko and its version, Node.js, the OS, and every browser version a capture reports. */
97
+ export function toolsOf (meta, nodes) {
98
+ const browsers = new Set()
99
+ for (const n of nodes) for (const vps of Object.values(n.captures || {})) for (const c of Object.values(vps || {})) if (c?.browser_version) browsers.add(c.browser_version)
100
+ return { cli: meta.tools?.cli || { name: 'kaloko', version: null }, node: meta.tools?.node || process.version, os: meta.tools?.os || `${process.platform} ${process.arch}`, browsers: [...browsers].sort() }
101
+ }
102
+
94
103
  /** The recording entry of a sidecar as the manifest carries it: file paths, duration, size, markers. */
95
104
  async function recEntry (run, e) {
96
105
  const r = await readJson(join(run.dir, e.file), null)
@@ -11,6 +11,11 @@ import { maskSpec, maskList, inPage, maskInPage, rememberPii, knownPii, maskMail
11
11
  * Encodes a PNG screenshot inside the page (no native dependency): the image as WebP (or PNG, lossless) and a
12
12
  * WebP thumbnail for the canvas grid; `marks` are numbered circles drawn on the image (documentation captures).
13
13
  */
14
+ /** "chromium 131.0.6778.33" or null: the browser of a page (Playwright), never failing a capture. */
15
+ export function browserVersion (page) {
16
+ try { const b = page.context().browser(); const v = b?.version?.(); return v ? `${b.browserType?.().name?.() || 'browser'} ${v}` : null } catch { return null }
17
+ }
18
+
14
19
  export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 640, thumbQuality = 0.8, marks = [], scale = 1, masks = [] } = {}) {
15
20
  const result = await page.evaluate(async ({ dataUrl, format, quality, thumbWidth, thumbQuality, marks, scale, masks }) => {
16
21
  const img = new Image()
@@ -300,6 +305,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
300
305
  node: node.id, locale, viewport, url,
301
306
  ...variantFields(scope, viewport),
302
307
  captured_at: new Date().toISOString(),
308
+ // the engine and its version, for the record of what walked the run (acceptance records, evidence)
309
+ browser_version: browserVersion(page),
303
310
  criteria_hash: criteriaHash(node),
304
311
  console_errors: errors, blocked_requests: blocked,
305
312
  ...result,
@@ -29,6 +29,7 @@ export const HIDDEN = {
29
29
  history: ['lang'],
30
30
  tokens: ['env', 'version'],
31
31
  stability: ['scenario'],
32
+ health: ['text'],
32
33
  status: ['branch'],
33
34
  feedback: ['branch'],
34
35
  share: ['branch', 'name', 'notes', 'notes-preview', 'strict', 'mine', 'theirs', 'lang'],
package/src/lib/draft.mjs CHANGED
@@ -65,7 +65,7 @@ export function draftScenario (pages, { id, title } = {}) {
65
65
  `Source of truth for intent. Machine scenario: next to this file. Drafted by \`kaloko draft\` from ${pages.length} page(s) of ${origin} on ${new Date().toISOString().slice(0, 10)}; sharpen purposes and criteria before relying on it.`, '',
66
66
  '## Goal and scope', '', `Public pages of ${host} read well, are findable, accessible and fast, as a visitor sees them. Read-only on production; nothing is submitted.`, '',
67
67
  '## Steps and criteria', '',
68
- 'Every page step also runs the packs `seo` (AC1xx), `a11y` (AC3xx), `perf` (AC4xx) and `console` (AC501); the error page runs `errors` (AC6xx).', '',
68
+ 'Every page step also runs the packs `seo` (HC1xx), `a11y` (HC3xx), `perf` (HC4xx) and `console` (HC501); the error page runs `errors` (HC6xx). Pack checks are the health of the pages: advisory, reported apart, never part of a verdict. Adopt one into the criteria below (`kaloko health adopt <step>:<check>`) when it must decide acceptance.', '',
69
69
  ...nodes.flatMap((n) => [`### ${n.id} — ${n.title} (\`screen\`)`, '', `- Path: \`${n.path}\``, `- Purpose: ${n.purpose}`, ...n.criteria.map((c) => `- ${c.id}: ${c.text}.`), '']),
70
70
  '## Environments and limits', '', `production (read-only, as a visitor): add \`environments.production: { base_url: ${origin}, readonly: true }\` to kaloko.config.yml if it is not there.`, '',
71
71
  '## Change log', '', '| Date | Commit | Change |', '|---|---|---|', `| ${new Date().toISOString().slice(0, 10)} | — | Drafted from the pages by kaloko draft. |`, ''
@@ -11,6 +11,7 @@ import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.
11
11
  import { packHelp } from './pack-help.mjs'
12
12
  import { tokenCriteria, storedValueChecks, STYLE_ASSERTS } from './assertions.mjs'
13
13
  import { appliesToState } from './manifest-core.mjs'
14
+ import { isHealthCriterion, findingSeverity, toAcceptanceId } from './health-core.mjs'
14
15
 
15
16
  /** Latest capture per node/locale/viewport. */
16
17
  export function latestCaptures (entries) {
@@ -19,7 +20,10 @@ export function latestCaptures (entries) {
19
20
  return [...map.values()]
20
21
  }
21
22
 
22
- export const criteriaHash = (node) => sha256(node.criteria || [])
23
+ // `acceptance` (a pack kept in acceptance) changes what decides the verdict, not what a capture checks
24
+ // a pack check hashes in its AC form whatever its role (HC302 in health, AC302 in acceptance): moving a pack in or out of
25
+ // acceptance, and the HC ids themselves (7 Oct 2026), leave the captures of before valid
26
+ export const criteriaHash = (node) => sha256((node.criteria || []).map(({ acceptance, ...c }) => (c.pack ? { ...c, id: toAcceptanceId(c.id) } : c)))
23
27
  /** A criterion with `environments` (e.g. [production]) applies only there; without a known environment it applies. */
24
28
  export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
25
29
  /** Environment and step state (#46): a criterion with `states:` applies only to captures of those states. */
@@ -177,7 +181,8 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
177
181
  await writeJson(paths.step(stepIdOf(entry), entry.locale, entry.viewport, 'eval.json'), evaluation)
178
182
  report.evaluated++
179
183
  // what did not pass, for the walk summary and agents: step, locale, viewport, criterion and why
180
- for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes${c.reason_source === 'model' ? `: ${c.reason}` : ''}` : c.note) || null, ...(c.reason ? { reason: c.reason } : {}), ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
184
+ // health checks (pack criteria outside acceptance) are reported apart: advisory, never a verdict
185
+ for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') { const def = (node.criteria || []).find((x) => x.id === c.id); report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes${c.reason_source === 'model' ? `: ${c.reason}` : ''}` : c.note) || null, ...(c.reason ? { reason: c.reason } : {}), ...(def?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}), ...(isHealthCriterion(def) ? { health: true, pack: def.pack, severity: findingSeverity(c.verdict, c.id) } : {}) }) }
181
186
  }
182
187
  }
183
188
  } finally { if (drift?.browser) await drift.browser.close(); await vision?.close(); await triage?.close() }