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 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
- (create it in your organization's settings); `TYPESAFE_API_KEY` for the
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.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=300000 test/*.test.mjs worker/test/*.test.mjs"
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",
@@ -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, and you read `kaloko feedback` and `kaloko inbox`
32
- before continuing on that task.
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. Ask the person
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, reviewer feedback, mentions, inbox, verdicts and baselines, and
79
- cleaning up runs (trash, prune, keep, archive):
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, open rejections and comments, and failed manual criteria.
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
- say(`Next: create a tester token in the app (Settings → API tokens) and put KALOKO_TOKEN into .env.\nScenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
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
- if (files.size > 20) {
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: (await body(p)).data.length })
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
- for (const r of data.items) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
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)
@@ -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`, `Create a tester token in ${service}/${config.org || '<org>'}/settings and put ${tokenEnv}=… into .env (or ~/.config/kaloko/.env).`)
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
- add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${me.role === 'viewer' ? ' (viewer tokens cannot share runs)' : ''}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
119
- if (me.role === 'viewer') add('token role', false, 'viewer', 'kaloko share needs a tester token.')
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
+ }