kaloko 0.4.4 → 0.4.8
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.
- package/README.md +20 -2
- package/package.json +2 -2
- package/skills/kaloko/SKILL.md +12 -5
- package/skills/kaloko/reference/collaboration.md +45 -2
- package/skills/kaloko/reference/evaluation.md +5 -0
- package/skills/kaloko/reference/first-run.md +51 -0
- package/skills/kaloko/reference/mcp.md +5 -0
- package/src/cli.mjs +73 -6
- package/src/lib/doctor.mjs +5 -3
- package/src/lib/login.mjs +70 -0
- package/src/lib/onboarding.mjs +33 -0
- package/src/lib/review.mjs +69 -0
- package/viewer/index.html +554 -18
package/README.md
CHANGED
|
@@ -64,6 +64,23 @@ npx kaloko share --pr # upload to kaloko.app, comment on the PR
|
|
|
64
64
|
`npx kaloko help` lists every command. When something does not work (Chrome,
|
|
65
65
|
token, environment URL, evaluator key), `npx kaloko doctor` says what to fix.
|
|
66
66
|
|
|
67
|
+
## Review only what changed
|
|
68
|
+
|
|
69
|
+
After `kaloko share`, steps that look exactly as they did in the last accepted
|
|
70
|
+
run (same screenshots, same criteria, automatic checks passing) keep the
|
|
71
|
+
approval a person gave them, with who and when. The rest waits in review
|
|
72
|
+
mode: one step at a time on the canvas, `J`/`K` to move, `A` to approve, `R`
|
|
73
|
+
to return with a note. The share says it in one line:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
12 steps unchanged, approvals carried over; 5 need review: https://kaloko.app/acme/runs/…/#review
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`npx kaloko review` (or `--json`, or MCP `get_review_queue`) tells an agent
|
|
80
|
+
what still waits for a person, so it can pass the link on. Agents never
|
|
81
|
+
approve for people: their decisions are marked and do not count as a
|
|
82
|
+
person's.
|
|
83
|
+
|
|
67
84
|
## Design (Kaloko Design)
|
|
68
85
|
|
|
69
86
|
Design the screens of a flow as live HTML with your own agent and skills, in the same scenario that development
|
|
@@ -121,8 +138,9 @@ captured HTML, page text and console output.
|
|
|
121
138
|
- **E-mails**: built-in Mailpit client; any other mailbox through a small
|
|
122
139
|
adapter module (`provider: script`, example in `templates/mail/`), or hand a
|
|
123
140
|
message over with `npx kaloko mail --step <id> --file message.eml`.
|
|
124
|
-
- **Service and evaluator**: `KALOKO_TOKEN` for `share`, `feedback`, `inbox` and clean-up (`runs`, `trash`, `prune`, `keep`, `archive`)
|
|
125
|
-
(
|
|
141
|
+
- **Service and evaluator**: `KALOKO_TOKEN` for `share`, `feedback`, `review`, `inbox` and clean-up (`runs`, `trash`, `prune`, `keep`, `archive`)
|
|
142
|
+
(`npx kaloko login` approves one in the browser and saves it to `~/.config/kaloko/.env`, or create it in your
|
|
143
|
+
organization's settings); `TYPESAFE_API_KEY` for the
|
|
126
144
|
semantic evaluator (optional). Both names can be changed in the config.
|
|
127
145
|
|
|
128
146
|
`npx kaloko doctor` checks that every variable the config names is set and that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kaloko",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"qa",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"test": "node --test --test-timeout=
|
|
35
|
+
"test": "node --test --test-timeout=900000 test/*.test.mjs worker/test/*.test.mjs"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"ajv": "^8.20.0",
|
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -28,8 +28,9 @@ the canvas, storage and collaboration are the service.
|
|
|
28
28
|
`kaloko capture` run `kaloko evaluate`) and `kaloko build` ran; you report the summary line,
|
|
29
29
|
failing criteria with evidence, and the preview path (or shared URL).
|
|
30
30
|
- When the run was shared, its URL and the summary are in the issue or PR
|
|
31
|
-
the work belongs to,
|
|
32
|
-
|
|
31
|
+
the work belongs to, the person got the review-mode link with what waits
|
|
32
|
+
for them (`kaloko share` prints it; `kaloko review` repeats it), and you
|
|
33
|
+
read `kaloko feedback` and `kaloko inbox` before continuing on that task.
|
|
33
34
|
- Changes to intent are written back to `ACCEPTANCE.md` (change log row).
|
|
34
35
|
- On a design environment: the folder follows the bundle rules and
|
|
35
36
|
`kaloko design lint` shows no error, `kaloko walk` and `kaloko sync` ran,
|
|
@@ -56,12 +57,17 @@ a step in a sync conflict (`--mine`), turning on `html_view`, a real clean-up
|
|
|
56
57
|
(`kaloko prune --yes`), trashing someone else's run, archiving a scenario, and
|
|
57
58
|
anything the environment profile blocks. Never trash an accepted run, a
|
|
58
59
|
baseline, a run with review comments, or a run linked from an open PR or issue
|
|
59
|
-
comment.
|
|
60
|
+
comment. Never approve a
|
|
61
|
+
step or accept a run on a person's behalf; report what waits for them
|
|
62
|
+
(`kaloko review`, MCP `get_review_queue`) instead. Ask the person
|
|
60
63
|
for secret values (passwords, tokens, gate credentials) and for sign-ins only
|
|
61
64
|
they can do; never write secrets into the config, a scenario or a script.
|
|
62
65
|
|
|
63
66
|
## Where to look
|
|
64
67
|
|
|
68
|
+
- A new project or an organization without runs of its own (install, sign in,
|
|
69
|
+
the sample run, the first flow from a URL or a task, share, point the person
|
|
70
|
+
to the canvas): [reference/first-run.md](reference/first-run.md)
|
|
65
71
|
- Writing or updating the plan and scenario: [reference/acceptance.md](reference/acceptance.md)
|
|
66
72
|
- Android, iOS (Simulator), Electron and macOS apps, or screens captured by
|
|
67
73
|
another tool (Maestro, Appium, XCUITest) with their UI tree:
|
|
@@ -75,8 +81,9 @@ they can do; never write secrets into the config, a scenario or a script.
|
|
|
75
81
|
- Environments, access (gates, accounts, sign-in), e-mails, readonly and
|
|
76
82
|
production rules, and what is yours to arrange for the project's stack:
|
|
77
83
|
[reference/environments.md](reference/environments.md)
|
|
78
|
-
- Sharing,
|
|
79
|
-
|
|
84
|
+
- Sharing, review mode and approvals carried over unchanged steps, reviewer
|
|
85
|
+
feedback, mentions, inbox, verdicts and baselines, and cleaning up runs
|
|
86
|
+
(trash, prune, keep, archive):
|
|
80
87
|
[reference/collaboration.md](reference/collaboration.md)
|
|
81
88
|
- Reading runs, feedback and the inbox over MCP instead of the CLI:
|
|
82
89
|
[reference/mcp.md](reference/mcp.md)
|
|
@@ -4,7 +4,10 @@ A run is only worth something once the people who decide have seen it. The
|
|
|
4
4
|
loop teams use:
|
|
5
5
|
|
|
6
6
|
1. **Share the run** (`kaloko share`, tester token, after asking). The
|
|
7
|
-
command prints the canvas URL
|
|
7
|
+
command prints the canvas URL and one line on what waits for a person,
|
|
8
|
+
e.g. `12 steps unchanged, approvals carried over; 5 need review: <link>`.
|
|
9
|
+
Give the person that review-mode link (it ends in `#review`) with the
|
|
10
|
+
counts, not just the canvas.
|
|
8
11
|
2. **Put the link where the work is tracked.** When the branch has a pull
|
|
9
12
|
request and `gh` is signed in, `kaloko share --pr` posts one comment per
|
|
10
13
|
scenario on it (summary line, canvas link, criteria that need attention
|
|
@@ -19,7 +22,10 @@ loop teams use:
|
|
|
19
22
|
that waits for review and turns green or red with the verdict; do not
|
|
20
23
|
report it as passed before a person accepted the run.
|
|
21
24
|
3. **Read feedback before continuing.** `kaloko feedback` lists the run
|
|
22
|
-
verdict,
|
|
25
|
+
verdict, returned steps first (with the reviewer's note), open comments,
|
|
26
|
+
failed manual criteria and how many steps still wait for a person.
|
|
27
|
+
`kaloko review` (or MCP `get_review_queue`) says exactly what a person
|
|
28
|
+
still has to decide; see *Review mode* below.
|
|
23
29
|
`kaloko inbox` lists what needs your attention across runs: mentions,
|
|
24
30
|
returned runs, rejected steps in your runs, new runs of scenarios you
|
|
25
31
|
reviewed, and expiry warnings. Both take `--json`; `kaloko inbox --read`
|
|
@@ -31,6 +37,34 @@ loop teams use:
|
|
|
31
37
|
reply in the tracker with the new link. The reviewer who returned it is
|
|
32
38
|
told about the new run automatically.
|
|
33
39
|
|
|
40
|
+
## Review mode
|
|
41
|
+
|
|
42
|
+
Reviewers decide in **review mode** on the canvas (`Q`, the Review button,
|
|
43
|
+
or a link ending in `#review`): one step at a time, the steps that need a
|
|
44
|
+
person first (failed, returned, the AI was unsure or did not evaluate,
|
|
45
|
+
changed since the last accepted run, open comments), with the accepted run
|
|
46
|
+
beside it. `J`/`K` move, `A` approves, `R` returns with a note, `C`
|
|
47
|
+
comments; at the end they accept or return the run.
|
|
48
|
+
|
|
49
|
+
- **Approvals carry over unchanged steps.** When a new run is shared, a step
|
|
50
|
+
whose screenshots are byte-identical to the last accepted run, whose
|
|
51
|
+
criteria are the same and whose deterministic checks pass keeps the
|
|
52
|
+
approval a person gave it there, with its origin (who, when, which run).
|
|
53
|
+
Only changed or failing steps go to the queue. Anyone can revoke a carried
|
|
54
|
+
approval; the step then waits again. Organizations on Business can turn
|
|
55
|
+
this off (Settings → Approvals).
|
|
56
|
+
- A carried approval never accepts the run and never counts for required
|
|
57
|
+
approvers: the run verdict is always a person's decision.
|
|
58
|
+
- **Report what waits**, do not decide it: run `kaloko review` (add `--json`
|
|
59
|
+
for the data, `--run <id>` for another run) or MCP `get_review_queue` and
|
|
60
|
+
tell the person how many steps wait and give them `review_url`.
|
|
61
|
+
- **Never approve or accept on a person's behalf.** Decisions by tokens and
|
|
62
|
+
agents (MCP `decide_step`, `add_comment` with `approved`) are recorded as
|
|
63
|
+
yours, shown apart, and do not count as a person's unless the organization
|
|
64
|
+
allows it. Return a step only when you found a real problem, with a note.
|
|
65
|
+
- Returned steps are feedback: fix them, share a new run, and resolve the
|
|
66
|
+
thread with what changed (see below).
|
|
67
|
+
|
|
34
68
|
## Processing feedback
|
|
35
69
|
|
|
36
70
|
Comments are threads in three places:
|
|
@@ -195,6 +229,15 @@ Settings → Storage (keep the newest N per scenario and environment).
|
|
|
195
229
|
Everything above is available over the API with an organization token:
|
|
196
230
|
|
|
197
231
|
- `GET /api/v1/orgs/<org>/feedback?run=<id|latest>[&scenario=<id>]`
|
|
232
|
+
- `GET /api/v1/orgs/<org>/runs/<id>/review-queue` (or
|
|
233
|
+
`GET /api/v1/orgs/<org>/review-queue?run=<id|latest>&scenario=<id>`):
|
|
234
|
+
`items` in review order with `reasons`, `decision` (approved, returned or
|
|
235
|
+
carried, with who and how), `unconfirmed` (an agent's or token's decision
|
|
236
|
+
that does not count), `changed`, criteria with evidence, `screenshots` and
|
|
237
|
+
`ref_screenshots`; `carried` with provenance; `counts` (`queue`,
|
|
238
|
+
`decided`, `pending`, `carried`, `clear`); `reference` (the accepted run);
|
|
239
|
+
`review_url`. Decisions use the comment and verdict routes;
|
|
240
|
+
`POST …/runs/<id>/carried/<step>/revoke` revokes a carried approval.
|
|
198
241
|
- `GET|POST /api/v1/orgs/<org>/runs/<id>/comments` (a step: `node_id`;
|
|
199
242
|
the whole run: `node_id: ""`), `GET|POST …/scenarios/<id>/comments`
|
|
200
243
|
(the whole flow), and per comment `PATCH`, `DELETE`, `POST …/<cid>/resolve`
|
|
@@ -61,3 +61,8 @@ it was not evaluated.
|
|
|
61
61
|
|
|
62
62
|
`needs_review` is not a failure: look at the screenshot and state your
|
|
63
63
|
reading, or leave it to the reviewer.
|
|
64
|
+
|
|
65
|
+
After a share, steps that did not change since the last accepted run keep a
|
|
66
|
+
person's approval; only the rest waits in review mode. Report it as the
|
|
67
|
+
share prints it ("12 steps unchanged, approvals carried over; 5 need review:
|
|
68
|
+
<link>") and never call a carried or agent approval a person's acceptance.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# First run in a new project
|
|
2
|
+
|
|
3
|
+
Use this when the project has no `kaloko.config.yml` yet, or the organization
|
|
4
|
+
has no shared run of its own. The goal is one real flow on the canvas, shared,
|
|
5
|
+
with a person who knows where to approve it.
|
|
6
|
+
|
|
7
|
+
## Where the organization stands
|
|
8
|
+
|
|
9
|
+
- `GET /api/v1/orgs/<org>/getting-started` (MCP tool `get_started`) answers
|
|
10
|
+
`stage` (`empty`, `sample`, `active`), the sample run's URL, how many real
|
|
11
|
+
runs exist, the next steps and a prompt to start from.
|
|
12
|
+
- Every new organization has a **sample run**: a tea-shop checkout in Czech
|
|
13
|
+
and English on desktop and mobile, with one failing step per language, one
|
|
14
|
+
overflow on the phone and one criterion waiting for a person. It does not
|
|
15
|
+
count toward the plan and expires after 30 days. `<service>/<org>/sample`
|
|
16
|
+
opens it; it carries a short guide for the person (open a step, approve,
|
|
17
|
+
return with a note, share).
|
|
18
|
+
- If the person has never reviewed a run, send them the sample link first:
|
|
19
|
+
they learn the canvas there, not on your first draft. When it is gone,
|
|
20
|
+
`POST /api/v1/orgs/<org>/sample` (MCP `add_sample_run`, needs an upload or
|
|
21
|
+
full token) adds it again. Do not delete it unless asked
|
|
22
|
+
(`DELETE /api/v1/orgs/<org>/sample`).
|
|
23
|
+
|
|
24
|
+
## From zero to a first shared run
|
|
25
|
+
|
|
26
|
+
1. `npm install --save-dev kaloko`, then
|
|
27
|
+
`npx kaloko init --agent claude --org <org>` (it prints the sample link
|
|
28
|
+
and these steps). Fill `environments` in `kaloko.config.yml` with the URL
|
|
29
|
+
the app runs on.
|
|
30
|
+
2. `npx kaloko login`: the person approves a token in the browser. Ask them
|
|
31
|
+
to; never create or paste tokens yourself.
|
|
32
|
+
3. `npx kaloko doctor` and fix what it marks ✕.
|
|
33
|
+
4. Draft the first scenario:
|
|
34
|
+
- public pages: `npx kaloko draft <url> [url…]` writes a read-only
|
|
35
|
+
production scenario and a plan; sharpen purposes and criteria;
|
|
36
|
+
- a task or the main flow of the app: write `ACCEPTANCE.md` and
|
|
37
|
+
`qa/scenario.yml` as in [acceptance.md](acceptance.md). Pick the flow a
|
|
38
|
+
person would show a client (sign-up, checkout, the main form), not every
|
|
39
|
+
page.
|
|
40
|
+
5. `npx kaloko validate qa/scenario.yml`, then
|
|
41
|
+
`npx kaloko start --scenario qa/scenario.yml --env local`, `kaloko walk`,
|
|
42
|
+
`kaloko build`. Fix scenario mechanics until every step has a capture.
|
|
43
|
+
6. Ask before `kaloko share` (see *Ask first* in SKILL.md). After sharing,
|
|
44
|
+
give the person the canvas URL and say what to look at: the failing
|
|
45
|
+
criteria with their evidence and the steps that need a decision.
|
|
46
|
+
7. Read `kaloko feedback` when they have looked; fix what they returned and
|
|
47
|
+
walk again.
|
|
48
|
+
|
|
49
|
+
Done means: the scenario validates, the run is shared, its URL and summary
|
|
50
|
+
line are in your reply (and in the issue or PR the work belongs to), and the
|
|
51
|
+
person knows whether the sample or this run is what they should open.
|
|
@@ -28,8 +28,13 @@ file.
|
|
|
28
28
|
| `list_notifications` | the token owner's inbox (`unread_only`, `limit`) |
|
|
29
29
|
| `list_projects` | projects of the organization with counts; the slug is the `project` filter of `list_runs` and the value for `project:` in a scenario |
|
|
30
30
|
| `get_criteria_verdicts` | reviewer verdicts on manual or overridden criteria of a run |
|
|
31
|
+
| `get_review_queue` | what a person still has to decide in a run (`run_id`, or `scenario` / nothing for the newest run): steps in review order with reasons, decisions, approvals carried over unchanged steps with their origin, counts and `review_url` to give the person |
|
|
32
|
+
| `decide_step` | records your (the agent's) decision on a step: `approve`, or `return` with a `note`; marked as an agent's and not counted as a person's. Never use it to approve for a person |
|
|
33
|
+
| `revoke_carried_approval` | revokes an approval carried over to a step that looks unchanged but should be looked at again (`run_id`, `step`) |
|
|
31
34
|
| `get_step_assets` | documentation layer (Business): newest capture of every step of a scenario with stable image, HTML and metadata URLs (`scenario`, `step`, `locale`, `viewport`, `accepted`) |
|
|
32
35
|
| `list_step_changes` | documentation layer: steps whose capture or status changed since the previous accepted run or `since` (`scenario`, `since`, `accepted`); see [documentation.md](documentation.md) |
|
|
36
|
+
| `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) |
|
|
37
|
+
| `add_sample_run` | adds the sample run (or returns the one there is); needs an upload or full token and the Developer / Tester role |
|
|
33
38
|
|
|
34
39
|
## Comment tools
|
|
35
40
|
|
package/src/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { writeFile, access, readFile, mkdir } from 'node:fs/promises'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { gzipSync } from 'node:zlib'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
4
5
|
import { join, resolve, relative, dirname } from 'node:path'
|
|
5
6
|
import { pathToFileURL } from 'node:url'
|
|
6
7
|
import yaml from 'js-yaml'
|
|
@@ -29,8 +30,11 @@ import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
|
|
|
29
30
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
30
31
|
import { expandPacks } from './lib/packs.mjs'
|
|
31
32
|
import { upsertPrComment } from './lib/pr.mjs'
|
|
33
|
+
import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
|
|
32
34
|
import { htmlViewAllowed } from './lib/htmlview.mjs'
|
|
33
|
-
import { service } from './lib/service.mjs'
|
|
35
|
+
import { service, serviceUrl } from './lib/service.mjs'
|
|
36
|
+
import { login } from './lib/login.mjs'
|
|
37
|
+
import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
|
|
34
38
|
import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
|
|
35
39
|
import { designDir, readBase, headKey } from './lib/design.mjs'
|
|
36
40
|
import { lintDesign, formatLint } from './lib/design-lint.mjs'
|
|
@@ -51,6 +55,10 @@ Project
|
|
|
51
55
|
pages (title, description, H1) with the seo, a11y, perf and console packs
|
|
52
56
|
init [--agent claude|codex|all] [--org <slug>]
|
|
53
57
|
Create ${CONFIG_FILE} and install the kaloko skill for your agent
|
|
58
|
+
login [--org <slug>] [--url <service>] [--print] [--no-open]
|
|
59
|
+
Sign this machine in through the browser: approve the code on kaloko.app, choose
|
|
60
|
+
the organization and what the token may do; the token (at most 30 days) goes to
|
|
61
|
+
~/.config/kaloko/.env, or is printed with --print
|
|
54
62
|
doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
|
|
55
63
|
and app tools; --fix asks macOS for the desktop capture permissions
|
|
56
64
|
validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
|
|
@@ -106,7 +114,11 @@ Results
|
|
|
106
114
|
share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role);
|
|
107
115
|
--pr adds or updates one comment on the branch's pull request via gh
|
|
108
116
|
feedback [--run id] [--scenario id] [--json]
|
|
109
|
-
Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies
|
|
117
|
+
Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies;
|
|
118
|
+
returned steps first, with their notes
|
|
119
|
+
review [--run id] [--scenario id] [--json]
|
|
120
|
+
What a person still has to decide on a shared run (failed, unsure, changed, returned,
|
|
121
|
+
commented steps), approvals carried over unchanged steps, and the review-mode link
|
|
110
122
|
comment [--step id | --flow] --note "…" [--status rejected|approved] [--run id] [--scenario id]
|
|
111
123
|
comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
|
|
112
124
|
Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
|
|
@@ -558,7 +570,13 @@ const commands = {
|
|
|
558
570
|
await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
|
|
559
571
|
say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
|
|
560
572
|
}
|
|
561
|
-
|
|
573
|
+
// the organization's sample run and the way to a first real flow (src/lib/onboarding.mjs)
|
|
574
|
+
let written = ''
|
|
575
|
+
try { written = await readFile(existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE), 'utf8') } catch {}
|
|
576
|
+
const org = typeof args.org === 'string' ? args.org : (written.match(/^org:\s*["']?([a-z0-9-]+)["']?\s*$/m) || [])[1]
|
|
577
|
+
const base = serviceUrl({ share: { url: (written.match(/^share:[ \t]*\n(?:[ \t]+.*\n)*?[ \t]+url:[ \t]*["']?(https?:\/\/[^\s"']+)/m) || [])[1] } })
|
|
578
|
+
say(initNextSteps({ base, org }).join('\n'))
|
|
579
|
+
say(`Scenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
|
|
562
580
|
},
|
|
563
581
|
|
|
564
582
|
async validate (args) {
|
|
@@ -995,9 +1013,10 @@ const commands = {
|
|
|
995
1013
|
const body = async (p) => { const raw = await readFile(join(run.dir, p)); const gz = /\.(html|json)$/.test(p) && raw.length > 512; return { data: gz ? gzipSync(raw, { level: 6 }) : raw, gz } }
|
|
996
1014
|
// the file list goes first: the service checks limits once and each upload is then a plain write
|
|
997
1015
|
// (an older service without the plan answers 404/405 and every upload checks itself, as before)
|
|
998
|
-
|
|
1016
|
+
// screenshots name their SHA-256: the service checks it on arrival and compares unchanged steps with it
|
|
1017
|
+
if (files.size) {
|
|
999
1018
|
const plan = []
|
|
1000
|
-
for (const p of files) plan.push({ path: p, bytes:
|
|
1019
|
+
for (const p of files) { const { data } = await body(p); plan.push({ path: p, bytes: data.length, ...(/\.(png|webp|jpe?g)$/i.test(p) ? { sha256: createHash('sha256').update(data).digest('hex') } : {}) }) }
|
|
1001
1020
|
await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/files`, { files: plan }).catch((e) => { if (!/HTTP (404|405)/.test(e.message)) throw e })
|
|
1002
1021
|
}
|
|
1003
1022
|
const queue = [...files]
|
|
@@ -1031,6 +1050,9 @@ const commands = {
|
|
|
1031
1050
|
meta.shared = { url: fin.url, at: new Date().toISOString(), expires_days: created.expires_days }
|
|
1032
1051
|
await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
|
|
1033
1052
|
say(`Shared ${manifest.id} (${files.size} files, expires in ${created.expires_days} days)\n${fin.url}`)
|
|
1053
|
+
// what waits for a person, with the review-mode link to give them (approvals of unchanged steps carry over)
|
|
1054
|
+
const review = shareReviewLine(fin.review)
|
|
1055
|
+
if (review) say(review)
|
|
1034
1056
|
const capped = expiryNote(days, created.expires_days)
|
|
1035
1057
|
if (capped) say(`Note: ${capped}`)
|
|
1036
1058
|
if (args.pr) {
|
|
@@ -1127,12 +1149,38 @@ const commands = {
|
|
|
1127
1149
|
for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
|
|
1128
1150
|
}
|
|
1129
1151
|
const flow = data.flow_comments || []
|
|
1152
|
+
// how many steps still wait for a person (review mode); an older service answers 404 and the line is left out
|
|
1153
|
+
const queue = await client.request('GET', `/orgs/${config.org}/review-queue?run=${encodeURIComponent(data.run.id)}`).catch(() => null)
|
|
1154
|
+
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})`)
|
|
1130
1155
|
if (!data.items.length && !flow.length) { say(' No open rejections or comments.'); return }
|
|
1131
|
-
|
|
1156
|
+
// returned steps first: a reviewer said what should change
|
|
1157
|
+
const returned = data.items.filter((r) => r.status === 'rejected')
|
|
1158
|
+
if (returned.length) say(`Returned steps (${returned.length}), fix these first:`)
|
|
1159
|
+
for (const r of returned) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
|
|
1160
|
+
if (returned.length && data.items.length > returned.length) say('Comments:')
|
|
1161
|
+
for (const r of data.items.filter((x) => x.status !== 'rejected')) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
|
|
1132
1162
|
for (const r of flow) thread(r, 'whole flow')
|
|
1133
1163
|
say(`\nAnswer with: kaloko comment --reply <id> --note "…"; close with: kaloko comment --resolve <id> --note "Fixed in <commit>"${flow.length ? ' (add --flow for flow comments)' : ''}`)
|
|
1134
1164
|
},
|
|
1135
1165
|
|
|
1166
|
+
/**
|
|
1167
|
+
* What a person still has to decide on a shared run: the service's review queue (failed, unsure, changed since the
|
|
1168
|
+
* accepted run, returned, commented), the approvals carried over unchanged steps, and the review-mode link. For
|
|
1169
|
+
* agents: report it to the person; never approve on their behalf.
|
|
1170
|
+
*/
|
|
1171
|
+
async review (args) {
|
|
1172
|
+
const { config, root } = await context(args)
|
|
1173
|
+
const client = service(config)
|
|
1174
|
+
let runId = typeof args.run === 'string' ? args.run : null
|
|
1175
|
+
let scenarioId = typeof args.scenario === 'string' ? args.scenario : null
|
|
1176
|
+
if (!runId && !scenarioId) {
|
|
1177
|
+
try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id; else scenarioId = r.meta.scenario.id } catch {}
|
|
1178
|
+
}
|
|
1179
|
+
const q = new URLSearchParams({ run: runId || 'latest', ...(scenarioId ? { scenario: scenarioId } : {}) })
|
|
1180
|
+
const data = await client.request('GET', `/orgs/${config.org}/review-queue?${q}`)
|
|
1181
|
+
say(args.json ? JSON.stringify(data, null, 2) : formatReviewQueue(data))
|
|
1182
|
+
},
|
|
1183
|
+
|
|
1136
1184
|
/**
|
|
1137
1185
|
* Comments from the CLI: on a step (--step), the whole run (default) or the whole flow (--flow); reply, resolve
|
|
1138
1186
|
* (with a closing note), reopen, edit and delete by id. Targets the shared run (--run, default the current one).
|
|
@@ -1218,6 +1266,25 @@ const commands = {
|
|
|
1218
1266
|
say(args.undo ? `✓ ${args._[0]} is active again` : `✓ ${args._[0]} archived: hidden from the scenario list, its runs stay reachable (kaloko archive ${args._[0]} --undo)`)
|
|
1219
1267
|
},
|
|
1220
1268
|
|
|
1269
|
+
async login (args) {
|
|
1270
|
+
// a project is optional: it only names the service URL, the organization and the token variable
|
|
1271
|
+
let config = {}
|
|
1272
|
+
const shell = { ...process.env } // before the project and user .env files are read
|
|
1273
|
+
try { ({ config } = await context(args)) } catch (e) { if (!(e instanceof UserError)) throw e }
|
|
1274
|
+
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
1275
|
+
const inShell = shell[tokenEnv]
|
|
1276
|
+
const base = typeof args.url === 'string' ? args.url : serviceUrl(config)
|
|
1277
|
+
const version = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
|
|
1278
|
+
const r = await login(base, { org: typeof args.org === 'string' ? args.org : config.org || null, print: !!args.print, open: !args['no-open'], tokenEnv, version, say })
|
|
1279
|
+
if (args.print) return
|
|
1280
|
+
say(`✓ Signed in to ${r.org_name || r.org} as ${r.email} (${r.scope}; expires ${String(r.expires_at || '').slice(0, 10)}). ${tokenEnv} saved to ${r.file}.`)
|
|
1281
|
+
if (config.org && r.org && config.org !== r.org) say(`! ${CONFIG_FILE} names the organization ${config.org}; the token is for ${r.org}.`)
|
|
1282
|
+
for (const line of loginNextSteps({ base, org: r.org })) say(line)
|
|
1283
|
+
let projectEnv = ''
|
|
1284
|
+
try { projectEnv = await readFile(join(process.cwd(), '.env'), 'utf8') } catch {}
|
|
1285
|
+
if (inShell || new RegExp(`^\\s*(export\\s+)?${tokenEnv}\\s*=`, 'm').test(projectEnv)) say(`! ${tokenEnv} is also set ${inShell ? 'in your shell' : 'in this project\'s .env'}, which wins over ~/.config/kaloko/.env. Remove it there to use the new token.`)
|
|
1286
|
+
},
|
|
1287
|
+
|
|
1221
1288
|
async inbox (args) {
|
|
1222
1289
|
const { config } = await context(args)
|
|
1223
1290
|
const client = service(config)
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -107,7 +107,7 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
107
107
|
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
108
108
|
const token = env[tokenEnv]
|
|
109
109
|
const service = serviceUrl(config, env)
|
|
110
|
-
if (!token) add('token', false, `${tokenEnv} not set`, `
|
|
110
|
+
if (!token) add('token', false, `${tokenEnv} not set`, `Run kaloko login, or create a tester token in ${service}/${config.org || '<org>'}/settings and put ${tokenEnv}=… into .env (or ~/.config/kaloko/.env).`)
|
|
111
111
|
else {
|
|
112
112
|
try {
|
|
113
113
|
const res = await fetchImpl(`${service}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } })
|
|
@@ -115,8 +115,10 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
115
115
|
else {
|
|
116
116
|
const me = await res.json()
|
|
117
117
|
const sameOrg = !config.org || me.org === config.org
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// sharing runs needs the Developer / Tester role (or admin) and, on tokens with scope sets, the upload scope
|
|
119
|
+
const canShare = ['tester', 'admin'].includes(me.role) && (!Array.isArray(me.scopes) || me.scopes.includes('upload'))
|
|
120
|
+
add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${canShare ? '' : ' (this token cannot share runs)'}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
|
|
121
|
+
if (!canShare) add('token role', false, Array.isArray(me.scopes) ? `${me.role} · ${me.scopes.join(', ')}` : me.role, 'kaloko share needs a Developer / Tester token with the upload scope.')
|
|
120
122
|
}
|
|
121
123
|
} catch (e) { add('token', false, `${service} unreachable: ${e.message}`, 'Check your network; KALOKO_URL overrides the service URL.') }
|
|
122
124
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// kaloko login: signs this machine in through the browser instead of pasting a token. The service hands out a code
|
|
2
|
+
// (POST /cli/device), a signed-in person approves it at /cli/authorize for one organization with the scopes their
|
|
3
|
+
// role allows, and the next poll (POST /cli/token) returns a personal token that expires within 30 days. The token
|
|
4
|
+
// goes to ~/.config/kaloko/.env (or is printed with --print).
|
|
5
|
+
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { homedir, hostname } from 'node:os'
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { UserError } from './util.mjs'
|
|
10
|
+
|
|
11
|
+
export const userEnvFile = (home = homedir()) => join(home, '.config', 'kaloko', '.env')
|
|
12
|
+
|
|
13
|
+
/** Sets NAME=value in a .env text, replacing an earlier line of the same name; keeps everything else. */
|
|
14
|
+
export function setEnvLine (text, name, value) {
|
|
15
|
+
const lines = String(text || '').split('\n').filter((l, i, all) => !(i === all.length - 1 && l === ''))
|
|
16
|
+
const re = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`)
|
|
17
|
+
const at = lines.findIndex((l) => re.test(l))
|
|
18
|
+
if (at >= 0) lines[at] = `${name}=${value}`
|
|
19
|
+
else lines.push(`${name}=${value}`)
|
|
20
|
+
return lines.join('\n') + '\n'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function openBrowser (url) {
|
|
24
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]
|
|
25
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref() } catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Runs the device flow against `base` (the service URL). Returns { token, org, email, scope, expires_at, file? }.
|
|
30
|
+
* Options: org (preselected in the browser), print (do not write the file), open (launch the browser), tokenEnv
|
|
31
|
+
* (the variable name, share.token_env), home, version, say, fetchImpl, sleep.
|
|
32
|
+
*/
|
|
33
|
+
export async function login (base, { org = null, print = false, open = true, tokenEnv = 'KALOKO_TOKEN', home = homedir(), version = '', say = console.log, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
34
|
+
base = base.replace(/\/$/, '')
|
|
35
|
+
const post = async (path, body) => {
|
|
36
|
+
let res
|
|
37
|
+
try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base}: ${e.message}`) }
|
|
38
|
+
return { status: res.status, body: await res.json().catch(() => ({})) }
|
|
39
|
+
}
|
|
40
|
+
const start = await post('/cli/device', { client: `kaloko${version ? ` ${version}` : ''} on ${hostname()}`.slice(0, 60) })
|
|
41
|
+
if (start.status !== 200 || !start.body.device_code) throw new UserError(`${base} did not start the sign-in (HTTP ${start.status}${start.body.error ? `: ${start.body.error}` : ''}). Is this a Kaloko service?`)
|
|
42
|
+
const { device_code: device, user_code: code, verification_uri_complete: complete, expires_in: ttl = 600 } = start.body
|
|
43
|
+
let interval = Math.max(1, Number(start.body.interval) || 5)
|
|
44
|
+
const link = org ? `${complete}&org=${encodeURIComponent(org)}` : complete
|
|
45
|
+
say(`Open this page and approve the sign-in:\n ${link}\nCheck that it shows the code ${code}.`)
|
|
46
|
+
if (open) openBrowser(link)
|
|
47
|
+
const until = Date.now() + ttl * 1000
|
|
48
|
+
while (Date.now() < until) {
|
|
49
|
+
await sleep(interval * 1000)
|
|
50
|
+
const r = await post('/cli/token', { device_code: device })
|
|
51
|
+
if (r.status === 200 && r.body.access_token) {
|
|
52
|
+
const out = { token: r.body.access_token, org: r.body.org, org_name: r.body.org_name, email: r.body.email, scope: r.body.scope, expires_at: r.body.expires_at }
|
|
53
|
+
if (print) { say(`${tokenEnv}=${out.token}`); return out }
|
|
54
|
+
const file = userEnvFile(home)
|
|
55
|
+
await mkdir(dirname(file), { recursive: true })
|
|
56
|
+
let text = ''
|
|
57
|
+
try { text = await readFile(file, 'utf8') } catch {}
|
|
58
|
+
await writeFile(file, setEnvLine(text, tokenEnv, out.token), { mode: 0o600 })
|
|
59
|
+
await chmod(file, 0o600).catch(() => {})
|
|
60
|
+
return { ...out, file }
|
|
61
|
+
}
|
|
62
|
+
const error = r.body.error
|
|
63
|
+
if (error === 'authorization_pending') continue
|
|
64
|
+
if (error === 'slow_down') { interval += 5; continue }
|
|
65
|
+
if (error === 'access_denied') throw new UserError('The sign-in was declined in the browser.')
|
|
66
|
+
if (error === 'expired_token') throw new UserError('The code expired. Run kaloko login again.')
|
|
67
|
+
throw new UserError(`Sign-in failed (HTTP ${r.status}${error ? `: ${error}` : ''}).`)
|
|
68
|
+
}
|
|
69
|
+
throw new UserError('The code expired before it was approved. Run kaloko login again.')
|
|
70
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// What `kaloko init` and `kaloko login` print next: the organization's sample run (every new organization gets one,
|
|
2
|
+
// a reviewed checkout to try the canvas on) and the way to a first real flow. No service call: the sample link is a
|
|
3
|
+
// stable address that opens the organization's sample run, or its home page when there is none.
|
|
4
|
+
|
|
5
|
+
export const FIRST_FLOW_PROMPT = 'Use the Kaloko skill: draft a scenario for the main flow of this app on the local environment, walk it in every locale on desktop and mobile, share it and send me the canvas link.'
|
|
6
|
+
|
|
7
|
+
/** The organization slug from a config, unless it is still the template's placeholder. */
|
|
8
|
+
export const realOrg = (org) => (typeof org === 'string' && /^[a-z0-9][a-z0-9-]{1,40}$/.test(org) && org !== 'my-org' ? org : null)
|
|
9
|
+
|
|
10
|
+
/** Address of the organization's sample run on the service. */
|
|
11
|
+
export const sampleUrl = (base, org) => `${String(base).replace(/\/$/, '')}/${org}/sample`
|
|
12
|
+
|
|
13
|
+
/** Lines printed at the end of `kaloko init`. */
|
|
14
|
+
export function initNextSteps ({ base, org, loggedIn = false }) {
|
|
15
|
+
const o = realOrg(org)
|
|
16
|
+
return [
|
|
17
|
+
'Next:',
|
|
18
|
+
loggedIn ? ' 1. Signed in already (KALOKO_TOKEN found).' : ' 1. npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)',
|
|
19
|
+
o ? ` 2. Try a review on the sample run: ${sampleUrl(base, o)}` : ' 2. Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.',
|
|
20
|
+
' 3. Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:',
|
|
21
|
+
` "${FIRST_FLOW_PROMPT}"`
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Lines printed after a successful `kaloko login`. */
|
|
26
|
+
export function loginNextSteps ({ base, org }) {
|
|
27
|
+
const o = realOrg(org)
|
|
28
|
+
if (!o) return []
|
|
29
|
+
return [
|
|
30
|
+
`Sample run to try a review on: ${sampleUrl(base, o)}`,
|
|
31
|
+
'First real flow: npx kaloko draft <your app URL>, or ask your agent to walk the main flow and share it.'
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The service's review queue in words for the terminal (`kaloko review`, `kaloko share`, `kaloko feedback`): what a
|
|
2
|
+
// person still has to decide on a shared run, so an agent can tell them what waits and where. Agents never decide
|
|
3
|
+
// for people; they point them to the review mode link.
|
|
4
|
+
|
|
5
|
+
const REASON = {
|
|
6
|
+
fail: 'failed',
|
|
7
|
+
returned: 'returned',
|
|
8
|
+
needs_review: 'needs a person’s judgement',
|
|
9
|
+
not_evaluated: 'not evaluated',
|
|
10
|
+
revoked: 'carried approval revoked',
|
|
11
|
+
changed: 'changed since the accepted run',
|
|
12
|
+
new: 'new',
|
|
13
|
+
comments: 'open comments'
|
|
14
|
+
}
|
|
15
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
|
|
16
|
+
const day = (iso) => (iso ? String(iso).slice(0, 10) : '')
|
|
17
|
+
const cut = (s, n = 140) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
|
|
18
|
+
|
|
19
|
+
/** One line after `kaloko share`: "12 steps unchanged, approvals carried over; 5 need review: <link>". */
|
|
20
|
+
export function shareReviewLine (r) {
|
|
21
|
+
if (!r || r.queue == null) return null
|
|
22
|
+
const carried = r.carried ? `${plural(r.carried, 'step', 'steps')} unchanged, approvals carried over; ` : ''
|
|
23
|
+
if (!r.pending) return `${carried}no step waits for a person; the run still needs its verdict: ${r.url}`
|
|
24
|
+
return `${carried}${r.pending} ${r.pending === 1 ? 'needs' : 'need'} review: ${r.url}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Who decided and how, in a few words. */
|
|
28
|
+
function decisionText (d) {
|
|
29
|
+
if (!d) return 'waiting for a person'
|
|
30
|
+
const via = d.via === 'agent' ? ' (agent)' : d.via === 'token' ? ' (token)' : ''
|
|
31
|
+
if (d.state === 'carried') return `approval carried over: ${d.by}${via} approved it on ${day(d.at)} in run ${d.from_run}`
|
|
32
|
+
if (d.state === 'approved') return `approved by ${d.by}${via} on ${day(d.at)}`
|
|
33
|
+
return `returned by ${d.by}${via} on ${day(d.at)}: ${cut(d.note, 200)}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The whole queue for the terminal. */
|
|
37
|
+
export function formatReviewQueue (q) {
|
|
38
|
+
if (!q?.run) return 'No published run found.'
|
|
39
|
+
const c = q.counts || {}
|
|
40
|
+
const out = []
|
|
41
|
+
out.push(`Review of ${q.run.id} (${q.run.scenario}${q.run.environment ? `, ${q.run.environment}` : ''}): ${c.decided || 0} of ${plural(c.queue || 0, 'step', 'steps')} decided, ${c.pending || 0} waiting for a person${c.carried ? `; ${plural(c.carried, 'unchanged step', 'unchanged steps')} kept ${c.carried === 1 ? 'its' : 'their'} approval` : ''}`)
|
|
42
|
+
out.push(q.reference
|
|
43
|
+
? `Compared with the accepted run ${q.reference.id} (accepted${q.reference.accepted_by ? ` by ${q.reference.accepted_by}` : ''}${q.reference.accepted_at ? ` on ${day(q.reference.accepted_at)}` : ''})`
|
|
44
|
+
: 'No accepted run of this scenario yet: every step is new to the reviewers.')
|
|
45
|
+
if (!q.policy?.carry_over) out.push('Approvals do not carry over in this organization (Settings → Approvals).')
|
|
46
|
+
for (const it of q.items || []) {
|
|
47
|
+
const mark = !it.decision ? '○' : it.decision.state === 'returned' ? '↩' : '✓'
|
|
48
|
+
out.push(` ${mark} ${it.step}${it.title ? ` ${typeof it.title === 'string' ? it.title : Object.values(it.title)[0]}` : ''} — ${it.reasons.map((r) => REASON[r] || r).join(', ')}`)
|
|
49
|
+
if (it.changed?.captures?.length) out.push(` changed: ${it.changed.captures.map((x) => `${x.locale}/${x.viewport}${x.why !== 'pixels' ? ` (${x.why})` : ''}`).join(', ')}${it.changed.criteria ? ' · criteria changed' : ''}`)
|
|
50
|
+
else if (it.changed?.criteria) out.push(' changed: criteria')
|
|
51
|
+
for (const cr of (it.criteria || []).filter((x) => x.cells?.length).slice(0, 4)) {
|
|
52
|
+
const cell = cr.cells.find((x) => x.verdict === 'fail') || cr.cells[0]
|
|
53
|
+
out.push(` ${cr.id} [${cell.locale}/${cell.viewport}] ${cell.verdict}${cell.evidence ? `: ${cut(cell.evidence)}` : ''}`)
|
|
54
|
+
}
|
|
55
|
+
out.push(` ${decisionText(it.decision)}`)
|
|
56
|
+
if (it.unconfirmed) out.push(` not counted: ${decisionText(it.unconfirmed)}`)
|
|
57
|
+
if (it.open_comments) out.push(` ${plural(it.open_comments, 'open comment', 'open comments')} (kaloko feedback)`)
|
|
58
|
+
}
|
|
59
|
+
if (!(q.items || []).length) out.push(' Nothing waits for a person on the steps.')
|
|
60
|
+
if (q.carried?.length) {
|
|
61
|
+
out.push(`Approvals carried over (${q.carried.length}):`)
|
|
62
|
+
for (const x of q.carried.slice(0, 12)) out.push(` ✓ ${x.step} — ${x.approved_by}${x.approved_via && x.approved_via !== 'session' ? ` (${x.approved_via})` : ''}, ${day(x.approved_at)}, ${x.kind === 'run' ? 'accepted run' : 'step approved in'} ${x.from_run}`)
|
|
63
|
+
if (q.carried.length > 12) out.push(` … and ${q.carried.length - 12} more`)
|
|
64
|
+
}
|
|
65
|
+
const v = q.verdict || {}
|
|
66
|
+
out.push(`Run verdict: ${v.verdict || 'pending'}${v.by ? ` by ${v.by}${v.via && v.via !== 'session' ? ` (${v.via})` : ''}` : ''}${v.awaiting?.length ? ` · awaiting approval from ${v.awaiting.join(', ')}` : ''}`)
|
|
67
|
+
out.push(`Review mode for a person: ${q.review_url}`)
|
|
68
|
+
return out.join('\n')
|
|
69
|
+
}
|