kaloko 0.3.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.
Files changed (68) hide show
  1. package/README.md +119 -0
  2. package/bin/kaloko.mjs +4 -0
  3. package/bin/qawalk.mjs +4 -0
  4. package/package.json +42 -0
  5. package/schemas/bundle.v1.json +19 -0
  6. package/schemas/config.v1.json +268 -0
  7. package/schemas/revision.v1.json +36 -0
  8. package/schemas/run.v1.json +549 -0
  9. package/schemas/scenario.v1.json +668 -0
  10. package/schemas/tokens.v1.json +102 -0
  11. package/skills/kaloko/SKILL.md +94 -0
  12. package/skills/kaloko/reference/acceptance.md +208 -0
  13. package/skills/kaloko/reference/apps.md +161 -0
  14. package/skills/kaloko/reference/collaboration.md +218 -0
  15. package/skills/kaloko/reference/design.md +143 -0
  16. package/skills/kaloko/reference/documentation.md +72 -0
  17. package/skills/kaloko/reference/environments.md +140 -0
  18. package/skills/kaloko/reference/evaluation.md +62 -0
  19. package/skills/kaloko/reference/execution.md +75 -0
  20. package/skills/kaloko/reference/mcp.md +69 -0
  21. package/skills/kaloko/reference/stacks.md +122 -0
  22. package/src/cli.mjs +1344 -0
  23. package/src/lib/access.mjs +169 -0
  24. package/src/lib/apps/capture.mjs +59 -0
  25. package/src/lib/apps/drivers.mjs +284 -0
  26. package/src/lib/apps/helper.swift +102 -0
  27. package/src/lib/apps/tree.mjs +222 -0
  28. package/src/lib/browser-daemon.mjs +41 -0
  29. package/src/lib/browser.mjs +161 -0
  30. package/src/lib/build.mjs +149 -0
  31. package/src/lib/calibrate.mjs +97 -0
  32. package/src/lib/capture.mjs +233 -0
  33. package/src/lib/compare.mjs +88 -0
  34. package/src/lib/config.mjs +117 -0
  35. package/src/lib/console.mjs +13 -0
  36. package/src/lib/coverage.mjs +75 -0
  37. package/src/lib/design-cli.mjs +440 -0
  38. package/src/lib/design-core.mjs +163 -0
  39. package/src/lib/design.mjs +164 -0
  40. package/src/lib/doctor.mjs +149 -0
  41. package/src/lib/draft.mjs +76 -0
  42. package/src/lib/drift.mjs +145 -0
  43. package/src/lib/evaluate.mjs +222 -0
  44. package/src/lib/git.mjs +70 -0
  45. package/src/lib/htmlview.mjs +130 -0
  46. package/src/lib/jev.mjs +95 -0
  47. package/src/lib/mail.mjs +182 -0
  48. package/src/lib/manifest-core.mjs +127 -0
  49. package/src/lib/pack-help.mjs +55 -0
  50. package/src/lib/packs.mjs +121 -0
  51. package/src/lib/pr.mjs +78 -0
  52. package/src/lib/probe.mjs +379 -0
  53. package/src/lib/recheck.mjs +70 -0
  54. package/src/lib/run.mjs +85 -0
  55. package/src/lib/scenario.mjs +98 -0
  56. package/src/lib/schema.mjs +25 -0
  57. package/src/lib/secrets.mjs +59 -0
  58. package/src/lib/service.mjs +52 -0
  59. package/src/lib/stack.mjs +130 -0
  60. package/src/lib/tokens.mjs +352 -0
  61. package/src/lib/util.mjs +97 -0
  62. package/templates/ACCEPTANCE.md +55 -0
  63. package/templates/cs/ACCEPTANCE.md +55 -0
  64. package/templates/kaloko.config.yml +76 -0
  65. package/templates/mail/letter-opener.mjs +47 -0
  66. package/templates/scenario.yml +119 -0
  67. package/templates/steps/consent.mjs +33 -0
  68. package/viewer/index.html +2772 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,1344 @@
1
+ import { writeFile, access, readFile, mkdir } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { gzipSync } from 'node:zlib'
4
+ import { join, resolve, relative, dirname } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+ import yaml from 'js-yaml'
7
+ import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE } from './lib/config.mjs'
8
+ import { loadScenario, validateScenario } from './lib/scenario.mjs'
9
+ import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
10
+ import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
11
+ import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
12
+ import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
13
+ import { observeConsole } from './lib/console.mjs'
14
+ import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
15
+ import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher } from './lib/access.mjs'
16
+ import { rememberEnvironment } from './lib/secrets.mjs'
17
+ import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
18
+ import { buildManifest, writePreview } from './lib/build.mjs'
19
+ import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
20
+ import { packageRoot } from './lib/schema.mjs'
21
+ import { compareRuns, formatComparison } from './lib/compare.mjs'
22
+ import { doctor, formatDoctor } from './lib/doctor.mjs'
23
+ import { parsePlan, coverage, formatCoverage } from './lib/coverage.mjs'
24
+ import { calibrate, formatCalibration, casesFromGolden, casesFromRun } from './lib/calibrate.mjs'
25
+ import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
26
+ import { recheckCaptures } from './lib/recheck.mjs'
27
+ import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
28
+ import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
29
+ import { expandPacks } from './lib/packs.mjs'
30
+ import { upsertPrComment } from './lib/pr.mjs'
31
+ import { htmlViewAllowed } from './lib/htmlview.mjs'
32
+ import { service } from './lib/service.mjs'
33
+ import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
34
+ import { designDir, readBase, headKey } from './lib/design.mjs'
35
+ import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
36
+ import { captureApp } from './lib/apps/capture.mjs'
37
+ import { readTree, flatten } from './lib/apps/tree.mjs'
38
+
39
+ const fmtBytes = (n) => { n = Number(n) || 0; const u = ['B', 'kB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ } return `${n.toFixed(i ? 1 : 0)} ${u[i]}` }
40
+
41
+ const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
42
+
43
+ Usage: kaloko <command> [options]
44
+
45
+ Project
46
+ draft <url> [url…] [--id SITE-X] [--title t] [--out qa/flows/site.yml] [--force]
47
+ First plan + read-only production scenario for public pages, drafted from the
48
+ pages (title, description, H1) with the seo, a11y, perf and console packs
49
+ init [--agent claude|codex|all] [--org <slug>]
50
+ Create ${CONFIG_FILE} and install the kaloko skill for your agent
51
+ doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
52
+ and app tools; --fix asks macOS for the desktop capture permissions
53
+ validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
54
+ Validate scenarios (schema + references); --coverage compares criteria with the plan
55
+ With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
56
+ scenarios List scenarios matched by the config
57
+
58
+ Run
59
+ start --scenario <file> --env <name> [--locales cs,en] [--viewports desktop,mobile] [--executor <name>]
60
+ [--app-version <v>] (default: the git tag on HEAD; documentation captures carry it)
61
+ Create a run directory and make it current
62
+ browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
63
+ Shared Chromium with CDP (agents, scripts and people drive it)
64
+ open --step <id> [--locale l] Navigate the current page to the step URL
65
+ session reset New visitor: clear cookies and storage in the shared browser
66
+ capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
67
+ Capture the current page for a step in all viewports
68
+ capture --step <id> --crop <css> [--title <text>]
69
+ Attach an element screenshot to a step
70
+ capture --step <id> --image <shot.png> [--tree <dump.xml|json> | --ocr] [--platform android|ios|desktop]
71
+ [--device <name>] [--density 2.625 | --scale 3] [--viewport <name>]
72
+ An app screen taken by any tool (Maestro, Appium, XCUITest, a person) with its
73
+ UI dump (uiautomator/Appium XML, idb or Maestro JSON); app scenarios without
74
+ --image capture the current screen of the environment's app
75
+ mail --step <id> [--locale l] [--to <addr>] [--subject <text>] [--timeout 60]
76
+ [--since <ISO date> | --any-time] [--id <mailbox message id>]
77
+ Only messages received since the run started, unless --since/--any-time; --id takes one exact message
78
+ Capture an e-mail from the environment's mailbox (mail.provider) as an email step
79
+ mail --step <id> --file <message.eml|.html|.json|.txt> [--subject <text>]
80
+ Capture an e-mail you obtained yourself (any stack, any mailbox)
81
+ auth save --env <e> --account <key> [--url <url>]
82
+ A person signs in once (SSO, MFA) in a visible browser; the session is saved
83
+ for that account and walk reuses it (never uploaded)
84
+ auth list Saved sessions per environment and account, with their age
85
+ walk [--steps a,b] [--locales l] [--keep-going] [--ephemeral]
86
+ Run playwright steps (execution.script) and capture them; a session stops
87
+ after 3 steps in a row fail with the same error unless --keep-going
88
+ One walk at a time per shared browser; --ephemeral walks in an own browser
89
+ Semantic questions start as each step is captured; the walk ends with the
90
+ verdicts and what failed (--no-evaluate skips that; kaloko evaluate redoes it)
91
+ [--items a,b] [--limit N] Stack steps (kind: stack, many pages of one template): only pages whose id or
92
+ path contains a, b; at most N pages (default items.limit, 500)
93
+ status Show what the current run has captured
94
+ Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
95
+
96
+ Results
97
+ evaluate Deterministic checks + semantic evaluator (default JEV) for all captures
98
+ evaluate --recheck [--steps a,b] After a scenario fix: re-run deterministic checks on the stored HTML (no walk);
99
+ console, overflow and performance keep their live result
100
+ build Write run.json and the local preview (index.html)
101
+ preview Build and print the preview path
102
+ share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role);
103
+ --pr adds or updates one comment on the branch's pull request via gh
104
+ feedback [--run id] [--scenario id] [--json]
105
+ Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies
106
+ comment [--step id | --flow] --note "…" [--status rejected|approved] [--run id] [--scenario id]
107
+ comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
108
+ Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
109
+ projects [--json] Projects of the organization (use the slug as project: in a scenario or config)
110
+ runs [--scenario id] [--all] [--json]
111
+ Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every run
112
+ trash <run-id>... | restore <run-id>
113
+ Move runs to the trash (uploader or admin) and back; restorable until purge
114
+ prune [--scenario id] [--environment e] [--keep 3] [--older-than 14d] [--yes]
115
+ Keep the newest runs per scenario and environment plus kept, accepted, baseline,
116
+ referenced and commented runs; lists what would go to the trash unless --yes
117
+ keep <run-id> [--off] A kept run never expires and is never pruned
118
+ archive <scenario-id> [--undo] Hide a retired scenario from the scenario list (admin); its runs stay reachable
119
+ inbox [--all] [--read] [--json] Your notifications on kaloko.app (mentions, returned runs, new runs, expiry); --read marks them read
120
+ calibrate [--golden labels.yml] [--scenario id [--limit 30]] [--target 0.95] [--json]
121
+ Semantic evaluator vs human labels: precision/recall per criterion at the
122
+ configured thresholds and suggested thresholds (labels from a golden file or
123
+ from reviewers' criterion verdicts in the service)
124
+ export [--run id] [--out file.zip] Download a shared run (run.json + files) as a zip before it expires
125
+ export --scenario <id> [--accepted] [--out dir] [--locales l] [--viewports v]
126
+ Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
127
+ index.json (documentation layer, Business plan); unchanged files are skipped
128
+ public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
129
+ compare [--run id] [--baseline id | --design] [--json]
130
+ Text diff of two runs: step statuses, criterion verdicts, scores and evidence
131
+ (default: current run against the scenario baseline, else the previous run)
132
+
133
+ Design (environments with kind: design; live HTML steps in a folder)
134
+ start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
135
+ walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
136
+ status Per step: synced, changed here, changed in the draft, conflict; step status
137
+ sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b]
138
+ Exchange revisions with the shared draft; only changed steps and missing files travel
139
+ share [--name <text>] [--branch b] Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged"
140
+ history [--scenario id] [--step id] [--locale l] [--json]
141
+ Versions of the flow; revisions of a step with who (person or agent) and when
142
+ reference [--scenario id] (--design|--baseline) (--run id|--version n|--off) [--branch b]
143
+ Show or set the design reference (accepted version) and the baseline
144
+ tokens pull|push|check|validate [--project p] [--file tokens.json|tokens.resolver.json] [--mode m] [--json]
145
+ The project's token set (DTCG); check lists raw values outside it in the live folder
146
+ branch list | create <name> [--from vN] [--git <branch>] | merge <name> [--take-branch a,b] [--keep-main c]
147
+ Branches of the draft; a git-linked branch is what implementation runs of that git branch check against
148
+ export --live [--version n|--draft|--run id] [--out design/]
149
+ Live files of a version (default: the design reference) into a folder: the handoff
150
+ export --live --views --run <id> [--viewport desktop] [--out design/]
151
+ Fork into a draft: the HTML views of an app run become design steps (experimental)
152
+
153
+ Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
154
+ `
155
+
156
+ /** validate: scenarios the service still lists as active whose scenario file no longer exists here (#58). */
157
+ async function warnRetired (args) {
158
+ if (args.offline) return
159
+ let config, client
160
+ try { ({ config } = await context(args)); client = service(config) } catch { return } // no project or no token: offline
161
+ if (!config?.org) return
162
+ let list
163
+ try { list = await client.request('GET', `/orgs/${config.org}/scenarios?runs=1`) } catch { return }
164
+ const gone = []
165
+ for (const s of list || []) {
166
+ if (s.archived || !s.file || (config.project && s.project && s.project !== config.project)) continue
167
+ try { await readFile(resolve(s.file)) } catch { gone.push(s) }
168
+ }
169
+ for (const s of gone) say(`! ${s.id}: the service lists it as active, but ${s.file} is gone here (split or renamed?). Archive it: kaloko archive ${s.id}`)
170
+ }
171
+
172
+ /** The end of a walk: evaluate (the semantic answers are mostly cached by now) and say what did not pass. */
173
+ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
174
+ const t0 = Date.now()
175
+ await prefetch.done()
176
+ const report = await evaluateRun({ run, scenario, config, root })
177
+ const fails = report.findings.filter((f) => f.verdict === 'fail'); const unsure = report.findings.filter((f) => f.verdict === 'needs_review')
178
+ 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` : ''}.`)
179
+ const line = (f) => `${f.node}${f.item ? `/${f.item}` : ''} ${f.locale}/${f.viewport} ${f.id}${f.why ? `: ${String(f.why).slice(0, 140)}` : ''}`
180
+ // pack criteria say how to fix them; a fix repeated across steps and locales is printed once
181
+ const hinted = new Set()
182
+ const withFix = (f) => { const l = line(f); if (!f.fix || hinted.has(f.id)) return l; hinted.add(f.id); return `${l}\n fix: ${f.fix}` }
183
+ 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)` : ''}`)
184
+ if (unsure.length) say(` ? ${unsure.slice(0, 8).map(line).join('\n ? ')}${unsure.length > 8 ? `\n … ${unsure.length - 8} more` : ''}`)
185
+ if (report.skipped.length) say(` Semantic criteria not evaluated: ${[...new Set(report.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}`)
186
+ }
187
+
188
+ function parse (argv) {
189
+ const args = { _: [] }
190
+ for (let i = 0; i < argv.length; i++) {
191
+ const a = argv[i]
192
+ if (a.startsWith('--')) {
193
+ const [k, v] = a.slice(2).split('=')
194
+ if (v !== undefined) args[k] = v
195
+ else if (argv[i + 1] && !argv[i + 1].startsWith('--')) args[k] = argv[++i]
196
+ else args[k] = true
197
+ } else args._.push(a)
198
+ }
199
+ return args
200
+ }
201
+ const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
202
+ const say = (...m) => console.log(...m)
203
+
204
+ async function context (args) {
205
+ const { config, root } = await loadConfig(process.cwd(), args.config)
206
+ return { config, root }
207
+ }
208
+
209
+ async function runContext (args) {
210
+ const ctx = await context(args)
211
+ const run = await resolveRun({ root: ctx.root, output: ctx.config.output, run: args.run })
212
+ const scenario = await loadScenario(resolve(ctx.root, run.meta.scenario_file))
213
+ const env = environment(ctx.config, run.meta.environment.name)
214
+ // a design is not checked against itself: the design reference checks apply to implementation runs only (#42)
215
+ if (env.kind === 'design') for (const n of scenario.nodes) if (n.criteria) n.criteria = n.criteria.filter((c) => !['design_pixels', 'design_structure'].includes(c.check.assert))
216
+ rememberEnvironment(env)
217
+ return { ...ctx, run, scenario, env }
218
+ }
219
+
220
+ function stepOf (scenario, id) {
221
+ if (!id) throw new UserError('--step <id> is required.')
222
+ const node = scenario.nodes.find((n) => n.id === id)
223
+ if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
224
+ return node
225
+ }
226
+
227
+ /** Scenario of --scenario (id or file) or of the current run. */
228
+ async function scenarioIdOf (args, config, root) {
229
+ if (typeof args.scenario === 'string') {
230
+ if (/\.ya?ml$/.test(args.scenario)) return (await loadScenario(resolve(args.scenario))).id
231
+ return args.scenario
232
+ }
233
+ try { return (await resolveRun({ root, output: config.output, run: args.run })).meta.scenario.id } catch {}
234
+ throw new UserError('--scenario <id|file> is required when there is no current run.')
235
+ }
236
+
237
+ function scopeViewports (run, pick) {
238
+ const all = run.meta.scope.viewports
239
+ const names = pick || Object.keys(all)
240
+ return Object.fromEntries(names.map((n) => { if (!all[n]) throw new UserError(`Unknown viewport "${n}".`); return [n, all[n]] }))
241
+ }
242
+
243
+ async function scenarioFiles (config, root) {
244
+ const { readdir } = await import('node:fs/promises')
245
+ const out = []
246
+ for (const pattern of config.scenarios || []) {
247
+ const parts = pattern.split('/')
248
+ const fixed = []
249
+ for (const p of parts) { if (/[*?]/.test(p)) break; fixed.push(p) }
250
+ const base = join(root, ...fixed)
251
+ const re = globToRegExp(pattern)
252
+ const walk = async (dir) => {
253
+ let entries = []
254
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
255
+ for (const e of entries) {
256
+ const full = join(dir, e.name)
257
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && !e.name.startsWith('.')) await walk(full) } else if (re.test(relative(root, full))) out.push(full)
258
+ }
259
+ }
260
+ await walk(base)
261
+ }
262
+ return [...new Set(out)].sort()
263
+ }
264
+
265
+ async function catalogFor (config, root) {
266
+ const runs = await listRuns(root, config.output)
267
+ const out = []
268
+ for (const file of await scenarioFiles(config, root)) {
269
+ try {
270
+ const s = yaml.load(await readFile(file, 'utf8'))
271
+ out.push({
272
+ id: s.id, title: s.title, kind: s.kind || 'task', file: relative(root, file),
273
+ runs: runs.filter((r) => r.scenario?.id === s.id).map((r) => ({ id: r.id, started_at: r.started_at, git: r.git, environment: r.environment, summary: r.summary })).sort((a, b) => b.started_at.localeCompare(a.started_at))
274
+ })
275
+ } catch {}
276
+ }
277
+ return out
278
+ }
279
+
280
+ // `{run}` in an address stands for the short run id (same as mail.address in the config).
281
+ const expandRun = (value, short) => (value || '').replace('{run}', short)
282
+
283
+ // Finds the e-mail (or takes the one given) and captures it as the node's screens.
284
+ async function captureMail ({ config, root, run, env, node, locale, to, subject, timeoutMs = 60000, viewports, message = null, since, id = null }) {
285
+ if (!message) {
286
+ const client = mailClient(env.mail, root, env)
287
+ // only messages that arrived during this run unless told otherwise (#52): stale e-mails are not captured silently
288
+ message = await waitForMessage(client, { to, subject, since: since === undefined ? run.meta.started_at : since, id }, { timeoutMs })
289
+ }
290
+ const browser = await launchEphemeral(config)
291
+ try {
292
+ // The capture itself needs page JavaScript (webp encoding, settle), so the
293
+ // context keeps it on; the e-mail's own scripts are stripped and every
294
+ // non-image request is blocked instead. Images hosted behind the environment's
295
+ // gate get its headers, nobody else does.
296
+ const page = await (await browser.newContext()).newPage()
297
+ const gate = gateHeaders(env)
298
+ const toGate = Object.keys(gate).length ? originMatcher(gateOrigins(env)) : () => false
299
+ await page.route('**/*', (route) => {
300
+ const r = route.request()
301
+ if (r.url().startsWith('data:')) return route.continue()
302
+ if (r.resourceType() !== 'image') return route.abort()
303
+ return toGate(r.url()) ? route.continue({ headers: { ...r.headers(), ...gate } }) : route.continue()
304
+ })
305
+ const html = (message.html || `<pre>${(message.text || '').replace(/</g, '&lt;')}</pre>`).replace(/<script[\s\S]*?<\/script>/gi, '')
306
+ // Remote images are allowed for the screenshot but must not hold the capture hostage.
307
+ await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 20000 })
308
+ await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {})
309
+ const mail = { id: message.id, subject: message.subject, from: message.from || '', to: (message.to || []).join(', '), date: message.date }
310
+ const results = await captureStep({ page, node, locale, viewports: scopeViewports(run, viewports), run, maxChars: config.evaluators?.jev?.max_state_chars, extra: { mail } })
311
+ return { subject: mail.subject, count: results.length }
312
+ } finally {
313
+ await browser.close()
314
+ }
315
+ }
316
+
317
+ /** Documentation layer: newest (accepted) capture of every step into a folder, skipping files whose run did not change. */
318
+ async function exportScenario ({ args, config, root, client }) {
319
+ const { mkdir: mk, writeFile: wf, readFile: rf } = await import('node:fs/promises')
320
+ const id = args.scenario
321
+ const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, 'docs', id)
322
+ const q = args.accepted ? '?accepted=1' : ''
323
+ const index = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(id)}/steps${q}`)
324
+ const want = (v, allowed) => !allowed || allowed.includes(v)
325
+ const locales = list(args.locales); const viewports = list(args.viewports)
326
+ let previous = null
327
+ try { previous = JSON.parse(await rf(join(out, 'index.json'), 'utf8')) } catch {}
328
+ const had = new Map((previous?.steps || []).flatMap((s) => (s.files || []).map((f) => [`${s.id}|${f.locale}|${f.viewport}`, f])))
329
+ const steps = []; let written = 0; let kept = 0
330
+ for (const s of index.steps) {
331
+ const files = []
332
+ for (const c of s.captures.filter((c) => want(c.locale, locales) && want(c.viewport, viewports))) {
333
+ const ext = c.image.format
334
+ const rel = `steps/${s.id}/${c.locale}/${c.viewport}`
335
+ const old = had.get(`${s.id}|${c.locale}|${c.viewport}`)
336
+ const entry = { locale: c.locale, viewport: c.viewport, image: `${rel}.${ext}`, html: `${rel}.html`, run: c.run.id, captured_at: c.captured_at, page_url: c.page_url, size: c.size, version: c.run.version }
337
+ if (old && old.run === c.run.id) { files.push(old); kept++; continue }
338
+ await mk(join(out, 'steps', s.id, c.locale), { recursive: true })
339
+ const img = await client.fetch(c.links.image.replace(/^https?:\/\/[^/]+\/api\/v1/, ''))
340
+ await wf(join(out, entry.image), Buffer.from(await img.arrayBuffer()))
341
+ const html = await client.fetch(c.links.html.replace(/^https?:\/\/[^/]+\/api\/v1/, ''))
342
+ await wf(join(out, entry.html), Buffer.from(await html.arrayBuffer()))
343
+ files.push(entry); written++
344
+ }
345
+ steps.push({ id: s.id, title: s.title, status: s.status, files })
346
+ }
347
+ await mk(out, { recursive: true })
348
+ await wf(join(out, 'index.json'), JSON.stringify({ schema: 'qawalk.docs-export.v1', scenario: index.scenario, run: index.run, accepted: !!args.accepted, exported_at: new Date().toISOString(), steps }, null, 2) + '\n')
349
+ say(`Exported ${index.steps.length} steps of ${id} from run ${index.run.id}${index.run.app_version ? ` (version ${index.run.app_version})` : ''} → ${relative(process.cwd(), out) || '.'}\n ${written} files downloaded, ${kept} unchanged`)
350
+ }
351
+
352
+ /**
353
+ * Walks a stack step: resolves its pages, then opens and captures each one with the step's criteria and index
354
+ * fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
355
+ */
356
+ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args }) {
357
+ const { items, total, truncated } = await resolveItems({ node, base: baseUrl(env, locale), page, scenarioDir, env, locale, limit: Number(args.limit) || undefined })
358
+ const only = list(args.items)
359
+ const chosen = only ? items.filter((it) => only.some((q) => it.id === q || it.path.includes(q))) : items
360
+ const scope = run.meta.scope.viewports
361
+ const vpNames = (node.items.viewports || []).filter((v) => scope[v])
362
+ const viewports = Object.fromEntries((vpNames.length ? vpNames : [Object.keys(scope)[0]]).map((v) => [v, scope[v]]))
363
+ say(`▦ ${node.id} ${locale}: ${chosen.length} page(s)${truncated ? ` — limit ${Number(args.limit) || node.items.limit || DEFAULT_LIMIT} reached (${total}+ found; raise items.limit or pass --limit)` : ''}`)
364
+ const fields = fieldsOf(node)
365
+ const script = node.execution?.script ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
366
+ let status = null
367
+ const onResponse = (r) => { if (r.request().isNavigationRequest() && r.frame() === page.mainFrame()) status = r.status() }
368
+ page.on('response', onResponse)
369
+ const streak = failureStreak(5)
370
+ const failed = []; let ok = 0
371
+ try {
372
+ for (const [i, item] of chosen.entries()) {
373
+ if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
374
+ status = null
375
+ const since = Date.now()
376
+ let captured = false
377
+ const capture = (opts = {}) => { captured = true; return captureStep({ page, node, locale, viewports: opts.viewports ? Object.fromEntries(opts.viewports.map((v) => [v, scope[v]])) : viewports, run, consoleFile, since, item, fields, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { http_status: status } }) }
378
+ try {
379
+ if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
380
+ else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
381
+ if (!captured) await capture()
382
+ ok++; streak.ok('stack')
383
+ } catch (error) {
384
+ failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
385
+ if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}): the last 5 pages failed with the same error`)
386
+ }
387
+ if ((i + 1) % 25 === 0 && i + 1 < chosen.length) say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}`)
388
+ }
389
+ } finally { page.off('response', onResponse) }
390
+ say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}: ${ok}/${chosen.length} page(s) captured`)
391
+ return failed
392
+ }
393
+
394
+ /** Deep link of an app step: a URL scheme as written, a path under the environment's base URL (universal links). */
395
+ function linkFor (env, node, locale) {
396
+ const raw = typeof node.path === 'string' ? node.path : (node.path?.[locale] ?? node.path?.default)
397
+ if (!raw) return null
398
+ const p = raw.replaceAll('{random}', () => Math.random().toString(36).slice(2, 10))
399
+ if (/^[a-z][a-z0-9+.-]*:/i.test(p)) return p
400
+ return env.base_url ? urlFor(env, { path: p }, locale) : null
401
+ }
402
+ const appViewport = (run) => Object.keys(run.meta.scope.viewports || {})[0] || null
403
+
404
+ /**
405
+ * Walks an app scenario (platform android, ios, electron, desktop) with the environment's driver: per locale the
406
+ * app starts fresh, each step runs its script (with `app`, the driver) or opens its deep link, then is captured.
407
+ */
408
+ async function walkApp ({ config, root, run, scenario, env, args }) {
409
+ const platform = scenario.platform
410
+ const pick = list(args.steps)
411
+ const locales = list(args.locales) || run.meta.scope.locales
412
+ const scenarioDir = resolve(root, run.meta.scenario_file, '..')
413
+ const pending = []; const failed = []; const skipped = []
414
+ // WebP and thumbnails are encoded in a headless Chrome page (no native image dependency); PNG without Chrome
415
+ const enc = await launchEphemeral(config).catch(() => null)
416
+ const encoder = enc ? await enc.newPage().catch(() => null) : null
417
+ const streak = failureStreak(args['keep-going'] ? 0 : 3)
418
+ const maxChars = config.evaluators?.jev?.max_state_chars
419
+ try {
420
+ for (const locale of locales) {
421
+ const driver = driverFor(platform, env, { root, scenarioDir, locale })
422
+ try {
423
+ await driver.open({ locale, reset: true })
424
+ if (driver.web) observeConsole(driver.page)
425
+ say(`${platform} ${locale}: ${driver.web ? 'Electron window' : 'app'} ready`)
426
+ } catch (error) { failed.push(`${platform} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${platform} ${locale}: ${error.message.split('\n')[0]}`); await driver.close().catch(() => {}); if (error instanceof UserError) break; continue }
427
+ try {
428
+ for (const node of scenario.nodes) {
429
+ if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
430
+ if (node.kind === 'stack') { pending.push(`${node.id} (${locale}): stack steps list web pages; not walked on ${platform}`); continue }
431
+ if (node.kind === 'email' && node.mail?.to) {
432
+ try { const r = await captureMail({ config, root, run, env, node, locale, to: expandRun(node.mail.to, run.meta.short), subject: localized(node.mail?.subject, locale), timeoutMs: 45000 }); say(`✓ ${node.id} ${locale}: "${r.subject}"`) } catch (error) { failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`) }
433
+ continue
434
+ }
435
+ const scripted = ['playwright', 'script'].includes(node.execution?.strategy) && node.execution?.script
436
+ const link = linkFor(env, node, locale)
437
+ if (!scripted && !(link && ['playwright', 'script'].includes(node.execution?.strategy))) { pending.push(`${node.id} (${locale}): ${node.execution?.strategy || 'agent'} — ${(node.execution?.instructions || '').split('\n')[0]}`); continue }
438
+ if (streak.stopped(locale)) { skipped.push(`${node.id} (${locale})`); continue }
439
+ try {
440
+ let captured = false
441
+ const capture = async (o = {}) => {
442
+ captured = true
443
+ if (driver.web) {
444
+ const [w, h] = await driver.page.evaluate(() => [innerWidth, innerHeight])
445
+ return captureStep({ page: driver.page, node, locale, options: captureOptions(scenario, node), viewports: { [o.viewport || appViewport(run) || 'window']: [w, h] }, run, consoleFile: null, maxChars, extra: { platform, device: 'Electron' } })
446
+ }
447
+ return captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: o.viewport || appViewport(run), run, platform, encoder, maxChars, extra: { url: link } })
448
+ }
449
+ if (scripted) {
450
+ const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
451
+ await mod.default({ app: driver, page: driver.page || null, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, env, run: run.meta, capture, link: (id = node.id) => linkFor(env, stepOf(scenario, id), locale), url: (id = node.id) => linkFor(env, stepOf(scenario, id), locale) })
452
+ } else await driver.link(link)
453
+ if (!captured) await capture()
454
+ say(`✓ ${node.id} ${locale}`)
455
+ streak.ok(locale)
456
+ } catch (error) {
457
+ failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
458
+ say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
459
+ if (streak.record(locale, error.message)) say(` stopping ${locale}: the last 3 steps failed with the same error`)
460
+ }
461
+ }
462
+ } finally { await driver.close().catch(() => {}) }
463
+ }
464
+ } finally { await enc?.close().catch(() => {}) }
465
+ if (pending.length) say(`\nSteps for an agent or a person (drive the app, then kaloko capture --step <id> --image shot.png [--tree dump.xml|json]):\n - ${[...new Set(pending)].join('\n - ')}`)
466
+ if (failed.length) { say(`\nSteps that failed (fix and rerun with --steps):\n - ${failed.join('\n - ')}`); process.exitCode = 1 }
467
+ if (skipped.length) { say(`\nSteps not walked after repeated failures:\n - ${skipped.join('\n - ')}`); process.exitCode = 1 }
468
+ }
469
+
470
+ /**
471
+ * `kaloko capture --image`: a screen someone else took (a person, an agent, Maestro, Appium, XCUITest, Detox),
472
+ * with its UI dump when there is one (uiautomator/Appium XML, idb or Maestro JSON), or OCR on macOS (--ocr).
473
+ */
474
+ async function captureImage ({ config, run, scenario, node, locale, args }) {
475
+ const png = await readFile(resolve(String(args.image))).catch(() => { throw new UserError(`--image ${args.image} cannot be read.`) })
476
+ const px = pngSize(png)
477
+ if (!px) throw new UserError('--image must be a PNG screenshot.')
478
+ const platform = typeof args.platform === 'string' ? args.platform : (scenario.platform && scenario.platform !== 'web' ? scenario.platform : 'desktop')
479
+ let scale = Number(args.scale) || null
480
+ let tree = null
481
+ if (typeof args.tree === 'string') {
482
+ const raw = await readFile(resolve(args.tree), 'utf8').catch(() => { throw new UserError(`--tree ${args.tree} cannot be read.`) })
483
+ const density = Number(args.density) || 1
484
+ tree = readTree(raw, { density })
485
+ // without --scale: the tree's extent against the image width (Android dumps in pixels stay 1 unless --density)
486
+ if (!scale && Number(args.density)) scale = density
487
+ if (!scale) { const right = Math.max(0, ...flatten(tree.root).map((n) => n.bounds ? n.bounds.x + n.bounds.w : 0)); scale = right > 0 ? Math.max(1, Math.min(4, Math.round(px[0] / right))) : 1 }
488
+ } else if (args.ocr) {
489
+ scale ||= platform === 'ios' ? 3 : 1
490
+ const res = await ocrImage(png, locale ? [locale] : undefined)
491
+ tree = readTree({ ocr: res.ocr.map((l) => ({ text: l.text, x: Math.round(l.x / scale), y: Math.round(l.y / scale), w: Math.round(l.w / scale), h: Math.round(l.h / scale) })) })
492
+ }
493
+ scale ||= 1
494
+ const enc = await launchEphemeral(config).catch(() => null)
495
+ try {
496
+ const encoder = enc ? await enc.newPage().catch(() => null) : null
497
+ return await captureApp({ snapshot: { png, tree, size: [Math.round(px[0] / scale), Math.round(px[1] / scale)], scale, device: typeof args.device === 'string' ? args.device : null, crash: null, logErrors: null }, node, locale, viewport: typeof args.viewport === 'string' ? args.viewport : appViewport(run), run, platform, encoder, maxChars: config.evaluators?.jev?.max_state_chars, extra: { source: 'import', url: typeof args.url === 'string' ? args.url : null } })
498
+ } finally { await enc?.close().catch(() => {}) }
499
+ }
500
+
501
+ const commands = {
502
+ async init (args) {
503
+ const cwd = process.cwd()
504
+ const file = join(cwd, CONFIG_FILE)
505
+ let exists = false
506
+ try { await access(file); exists = true } catch {}
507
+ // a project from before the rename keeps its qawalk.config.yml (it is still read)
508
+ try { await access(join(cwd, LEGACY_CONFIG_FILE)); exists = true; say(`${LEGACY_CONFIG_FILE} found; kept (still read; rename it to ${CONFIG_FILE} when convenient).`) } catch {}
509
+ if (!exists) {
510
+ let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
511
+ if (typeof args.org === 'string') {
512
+ if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(args.org)) throw new UserError('--org must be the organization slug from kaloko.app (a-z, 0-9, -).')
513
+ tpl = tpl.replace(/^org: .*$/m, `org: ${args.org}`)
514
+ }
515
+ await writeFile(file, tpl)
516
+ say(`Created ${CONFIG_FILE}. Edit org and environments.`)
517
+ } else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
518
+ const agent = typeof args.agent === 'string' ? args.agent : 'none'
519
+ if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
520
+ const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
521
+ const skill = join(packageRoot, 'skills', 'kaloko')
522
+ const targets = { claude: ['.claude/skills/kaloko'], codex: ['skills/kaloko'], all: ['.claude/skills/kaloko', 'skills/kaloko'], none: [] }[agent]
523
+ for (const t of targets) {
524
+ await mkdir(join(cwd, t, '..'), { recursive: true })
525
+ await cp(skill, join(cwd, t), { recursive: true, force: true })
526
+ say(`Installed skill → ${t}`)
527
+ // the skill was called qawalk before the rename: two copies would compete for the same tasks
528
+ const legacy = join(cwd, t.replace(/kaloko$/, 'qawalk'))
529
+ if (existsSync(join(legacy, 'SKILL.md')) && /^name: qawalk$/m.test(await readFile(join(legacy, 'SKILL.md'), 'utf8'))) { await rm(legacy, { recursive: true, force: true }); say(`Removed the former skill ${t.replace(/kaloko$/, 'qawalk')}`) }
530
+ }
531
+ if (agent === 'codex' || agent === 'all') {
532
+ const agents = join(cwd, 'AGENTS.md')
533
+ let text = ''
534
+ try { text = await readFile(agents, 'utf8') } catch {}
535
+ if (text.includes('skills/qawalk/SKILL.md')) {
536
+ await writeFile(agents, text.replace(/## QAwalk\n/g, '## Kaloko\n').replace(/the QAwalk canvas/g, 'the Kaloko canvas').replaceAll('skills/qawalk/SKILL.md', 'skills/kaloko/SKILL.md'))
537
+ say('Updated the pointer in AGENTS.md to skills/kaloko')
538
+ } else if (!text.includes('skills/kaloko')) {
539
+ await appendFile(agents, `${text && !text.endsWith('\n') ? '\n' : ''}\n## Kaloko\n\nFor acceptance plans, scenario runs and the Kaloko canvas use \`skills/kaloko/SKILL.md\`.\n`)
540
+ say('Added a Kaloko pointer to AGENTS.md')
541
+ }
542
+ }
543
+ const consentStep = join(cwd, 'qa', 'flows', 'steps', 'consent.mjs')
544
+ try { await access(consentStep) } catch {
545
+ await mkdir(join(consentStep, '..'), { recursive: true })
546
+ await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
547
+ say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
548
+ }
549
+ 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')}`)
550
+ },
551
+
552
+ async validate (args) {
553
+ if (!args._.length) throw new UserError('Usage: kaloko validate <scenario.yml>...')
554
+ let failed = 0
555
+ for (const file of args._) {
556
+ const scenario = yaml.load(await readFile(file, 'utf8'))
557
+ const problems = validateScenario(scenario)
558
+ if (!problems.length) expandPacks(scenario)
559
+ if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
560
+ if (args.coverage) {
561
+ // the plan is scenario.source (relative to the working directory or to the scenario file), or --source
562
+ const candidates = typeof args.source === 'string' ? [resolve(args.source)] : scenario.source ? [resolve(scenario.source), resolve(file, '..', scenario.source), resolve(file, '..', '..', scenario.source)] : []
563
+ let plan = null
564
+ for (const c of candidates) { try { plan = parsePlan(await readFile(c, 'utf8')); break } catch {} }
565
+ if (!plan) { say(` · no acceptance plan found (${scenario.source ? `source: ${scenario.source}` : 'scenario has no source'}); pass --source <ACCEPTANCE.md>`); continue }
566
+ const cov = coverage(scenario, plan)
567
+ say(formatCoverage(file, cov))
568
+ if (cov.missing.length) process.exitCode = 1
569
+ }
570
+ }
571
+ if (failed) process.exitCode = 1
572
+ await warnRetired(args)
573
+ },
574
+
575
+ async scenarios (args) {
576
+ const { config, root } = await context(args)
577
+ for (const s of await catalogFor(config, root)) say(`${s.id.padEnd(36)} ${s.kind.padEnd(5)} ${String(s.runs.length).padStart(3)} runs ${s.file}`)
578
+ },
579
+
580
+ async start (args) {
581
+ const { config, root } = await context(args)
582
+ if (!args.scenario || !args.env) throw new UserError('Usage: kaloko start --scenario <file> --env <name>')
583
+ const scenarioFile = resolve(args.scenario)
584
+ const scenario = await loadScenario(scenarioFile)
585
+ const env = environment(config, args.env)
586
+ if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
587
+ if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
588
+ const locales = list(args.locales) || scenario.locales || ['default']
589
+ const vpAll = scenario.viewports || { desktop: [1280, 800], mobile: [390, 844] }
590
+ const vpNames = list(args.viewports) || Object.keys(vpAll)
591
+ const scope = { locales, viewports: Object.fromEntries(vpNames.map((n) => [n, vpAll[n]])), ...(args.note ? { note: args.note } : {}) }
592
+ const git = await gitMeta(root)
593
+ const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
594
+ const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
595
+ const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
596
+ const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer })
597
+ say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.join(', ')}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
598
+ const addr = recipient(env.mail, meta.short)
599
+ if (addr) say(` test e-mail: ${addr}`)
600
+ say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
601
+ },
602
+
603
+ async browser (args) {
604
+ const { config, root } = await context(args)
605
+ const action = args._[0] || 'status'
606
+ let env = null
607
+ if (typeof args.env === 'string') env = environment(config, args.env)
608
+ else { try { const run = await resolveRun({ root, output: config.output, run: args.run }); env = environment(config, run.meta.environment.name) } catch {} }
609
+ if (action === 'start') {
610
+ const state = await startBrowser({ root, config, env, headed: !!args.headed })
611
+ say(`${state.reused ? 'Browser already running' : 'Browser started'} for ${env.name}: ${state.cdp} (pid ${state.pid})${state.readonly ? ', readonly policy on' : ''}\nConnect Playwright with chromium.connectOverCDP('${state.cdp}') or Playwright MCP --cdp-endpoint ${state.cdp}`)
612
+ } else if (action === 'stop') {
613
+ say(await stopBrowser({ root, config, env }) ? `Browser stopped${env ? ` (${env.name})` : ' (all environments)'}.` : 'Browser was not running.')
614
+ } else {
615
+ const states = await allStates({ root, config })
616
+ say(states.length ? states.map((s) => `${s.environment}: ${s.cdp} (pid ${s.pid})${s.readonly ? ', readonly' : ''}`).join('\n') : 'No shared browser is running.')
617
+ }
618
+ },
619
+
620
+ async open (args) {
621
+ const { config, root, run, scenario, env } = await runContext(args)
622
+ const node = stepOf(scenario, args.step)
623
+ const locale = args.locale || run.meta.scope.locales[0]
624
+ const url = urlFor(env, node, locale)
625
+ if (!url) throw new UserError(`Step ${node.id} has no path.`)
626
+ const conn = await connectBrowser({ root, config, env })
627
+ if (!conn) throw new UserError('Start the browser first: kaloko browser start')
628
+ const page = await currentPage(conn.context, args.page)
629
+ await page.goto(url, { waitUntil: 'domcontentloaded' })
630
+ say(`Opened ${url}`)
631
+ await conn.browser.close()
632
+ },
633
+
634
+ async capture (args) {
635
+ const { config, root, run, scenario, env } = await runContext(args)
636
+ const node = stepOf(scenario, args.step)
637
+ const locale = args.locale || run.meta.scope.locales[0]
638
+ if (!run.meta.scope.locales.includes(locale)) throw new UserError(`Locale ${locale} is not in the run scope (${run.meta.scope.locales.join(', ')}).`)
639
+ if (typeof args.image === 'string') {
640
+ const r = await captureImage({ config, run, scenario, node, locale, args })
641
+ const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
642
+ say(`✓ ${node.id} ${locale} ${r.viewport} ${r.platform}${r.device ? ` · ${r.device}` : ''} ${r.size.width}×${r.size.height} ${r.files.tree ? `UI tree (${r.capture.tree})` : 'no UI tree'} ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}`)
643
+ return
644
+ }
645
+ if (isApp(scenario.platform)) {
646
+ // the app's current screen, through the environment's driver (no navigation)
647
+ const driver = driverFor(scenario.platform, env, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
648
+ if (driver.web) throw new UserError('For Electron, run kaloko walk --steps <id>, or pass --image.')
649
+ await driver.open({ locale, attach: true })
650
+ const enc = await launchEphemeral(config).catch(() => null)
651
+ try {
652
+ const r = await captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0], run, platform: scenario.platform, encoder: enc ? await enc.newPage() : null, maxChars: config.evaluators?.jev?.max_state_chars })
653
+ say(`✓ ${node.id} ${locale} ${r.viewport} ${r.device || scenario.platform} ${r.files.tree ? `UI tree (${r.capture.tree})` : 'no UI tree'}`)
654
+ } finally { await enc?.close().catch(() => {}) }
655
+ return
656
+ }
657
+ let conn = await connectBrowser({ root, config, env })
658
+ let ephemeral = null
659
+ let page
660
+ if (conn) page = await currentPage(conn.context, args.page)
661
+ else {
662
+ if (!args.url && !node.path) throw new UserError('No shared browser is running. Start it (kaloko browser start) or pass --url.')
663
+ ephemeral = await launchEphemeral(config)
664
+ page = await (await newContext(ephemeral, env, { root, config })).newPage()
665
+ observeConsole(page)
666
+ }
667
+ try {
668
+ const target = args.url === true || (!conn && !args.url) ? urlFor(env, node, locale) : args.url
669
+ if (target) await page.goto(target, { waitUntil: 'domcontentloaded' })
670
+ if (args.crop) {
671
+ const entry = await captureCrop({ page, node, locale, selector: args.crop, title: args.title, run })
672
+ say(`Attached ${entry.file} to ${node.id}`)
673
+ return
674
+ }
675
+ const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
676
+ const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null })
677
+ for (const r of results) {
678
+ const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
679
+ say(`✓ ${node.id} ${locale} ${r.viewport} ${r.size.width}×${r.size.height}px ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}${r.blocked_requests.length ? ` blocked: ${r.blocked_requests.length}` : ''}`)
680
+ }
681
+ } finally {
682
+ if (conn) await conn.browser.close()
683
+ if (ephemeral) await ephemeral.close()
684
+ }
685
+ },
686
+
687
+ async mail (args) {
688
+ const { config, root, run, scenario, env } = await runContext(args)
689
+ const node = stepOf(scenario, args.step)
690
+ if (node.kind !== 'email') throw new UserError(`Step ${node.id} is not an email step.`)
691
+ const locale = args.locale || run.meta.scope.locales[0]
692
+ // recipient: --to, the node's mail.to, the node's session mailbox, then the environment's address
693
+ const to = expandRun(args.to || node.mail?.to || (node.session && scenario.sessions?.[node.session]?.mailbox) || recipient(env.mail, run.meta.short), run.meta.short)
694
+ const subject = args.subject || localized(node.mail?.subject, locale)
695
+ let message = null
696
+ if (typeof args.file === 'string') {
697
+ message = await readMessageFile(resolve(args.file))
698
+ if (typeof args.subject === 'string') message.subject = args.subject
699
+ if (typeof args.to === 'string') message.to = [args.to]
700
+ }
701
+ let since
702
+ if (args['any-time']) since = null
703
+ else if (typeof args.since === 'string') { const t = Date.parse(args.since); if (Number.isNaN(t)) throw new UserError(`--since ${args.since} is not a date (ISO 8601, e.g. 2026-09-27T10:00).`); since = new Date(t).toISOString() }
704
+ const results = await captureMail({ config, root, run, env, node, locale, to, subject, timeoutMs: (Number(args.timeout) || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
705
+ say(`✓ ${node.id} ${locale}: "${results.subject}" → ${results.count} viewport(s)`)
706
+ },
707
+
708
+ async walk (args) {
709
+ const { config, root, run, scenario, env } = await runContext(args)
710
+ if (env.kind === 'design') {
711
+ await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeViewports(run, list(args.viewports)), say, noCache: !!args['no-cache'] })
712
+ return
713
+ }
714
+ if (isApp(scenario.platform)) return walkApp({ config, root, run, scenario, env, args })
715
+ const pick = list(args.steps)
716
+ const locales = list(args.locales) || run.meta.scope.locales
717
+ // --ephemeral: an own browser (own cookies and tabs) even when the shared one runs, so runs can walk in parallel
718
+ const conn = args.ephemeral ? null : await connectBrowser({ root, config, env })
719
+ const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
720
+ const ephemeral = conn ? null : await launchEphemeral(config)
721
+ const ctx = conn ? conn.context : await newContext(ephemeral, env, { root, config })
722
+ const scenarioDir = resolve(root, run.meta.scenario_file, '..')
723
+ // semantic questions of every new capture start right away; the walk ends with the verdicts (--no-evaluate: skip)
724
+ const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root })
725
+ const pending = []
726
+ const failed = []
727
+ const skipped = []
728
+ const walked = await readCaptures(run.dir)
729
+ const sessions = scenario.sessions || null
730
+ // named sessions: one isolated browser context per person and locale (cookies, storage, sign-in), created lazily
731
+ const sessionContexts = new Map()
732
+ // `account` stays the scenario's key; `credentials` resolves it in this environment (config accounts)
733
+ const sessionInfo = (key) => key && sessions?.[key] ? { key, title: localized(sessions[key].title, locales[0]), mailbox: sessions[key].mailbox ? expandRun(sessions[key].mailbox, run.meta.short) : null, account: sessions[key].account || null, credentials: accountFor(env, sessions[key].account, { root, config }) } : null
734
+ const contextFor = async (key, locale) => {
735
+ if (!key || !sessions?.[key]) return null
736
+ const id = `${key}|${locale}`
737
+ if (!sessionContexts.has(id)) {
738
+ // every context gets the environment's gate and readonly policy; an account's saved sign-in is loaded
739
+ const account = accountFor(env, sessions[key].account, { root, config })
740
+ const c = await newContext(conn ? conn.browser : ephemeral, env, { root, config, account })
741
+ const p = await c.newPage()
742
+ observeConsole(p)
743
+ sessionContexts.set(id, { context: c, page: p })
744
+ const saved = account && await access(account.storage_state).then(() => true, () => false)
745
+ say(`session ${key} (${locale}): new browser context${saved ? ' with the saved sign-in' : ''}`)
746
+ }
747
+ return sessionContexts.get(id)
748
+ }
749
+ // stop a session after the same failure repeats (shared precondition such as a failed sign-in);
750
+ // --keep-going walks every step regardless
751
+ const streak = failureStreak(args['keep-going'] ? 0 : 3)
752
+ try {
753
+ for (const locale of locales) {
754
+ // the walk owns its tab; agents and people keep theirs in the same shared browser
755
+ const page = await ctx.newPage()
756
+ if (!conn) observeConsole(page)
757
+ try {
758
+ // every locale starts as a new visitor on its first walk in the run
759
+ if (conn && !walked.some((c) => c.locale === locale)) { await resetSession(ctx, page, baseUrl(env, locale)); say(`session reset (new visitor, ${locale})`) }
760
+ for (const node of scenario.nodes) {
761
+ if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
762
+ if (node.kind !== 'stack' && node.path && !urlFor(env, node, locale)) continue // step does not exist in this locale
763
+ if (node.session && sessions?.[node.session]?.blocked_on?.includes(env.name)) { pending.push(`${node.id} (${locale}): session ${node.session} is blocked on ${env.name}`); continue }
764
+ const sess = await contextFor(node.session, locale)
765
+ const sPage = sess ? sess.page : page
766
+ const sCtx = sess ? sess.context : ctx
767
+ if (node.kind === 'stack') {
768
+ try {
769
+ const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, args })
770
+ for (const b of bad) failed.push(`${node.id} (${locale}) ${b}`)
771
+ } catch (error) {
772
+ failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
773
+ say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
774
+ }
775
+ continue
776
+ }
777
+ if (node.kind === 'email' && (node.mail?.to || sessionInfo(node.session)?.mailbox)) {
778
+ // an e-mail node with a recipient is walked like a step: wait for the message and capture it
779
+ try {
780
+ const to = expandRun(node.mail?.to || sessionInfo(node.session).mailbox, run.meta.short)
781
+ const results = await captureMail({ config, root, run, env, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
782
+ say(`✓ ${node.id} ${locale}: "${results.subject}"`)
783
+ } catch (error) {
784
+ failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
785
+ say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
786
+ }
787
+ continue
788
+ }
789
+ if (!['playwright', 'script'].includes(node.execution.strategy) || !node.execution.script) { pending.push(`${node.id} (${locale}): ${node.execution.strategy} — ${node.execution.instructions.split('\n')[0]}`); continue }
790
+ const streakKey = `${node.session || ''}|${locale}`
791
+ if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale})`); continue }
792
+ try {
793
+ const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
794
+ // the automatic capture runs unless this script invocation captured the step itself: a crop is an
795
+ // attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
796
+ let captured = false
797
+ const capture = (opts = {}) => { captured = true; return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, opts.viewports), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {} }).then((r) => { prefetch?.add(r, run.dir); return r }) }
798
+ await mod.default({ page: sPage, context: sCtx, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
799
+ if (!captured) await capture()
800
+ say(`✓ ${node.id} ${locale}`)
801
+ streak.ok(streakKey)
802
+ } catch (error) {
803
+ failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
804
+ say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
805
+ if (streak.record(streakKey, error.message)) say(` stopping ${node.session ? `session ${node.session}` : 'this walk'} (${locale}): the last 3 steps failed with the same error; fix the shared cause (sign-in, environment) and rerun, or pass --keep-going`)
806
+ }
807
+ }
808
+ } finally { await page.close().catch(() => {}) }
809
+ }
810
+ } finally {
811
+ for (const { context } of sessionContexts.values()) await context.close().catch(() => {})
812
+ if (conn) await conn.browser.close() // over CDP this only disconnects; the shared browser keeps running
813
+ if (ephemeral) await ephemeral.close()
814
+ await releaseLock?.()
815
+ }
816
+ if (pending.length) say(`\nSteps for an agent or a person (capture them with kaloko capture --step <id>):\n - ${[...new Set(pending)].join('\n - ')}`)
817
+ if (failed.length) { say(`\nSteps that failed (fix and rerun with --steps):\n - ${failed.join('\n - ')}`); process.exitCode = 1 }
818
+ if (skipped.length) { say(`\nSteps not walked after repeated failures (rerun with --steps once the cause is fixed):\n - ${skipped.join('\n - ')}`); process.exitCode = 1 }
819
+ if (prefetch) await walkVerdicts({ prefetch, run, scenario, config, root })
820
+ },
821
+
822
+ async auth (args) {
823
+ const { config, root } = await context(args)
824
+ const action = args._[0]
825
+ const { stat, mkdir, writeFile: write, chmod } = await import('node:fs/promises')
826
+ if (action === 'list') {
827
+ const rows = []
828
+ for (const name of Object.keys(config.environments)) {
829
+ let env
830
+ try { env = environment(config, name) } catch { continue }
831
+ for (const key of Object.keys(env.accounts || {})) {
832
+ const file = accountFor(env, key, { root, config }).storage_state
833
+ const st = await stat(file).catch(() => null)
834
+ rows.push(`${name.padEnd(12)} ${key.padEnd(16)} ${st ? `saved ${Math.round((Date.now() - st.mtimeMs) / 36e5)} h ago · ${relative(process.cwd(), file)}` : 'no saved session'}`)
835
+ }
836
+ }
837
+ say(rows.length ? rows.join('\n') : 'No accounts in kaloko.config.yml (environments.<env>.accounts).')
838
+ return
839
+ }
840
+ if (action !== 'save' || typeof args.env !== 'string' || typeof args.account !== 'string') throw new UserError('Usage: kaloko auth save --env <name> --account <key> [--url <url>] | kaloko auth list')
841
+ const env = environment(config, args.env)
842
+ if (env.readonly) throw new UserError(`Environment ${env.name} is readonly; saved sign-ins are for accounts on non-production environments.`)
843
+ const account = accountFor(env, args.account, { root, config })
844
+ if (!account.configured) throw new UserError(`Add the account first: environments.${env.name}.accounts.${args.account} in ${CONFIG_FILE} (it may be empty: {}).`)
845
+ if (!process.stdin.isTTY) throw new UserError('kaloko auth save needs a person at the terminal: they sign in in the browser window and press Enter.')
846
+ const { chromium } = await import('playwright')
847
+ const browser = await chromium.launch({ headless: false, channel: config.browser.channel || undefined })
848
+ try {
849
+ const page = await (await newContext(browser, env, { root, config })).newPage()
850
+ await page.goto(typeof args.url === 'string' ? args.url : baseUrl(env), { waitUntil: 'domcontentloaded' })
851
+ say(`Sign in as "${args.account}" on ${env.name} in the browser window (SSO, MFA as usual), then press Enter here.`)
852
+ await new Promise((done) => { process.stdin.resume(); process.stdin.once('data', () => { process.stdin.pause(); done() }) })
853
+ const state = await page.context().storageState()
854
+ await mkdir(join(account.storage_state, '..'), { recursive: true })
855
+ // cookies are credentials: owner-only file, and a .gitignore next to it
856
+ await write(account.storage_state, JSON.stringify(state, null, 2), { mode: 0o600 })
857
+ await chmod(account.storage_state, 0o600)
858
+ await write(join(account.storage_state, '..', '.gitignore'), '*\n').catch(() => {})
859
+ say(`Saved ${state.cookies.length} cookies and ${state.origins.length} origin storages → ${relative(process.cwd(), account.storage_state)}\nwalk loads it for sessions with account: ${args.account}. Sessions expire; run this again when sign-in is asked for.`)
860
+ } finally { await browser.close() }
861
+ },
862
+
863
+ async session (args) {
864
+ if (args._[0] !== 'reset') throw new UserError('Usage: kaloko session reset')
865
+ const { config, root, env } = await runContext(args)
866
+ const conn = await connectBrowser({ root, config, env })
867
+ if (!conn) throw new UserError('Start the browser first: kaloko browser start')
868
+ try { await resetSession(conn.context, await currentPage(conn.context), env.base_url) } finally { await conn.browser.close() }
869
+ say('Session reset: cookies and storage cleared (new visitor)')
870
+ },
871
+
872
+ async status (args) {
873
+ const { config, root, run, scenario, env } = await runContext(args)
874
+ const caps = await readCaptures(run.dir)
875
+ say(`Run ${run.meta.id} · ${run.meta.environment.name} · ${run.meta.scope.locales.join(', ')}`)
876
+ if (env.kind === 'design') {
877
+ // per step: unchanged / changed here / changed in the draft, from the folder and the last sync (#42)
878
+ const dir = designDir(env, root)
879
+ const local = await localRevisions({ dir, scenario, locales: run.meta.scope.locales, allLocales: run.meta.scope.locales, project: run.meta.project?.slug })
880
+ const branch = typeof args.branch === 'string' ? args.branch : 'main'
881
+ const base = (await readBase(dir))?.[`${scenario.id}@${branch}`]
882
+ let remote = null
883
+ try { remote = Object.fromEntries(((await service(config).request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(scenario.id)}/draft?branch=${encodeURIComponent(branch)}`)).heads || []).map((h) => [headKey(h.node, h.locale, h.state), h])) } catch {}
884
+ say(`Design folder ${relative(process.cwd(), dir)} · draft ${remote ? `synced #${base?.seq ?? '—'}` : 'not reachable (offline or no token)'}`)
885
+ for (const node of scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external')) {
886
+ const cells = run.meta.scope.locales.map((l) => {
887
+ const k = headKey(node.id, l), mine = local[k]?.id, b = base?.heads?.[k], r = remote?.[k]?.revision
888
+ if (!mine) return `${l}:none`
889
+ if (r && mine === r) return `${l}:synced`
890
+ if (mine !== b && r && r !== b) return `${l}:CONFLICT`
891
+ if (mine !== b) return `${l}:changed here`
892
+ if (r && r !== b) return `${l}:changed in draft`
893
+ return `${l}:${remote ? 'new' : 'local'}`
894
+ })
895
+ const st = remote && Object.values(remote).find((h) => h.node === node.id)?.status
896
+ say(` ${node.id.padEnd(28)} ${cells.join(' ')}${st ? ` [${st.replace(/_/g, ' ')}]` : ''}`)
897
+ }
898
+ return
899
+ }
900
+ for (const node of scenario.nodes.filter((n) => n.kind !== 'decision')) {
901
+ const got = node.kind === 'stack'
902
+ ? run.meta.scope.locales.map((l) => `${l}:${new Set(caps.filter((c) => c.type === 'capture' && c.node === node.id && c.locale === l && c.item).map((c) => c.item)).size}`)
903
+ : run.meta.scope.locales.map((l) => caps.some((c) => c.type === 'capture' && c.node === node.id && c.locale === l) ? l : '·'.repeat(l.length))
904
+ say(` ${node.id.padEnd(28)} ${node.kind.padEnd(8)} ${(node.execution?.strategy || (node.kind === 'stack' ? 'pages' : '')).padEnd(10)} ${got.join(' ')}`)
905
+ }
906
+ },
907
+
908
+ async evaluate (args) {
909
+ const { config, root, run, scenario, env } = await runContext(args)
910
+ if (args.recheck) {
911
+ const r = await recheckCaptures({ run, scenario, config, root, env, only: list(args.steps) })
912
+ say(`Rechecked deterministic criteria of ${r.rechecked} capture(s) from the stored HTML (console, overflow and performance keep their live result).`)
913
+ if (r.skipped.length) say(` skipped: ${r.skipped.join('; ')}`)
914
+ }
915
+ const report = await evaluateRun({ run, scenario, config, root })
916
+ const via = report.via.qawalk ? ` via Kaloko${report.hosted?.limit != null ? ` (${report.hosted.questions} of ${report.hosted.limit} questions this month)` : ''}` : report.via['own-key'] ? ' with your key' : ''
917
+ say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
918
+ if (report.skipped.length) {
919
+ // one line per reason: "12 captures: TYPESAFE_API_KEY is not set …"
920
+ const byReason = new Map()
921
+ for (const x of report.skipped) { const [where, ...why] = x.split(': '); const k = why.join(': '); byReason.set(k, [...(byReason.get(k) || []), where]) }
922
+ 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 - ')}`)
923
+ }
924
+ if (report.outdated.length) say(`Criteria changed after capture of ${report.outdated.join(', ')}: not evaluated. Run "kaloko evaluate --recheck --steps ${report.outdated.join(',')}" (deterministic, from the stored HTML) or walk those steps again.`)
925
+ },
926
+
927
+ async build (args) {
928
+ const { config, root, run, scenario } = await runContext(args)
929
+ const manifest = await buildManifest({ run, scenario, config, root, catalog: await catalogFor(config, root) })
930
+ const file = await writePreview({ run, manifest })
931
+ const s = manifest.summary
932
+ say(`${manifest.id}: ${s.pass} pass · ${s.partial} partial · ${s.fail} fail · ${s.skip} not checked\nPreview: ${file}`)
933
+ return file
934
+ },
935
+
936
+ async preview (args) {
937
+ const file = await commands.build(args)
938
+ say(pathToFileURL(file).href)
939
+ },
940
+
941
+ async share (args) {
942
+ const { config, root, run, scenario, env } = await runContext(args)
943
+ const client = service(config)
944
+ // on a design environment, share publishes a version of the flow from the shared draft (#42)
945
+ if (env.kind === 'design') {
946
+ const r = await syncDesign({ config, root, run, scenario, env, client, args: { ...args, strict: true }, say, pull: false, render: (steps) => walkDesign({ config, root, run, scenario, env, pick: steps, locales: run.meta.scope.locales, viewports: run.meta.scope.viewports, say }).then(() => evaluateRun({ run, scenario, config, root })) })
947
+ if (r.conflicts) return
948
+ const v = await publishVersion({ config, scenario, client, args, say })
949
+ const meta = await readJson(join(run.dir, 'meta.json'))
950
+ meta.shared = { url: v.url, at: new Date().toISOString(), version: v.version, run: v.id }
951
+ await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
952
+ return
953
+ }
954
+ const manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
955
+ await writePreview({ run, manifest })
956
+ const files = new Set()
957
+ for (const n of manifest.nodes) {
958
+ for (const byVp of Object.values(n.captures || {})) for (const c of Object.values(byVp)) [c.image || c.png, c.thumb, c.html, c.eval, c.tree].filter(Boolean).forEach((p) => files.add(p))
959
+ for (const a of n.attachments || []) files.add(a.file)
960
+ // stack steps: the items index and every page's files
961
+ if (n.stack?.items) {
962
+ files.add(n.stack.items)
963
+ const idx = await readJson(join(run.dir, n.stack.items), { items: [] })
964
+ for (const it of idx.items) for (const byVp of Object.values(it.captures || {})) for (const c of Object.values(byVp)) [c.png, c.thumb, c.html, c.eval].filter(Boolean).forEach((p) => files.add(p))
965
+ }
966
+ }
967
+ const days = Number(args.expires) || config.share?.expires_days || 30
968
+ const { stat } = await import('node:fs/promises')
969
+ let total = 0
970
+ for (const p of files) total += (await stat(join(run.dir, p))).size
971
+ try {
972
+ const u = await client.request('GET', `/orgs/${config.org}/usage`)
973
+ if (total > u.limits.max_run_bytes) throw new UserError(`This run is ${fmtBytes(total)}; the limit per run is ${fmtBytes(u.limits.max_run_bytes)}. Capture fewer viewports or locales, or ask an admin for a higher limit.`)
974
+ if (u.usage.bytes + total > u.limits.quota_bytes) throw new UserError(`Storage quota would be exceeded: ${fmtBytes(u.usage.bytes)} used + ${fmtBytes(total)} > ${fmtBytes(u.limits.quota_bytes)}. Delete old runs in Settings or ask for a higher quota.`)
975
+ say(`Uploading ${files.size} files, ${fmtBytes(total)} (organization uses ${fmtBytes(u.usage.bytes)} of ${fmtBytes(u.limits.quota_bytes)})`)
976
+ } catch (e) { if (e instanceof UserError && /limit|quota/.test(e.message)) throw e }
977
+ const created = await client.request('POST', `/orgs/${config.org}/runs?expires_days=${days}`, manifest)
978
+ // text files travel and rest gzip-compressed; the service serves them with Content-Encoding: gzip
979
+ 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 } }
980
+ // the file list goes first: the service checks limits once and each upload is then a plain write
981
+ // (an older service without the plan answers 404/405 and every upload checks itself, as before)
982
+ if (files.size > 20) {
983
+ const plan = []
984
+ for (const p of files) plan.push({ path: p, bytes: (await body(p)).data.length })
985
+ await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/files`, { files: plan }).catch((e) => { if (!/HTTP (404|405)/.test(e.message)) throw e })
986
+ }
987
+ const queue = [...files]
988
+ let done = 0
989
+ const worker = async () => {
990
+ while (queue.length) {
991
+ const p = queue.shift()
992
+ const { data, gz } = await body(p)
993
+ await client.upload(`/orgs/${config.org}/runs/${manifest.id}/files/${p.split('/').map(encodeURIComponent).join('/')}`, data, p, gz ? { 'Content-Encoding': 'gzip' } : {})
994
+ done++
995
+ if (done % 10 === 0 || done === files.size) process.stdout.write(`\r uploaded ${done}/${files.size}`)
996
+ }
997
+ }
998
+ await Promise.all(Array.from({ length: files.size > 200 ? 12 : 6 }, worker))
999
+ if (files.size) process.stdout.write('\n')
1000
+ // experimental HTML views (#42): their bundles travel as blobs and attach to the run as revisions
1001
+ const viewIds = new Set(manifest.nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((vps) => Object.values(vps).map((c) => c.view?.revision).filter(Boolean))))
1002
+ if (viewIds.size) {
1003
+ const revs = []
1004
+ for (const id of viewIds) { const r = await readJson(join(run.dir, 'views', `${id}.json`), null); if (r) revs.push(r) }
1005
+ const blobs = new Map()
1006
+ for (const r of revs) for (const [p, h] of Object.entries(r.files)) blobs.set(h, { abs: join(run.dir, 'live', r.id, p), ext: p.split('.').pop() })
1007
+ try {
1008
+ await uploadBlobs({ client, org: config.org, files: blobs, say })
1009
+ await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/views`, { revisions: revs.map(({ sizes, ...r }) => r) })
1010
+ say(`Attached ${revs.length} HTML view(s).`)
1011
+ } catch (e) { say(`HTML views not attached: ${e.message.split('\n')[0]}`) }
1012
+ }
1013
+ const fin = await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/finalize`)
1014
+ const meta = await readJson(join(run.dir, 'meta.json'))
1015
+ meta.shared = { url: fin.url, at: new Date().toISOString(), expires_days: created.expires_days }
1016
+ await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
1017
+ say(`Shared ${manifest.id} (${files.size} files, expires in ${created.expires_days} days)\n${fin.url}`)
1018
+ const capped = expiryNote(days, created.expires_days)
1019
+ if (capped) say(`Note: ${capped}`)
1020
+ if (args.pr) {
1021
+ const r = await upsertPrComment({ manifest, url: fin.url, cwd: root })
1022
+ say(r.ok ? `PR comment ${r.action}: ${r.url}` : `PR comment skipped: ${r.reason}.`)
1023
+ }
1024
+ },
1025
+
1026
+ async sync (args) {
1027
+ const { config, root, run, scenario, env } = await runContext(args)
1028
+ const client = service(config)
1029
+ await syncDesign({ config, root, run, scenario, env, client, args, say, push: !args.pull, pull: !args.push, render: (steps) => walkDesign({ config, root, run, scenario, env, pick: steps, locales: run.meta.scope.locales, viewports: run.meta.scope.viewports, say }).then(() => evaluateRun({ run, scenario, config, root })) })
1030
+ },
1031
+
1032
+ async history (args) {
1033
+ const { config, root } = await context(args)
1034
+ const scenarioId = await scenarioIdOf(args, config, root)
1035
+ await historyCmd({ config, scenarioId, client: service(config), args, say })
1036
+ },
1037
+
1038
+ async reference (args) {
1039
+ const { config, root } = await context(args)
1040
+ const scenarioId = await scenarioIdOf(args, config, root)
1041
+ await referenceCmd({ config, scenarioId, client: service(config), args, say })
1042
+ },
1043
+
1044
+ async branch (args) {
1045
+ const { config, root } = await context(args)
1046
+ const scenarioId = await scenarioIdOf(args, config, root)
1047
+ await branchCmd({ config, scenarioId, client: service(config), args, say })
1048
+ },
1049
+
1050
+ async tokens (args) {
1051
+ const { config, root } = await context(args)
1052
+ let env = typeof args.env === 'string' ? environment(config, args.env) : null
1053
+ if (!env) { try { const r = await resolveRun({ root, output: config.output, run: args.run }); env = environment(config, r.meta.environment.name) } catch {} }
1054
+ if (!env) env = Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design') || null
1055
+ const needsService = args._[0] !== 'check'
1056
+ await tokensCmd({ config, root, env, client: needsService ? service(config) : null, args, say, project: typeof args.project === 'string' ? args.project : config.project })
1057
+ },
1058
+
1059
+ async feedback (args) {
1060
+ const { config, root } = await context(args)
1061
+ const client = service(config)
1062
+ // on a design environment the feedback is the draft's comments, pinned ones with their element (#42)
1063
+ let designRun = null
1064
+ try { const r = await resolveRun({ root, output: config.output, run: args.run }); if (r.meta.environment.kind === 'design') designRun = r } catch {}
1065
+ if (designRun && !args.scenario) {
1066
+ const branch = typeof args.branch === 'string' ? args.branch : 'main'
1067
+ const items = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(designRun.meta.scenario.id)}/draft/reviews?branch=${encodeURIComponent(branch)}`)
1068
+ if (args.json) { say(JSON.stringify(items, null, 2)); return }
1069
+ const open = items.filter((r) => !r.resolved_at && r.status !== 'approved')
1070
+ say(`Draft feedback for ${designRun.meta.scenario.id}${branch !== 'main' ? `@${branch}` : ''}: ${open.length} open`)
1071
+ for (const r of open) say(` ${r.status === 'rejected' ? '✕' : '•'} ${r.node_id}${r.locale ? ` [${r.locale}]` : ''}${r.anchor?.selector ? ` on ${r.anchor.selector}` : ''} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
1072
+ return
1073
+ }
1074
+ let runId = args.run
1075
+ let scenarioId = args.scenario
1076
+ if (!runId && !scenarioId) {
1077
+ try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id; else scenarioId = r.meta.scenario.id } catch {}
1078
+ }
1079
+ const q = new URLSearchParams({ run: runId || 'latest', ...(scenarioId ? { scenario: scenarioId } : {}) })
1080
+ const data = await client.request('GET', `/orgs/${config.org}/feedback?${q}`)
1081
+ if (args.json) { say(JSON.stringify(data, null, 2)); return }
1082
+ if (!data.run) { say('No published run found.'); return }
1083
+ say(`Feedback for ${data.run.id} (${data.scenario}) — run verdict: ${data.verdict?.verdict || 'pending'}${data.verdict?.by ? ` by ${data.verdict.by}` : ''}${data.verdict?.note ? `: ${data.verdict.note}` : ''}${data.verdict?.awaiting?.length ? `\n awaiting approval from: ${data.verdict.awaiting.join(', ')}` : ''}${data.approved.length ? `\n approved: ${data.approved.join(', ')}` : ''}`)
1084
+ for (const c of data.criterion_verdicts || []) say(` ✕ ${c.node_id} ${c.criterion_id}${c.locale ? ` [${c.locale}]` : ''} — ${c.email}: ${c.note}`)
1085
+ const thread = (r, where) => {
1086
+ say(` ${r.status === 'rejected' ? '✕' : '•'} [${r.id}] ${where}${r.locale ? ` [${r.locale}]` : ''}${r.pin ? ` · pinned at ${Math.round(r.pin.x * 100)}%,${Math.round(r.pin.y * 100)}%${r.pin.viewport ? ` (${r.pin.viewport})` : ''}` : ''}${r.anchor?.selector ? ` · on ${r.anchor.selector}` : ''} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
1087
+ for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
1088
+ }
1089
+ const flow = data.flow_comments || []
1090
+ if (!data.items.length && !flow.length) { say(' No open rejections or comments.'); return }
1091
+ for (const r of data.items) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
1092
+ for (const r of flow) thread(r, 'whole flow')
1093
+ 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)' : ''}`)
1094
+ },
1095
+
1096
+ /**
1097
+ * Comments from the CLI: on a step (--step), the whole run (default) or the whole flow (--flow); reply, resolve
1098
+ * (with a closing note), reopen, edit and delete by id. Targets the shared run (--run, default the current one).
1099
+ */
1100
+ async comment (args) {
1101
+ const { config, root } = await context(args)
1102
+ const client = service(config)
1103
+ let runId = typeof args.run === 'string' ? args.run : null
1104
+ let scenarioId = typeof args.scenario === 'string' ? args.scenario : null
1105
+ if (!runId || !scenarioId) { try { const r = await resolveRun({ root, output: config.output, run: runId || undefined }); if (!runId && r.meta.shared) runId = r.meta.id; scenarioId ||= r.meta.scenario.id } catch {} }
1106
+ const flow = !!args.flow
1107
+ if (flow && !scenarioId) throw new UserError('--flow needs a scenario: pass --scenario <id> or run it in a project with a current run.')
1108
+ if (!flow && !runId) throw new UserError('No shared run: pass --run <id>, share the current run first, or use --flow for the whole flow.')
1109
+ const base = flow ? `/orgs/${config.org}/scenarios/${encodeURIComponent(scenarioId)}/comments` : `/orgs/${config.org}/runs/${runId}/comments`
1110
+ const note = typeof args.note === 'string' ? args.note : ''
1111
+ const where = flow ? `flow ${scenarioId}` : `run ${runId}`
1112
+ if (typeof args.resolve === 'string') { await client.request('POST', `${base}/${encodeURIComponent(args.resolve)}/resolve`, { note }); say(`✓ resolved ${args.resolve} (${where})${note ? ` with: ${note}` : ''}`); return }
1113
+ if (typeof args.reopen === 'string') { await client.request('POST', `${base}/${encodeURIComponent(args.reopen)}/reopen`, {}); say(`✓ reopened ${args.reopen}`); return }
1114
+ if (typeof args.delete === 'string') { const r = await client.request('DELETE', `${base}/${encodeURIComponent(args.delete)}`); say(`✓ deleted ${args.delete}${r.kept ? ' (kept as "deleted" because others replied)' : ''}`); return }
1115
+ if (typeof args.edit === 'string') { await client.request('PATCH', `${base}/${encodeURIComponent(args.edit)}`, { note }); say(`✓ edited ${args.edit}`); return }
1116
+ if (!note.trim()) throw new UserError('Write the comment with --note "…".')
1117
+ const status = ['rejected', 'approved'].includes(args.status) ? args.status : 'comment'
1118
+ const body = { note, status, node_id: typeof args.step === 'string' ? args.step : '', ...(typeof args.reply === 'string' ? { parent_id: args.reply } : {}), ...(typeof args.locale === 'string' ? { locale: args.locale } : {}) }
1119
+ const c = await client.request('POST', base, body)
1120
+ say(`✓ ${args.reply ? `replied to ${args.reply}` : `commented on ${body.node_id ? `step ${body.node_id} of ` : 'the whole '}${where}`} [${c.id}]`)
1121
+ },
1122
+
1123
+ async runs (args) {
1124
+ const { config } = await context(args)
1125
+ const client = service(config)
1126
+ const all = !!args.all || typeof args.scenario === 'string'
1127
+ const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(typeof args.environment === 'string' ? { environment: args.environment } : {}), limit: '300' })
1128
+ const runs = await client.request('GET', `/orgs/${config.org}/runs?${q}`)
1129
+ if (args.json) { say(JSON.stringify(runs, null, 2)); return }
1130
+ if (!runs.length) { say('No shared runs match.'); return }
1131
+ for (const r of runs) {
1132
+ const s = r.summary || {}
1133
+ say(`${r.id} ${r.environment?.name || ''} ${r.verdict || 'pending'}${r.kept ? ' · kept' : ''} ${s.pass || 0}✓ ${s.partial || 0}◐ ${s.fail || 0}✕ ${r.author || ''}${r.older ? ` + ${r.older} older` : ''}`)
1134
+ }
1135
+ if (!all) say(`\nNewest run per scenario; --all or --scenario <id> lists every run.`)
1136
+ },
1137
+
1138
+ async trash (args) {
1139
+ const { config } = await context(args)
1140
+ if (!args._.length) throw new UserError('Usage: kaloko trash <run-id>...')
1141
+ const client = service(config)
1142
+ for (const id of args._) { await client.request('DELETE', `/orgs/${config.org}/runs/${encodeURIComponent(id)}`); say(`✓ ${id} moved to the trash (kaloko restore ${id} brings it back)`) }
1143
+ },
1144
+
1145
+ async restore (args) {
1146
+ const { config } = await context(args)
1147
+ if (args._.length !== 1) throw new UserError('Usage: kaloko restore <run-id>')
1148
+ await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/restore`, {})
1149
+ say(`✓ ${args._[0]} restored`)
1150
+ },
1151
+
1152
+ async prune (args) {
1153
+ const { config } = await context(args)
1154
+ const days = typeof args['older-than'] === 'string' ? Number(args['older-than'].replace(/d$/, '')) : null
1155
+ if (days != null && !Number.isInteger(days)) throw new UserError('--older-than takes days, e.g. 14d.')
1156
+ const body = { dry_run: !args.yes, keep_latest: args.keep === undefined ? 3 : Number(args.keep), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(typeof args.environment === 'string' ? { environment: args.environment } : {}), ...(typeof args.project === 'string' ? { project: args.project } : {}), ...(days != null ? { older_than_days: days } : {}) }
1157
+ const r = await service(config).request('POST', `/orgs/${config.org}/runs/prune`, body)
1158
+ if (args.json) { say(JSON.stringify(r, null, 2)); return }
1159
+ if (!r.trash.length) { say(`Nothing to clean up (keeping the newest ${r.keep_latest} per scenario and environment).`); return }
1160
+ say(`${r.dry_run ? 'Would move' : 'Moved'} ${r.trash.length} run(s) to the trash:`)
1161
+ for (const x of r.trash) say(` ${x.id} ${x.environment} ${x.uploaded_by}`)
1162
+ const others = r.kept.filter((x) => x.reason === 'uploaded by someone else').length
1163
+ if (others) say(`${others} older run(s) of other people stay (only an admin prunes them).`)
1164
+ if (r.dry_run) say('Dry run: nothing changed. Add --yes to move them to the trash (restorable until purge).')
1165
+ },
1166
+
1167
+ async keep (args) {
1168
+ const { config } = await context(args)
1169
+ if (args._.length !== 1) throw new UserError('Usage: kaloko keep <run-id> [--off]')
1170
+ const r = await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/keep`, { keep: !args.off })
1171
+ say(r.kept ? `✓ ${args._[0]} is kept: it never expires and is never pruned` : `✓ ${args._[0]} is no longer kept`)
1172
+ },
1173
+
1174
+ async archive (args) {
1175
+ const { config } = await context(args)
1176
+ if (args._.length !== 1) throw new UserError('Usage: kaloko archive <scenario-id> [--undo]')
1177
+ await service(config).request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(args._[0])}/${args.undo ? 'unarchive' : 'archive'}`, {})
1178
+ 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)`)
1179
+ },
1180
+
1181
+ async inbox (args) {
1182
+ const { config } = await context(args)
1183
+ const client = service(config)
1184
+ const q = new URLSearchParams(args.all ? {} : { unread: '1' })
1185
+ const data = await client.request('GET', `/orgs/${config.org}/notifications?${q}`)
1186
+ if (args.json) say(JSON.stringify(data, null, 2))
1187
+ else {
1188
+ say(`Inbox for ${config.org}: ${data.unread} unread${args.all ? `, showing ${data.items.length}` : ''}`)
1189
+ const line = (n) => { const d = n.detail || {}; const who = n.actor ? `${n.actor}: ` : ''; const where = n.run_id ? ` (${n.run_id}${n.node_id ? `#${n.node_id}` : ''})` : ''; return `${n.read ? ' ' : '•'} ${n.created_at.slice(0, 16).replace('T', ' ')} ${n.type} ${who}${[d.scenario, d.node, d.note, d.email, d.domain, d.expires_at && `expires ${d.expires_at.slice(0, 10)}`].filter(Boolean).join(' · ')}${where}` }
1190
+ for (const n of data.items) say(line(n))
1191
+ }
1192
+ if (args.read && data.items.some((n) => !n.read)) {
1193
+ const r = await client.request('POST', `/orgs/${config.org}/notifications`, { read: data.items.filter((n) => !n.read).map((n) => n.id) })
1194
+ say(`Marked ${r.read} read.`)
1195
+ }
1196
+ },
1197
+
1198
+ async projects (args) {
1199
+ const { config } = await context(args)
1200
+ const data = await service(config).request('GET', `/orgs/${config.org}/projects`)
1201
+ if (args.json) { say(JSON.stringify(data, null, 2)); return }
1202
+ if (!data.length) { say('No projects yet. Create one in Settings → Projects or with POST /api/v1/orgs/<org>/projects.'); return }
1203
+ for (const p of data) say(`${p.slug.padEnd(24)} ${p.name}${p.adhoc ? ' (from runs only)' : ''} ${p.scenarios} scenario(s), ${p.runs} run(s)${config.project === p.slug ? ' ← this config' : ''}`)
1204
+ },
1205
+
1206
+ async compare (args) {
1207
+ const { config, root } = await context(args)
1208
+ const { readJson } = await import('./lib/util.mjs')
1209
+ let client = null
1210
+ const remote = async (id) => { client ||= service(config); return client.request('GET', `/orgs/${config.org}/runs/${id}`) }
1211
+ // a run is read locally when its directory has a manifest, otherwise from the service
1212
+ const load = async (id) => {
1213
+ if (!id) return null
1214
+ const local = await readJson(id.includes('/') ? resolve(id, 'run.json') : join(root, config.output, id, 'run.json'), null)
1215
+ return local || remote(id)
1216
+ }
1217
+ let runId = args.run
1218
+ if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
1219
+ if (!runId) throw new UserError('--run <id> is required when there is no current run.')
1220
+ const run = await load(runId)
1221
+ if (!run) throw new UserError(`Run ${runId} not found locally or on the service.`)
1222
+ let baseId = typeof args.baseline === 'string' ? args.baseline : null
1223
+ // --design: against the design reference of the scenario (#42)
1224
+ if (args.design && run.scenario?.id) { client ||= service(config); baseId = (await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(run.scenario.id)}/references`)).design; if (!baseId) throw new UserError('This scenario has no design reference yet (kaloko reference --design --version <n>).') }
1225
+ if (!baseId) {
1226
+ // the service knows the scenario baseline; locally fall back to the previous run of the scenario
1227
+ if (!run.baseline_id && run.scenario?.id) { try { run.baseline_id = (await remote(runId)).baseline_id } catch {} }
1228
+ baseId = run.baseline_id && run.baseline_id !== run.id ? run.baseline_id : (run.history || []).filter((h) => h.started_at < run.started_at).sort((a, b) => b.started_at.localeCompare(a.started_at))[0]?.id
1229
+ if (!baseId) { const others = (await listRuns(root, config.output)).filter((r) => r.scenario?.id === run.scenario?.id && r.id !== run.id && r.started_at < run.started_at).sort((a, b) => b.started_at.localeCompare(a.started_at)); baseId = others[0]?.id }
1230
+ }
1231
+ if (!baseId) throw new UserError('No baseline or earlier run of this scenario to compare with; pass --baseline <id>.')
1232
+ const base = await load(baseId)
1233
+ if (!base) throw new UserError(`Run ${baseId} not found locally or on the service.`)
1234
+ const d = compareRuns(run, base)
1235
+ if (args.json) { say(JSON.stringify(d, null, 2)); return }
1236
+ say(formatComparison(d))
1237
+ if (config.share?.url && run.org) say(`\nCanvas: ${config.share.url.replace(/\/$/, '')}/${run.org}/runs/${run.id}/#cmp=${base.id}`)
1238
+ },
1239
+
1240
+ async draft (args) {
1241
+ const { root } = await context(args).catch(() => ({ root: process.cwd() }))
1242
+ const urls = args._.filter((u) => /^https?:\/\//.test(u))
1243
+ if (!urls.length) throw new UserError('Usage: kaloko draft <url> [url…] (public pages of one site)')
1244
+ const pages = []
1245
+ for (const url of urls) {
1246
+ let facts = {}
1247
+ try { const res = await fetch(url, { headers: { 'User-Agent': 'Kaloko-draft/1 (+https://kaloko.app)' }, redirect: 'follow', signal: AbortSignal.timeout(10000) }); facts = pageFacts(await res.text()); say(` ${res.status} ${url}${facts.title ? ` — ${facts.title}` : ''}`) } catch (e) { say(` ✕ ${url}: ${e.message} (drafted from the URL only)`) }
1248
+ pages.push({ url, facts })
1249
+ }
1250
+ let draft
1251
+ try { draft = draftScenario(pages, { id: typeof args.id === 'string' ? args.id : undefined, title: typeof args.title === 'string' ? args.title : undefined }) } catch (e) { throw new UserError(e.message) }
1252
+ const file = resolve(root, typeof args.out === 'string' ? args.out : join('qa', 'flows', `${new URL(draft.origin).hostname.replace(/^www\./, '').replace(/[^a-z0-9]+/gi, '-')}.yml`))
1253
+ const planFile = file.replace(/\.ya?ml$/, '') + '.ACCEPTANCE.md'
1254
+ draft.scenario.source = relative(root, planFile)
1255
+
1256
+ for (const f of [file, planFile]) if (existsSync(f) && !args.force) throw new UserError(`${relative(process.cwd(), f)} exists; pass --force to overwrite.`)
1257
+ const problems = validateScenario(JSON.parse(JSON.stringify(draft.scenario)))
1258
+ if (problems.length) throw new UserError(`Draft did not validate:\n - ${problems.join('\n - ')}`)
1259
+ await mkdir(join(dirname(file), 'steps'), { recursive: true })
1260
+ await writeFile(file, scenarioYaml(draft.scenario))
1261
+ await writeFile(planFile, draft.plan)
1262
+ const consent = join(dirname(file), 'steps', 'consent.mjs'); const open = join(dirname(file), 'steps', 'open.mjs')
1263
+ if (!existsSync(consent)) await writeFile(consent, await readFile(join(packageRoot, 'templates', 'steps', 'consent.mjs'), 'utf8'))
1264
+ if (!existsSync(open)) await writeFile(open, "// Opens the step's page (kaloko draft).\nexport default async ({ page, url }) => { await page.goto(url(), { waitUntil: 'domcontentloaded' }) }\n")
1265
+ say(`Drafted ${draft.scenario.nodes.length} step(s) of ${draft.origin}\n scenario: ${relative(process.cwd(), file)}\n plan: ${relative(process.cwd(), planFile)}\nNext: sharpen the purposes and criteria, make sure environments.production.base_url is ${draft.origin} (readonly: true), then kaloko start --scenario ${relative(process.cwd(), file)} --env production`)
1266
+ },
1267
+
1268
+ async calibrate (args) {
1269
+ const { config } = await context(args)
1270
+ const thresholds = { ...JEV_DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) }
1271
+ const cases = []
1272
+ if (typeof args.golden === 'string') cases.push(...await casesFromGolden(resolve(args.golden)))
1273
+ if (typeof args.scenario === 'string') {
1274
+ const client = service(config)
1275
+ const runs = await client.request('GET', `/orgs/${config.org}/runs?scenario=${encodeURIComponent(args.scenario)}&limit=${Number(args.limit) || 30}`)
1276
+ for (const r of runs) {
1277
+ const m = await client.request('GET', `/orgs/${config.org}/runs/${r.id}`)
1278
+ cases.push(...casesFromRun(m, m.criterion_verdicts))
1279
+ }
1280
+ }
1281
+ if (!cases.length) {
1282
+ throw new UserError(typeof args.scenario === 'string'
1283
+ ? `No labelled cases in the last ${Number(args.limit) || 30} runs of ${args.scenario}: reviewers have not set pass/fail on semantic criteria yet (lightbox → criterion → Pass/Fail), or pass --golden labels.yml.`
1284
+ : 'No labelled cases. Pass --golden labels.yml or --scenario <id> (reviewers set pass/fail on semantic criteria in the canvas).')
1285
+ }
1286
+ const report = calibrate(cases, thresholds, { target: Number(args.target) || 0.95 })
1287
+ say(args.json ? JSON.stringify(report, null, 2) : formatCalibration(report))
1288
+ },
1289
+
1290
+ async export (args) {
1291
+ const { config, root } = await context(args)
1292
+ const client = service(config)
1293
+ if (args.live) {
1294
+ const scenarioId = await scenarioIdOf(args, config, root)
1295
+ let out = typeof args.out === 'string' ? resolve(args.out) : null
1296
+ if (!out) { const d = Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design'); out = d ? designDir(d, root) : resolve(root, 'design') }
1297
+ await exportLive({ config, scenarioId, client, args, say, out })
1298
+ return
1299
+ }
1300
+ if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
1301
+ let runId = args.run
1302
+ if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
1303
+ if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
1304
+ const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, `${runId}.zip`)
1305
+ const { createWriteStream } = await import('node:fs')
1306
+ const { Readable } = await import('node:stream')
1307
+ const { pipeline } = await import('node:stream/promises')
1308
+ const res = await client.fetch(`/orgs/${config.org}/runs/${runId}/export.zip`)
1309
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(out))
1310
+ const { size } = await (await import('node:fs/promises')).stat(out)
1311
+ say(`Exported ${runId} → ${out} (${fmtBytes(size)})`)
1312
+ },
1313
+
1314
+ async public (args) {
1315
+ const { config, root } = await context(args)
1316
+ const client = service(config)
1317
+ let runId = args.run
1318
+ if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
1319
+ if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
1320
+ const r = await client.request('POST', `/orgs/${config.org}/runs/${runId}/public`, { public: !args.off })
1321
+ say(r.public ? `Public: ${r.url} (readable without sign-in; only one public run per scenario)` : `Private again: ${runId}`)
1322
+ },
1323
+
1324
+ async doctor (args) {
1325
+ const { config, root } = await context(args)
1326
+ const r = await doctor({ config, root, fix: !!args.fix })
1327
+ say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
1328
+ if (!r.ok) process.exitCode = 1
1329
+ },
1330
+
1331
+ async help () { say(HELP) }
1332
+ }
1333
+
1334
+ export async function main (argv) {
1335
+ const args = parse(argv)
1336
+ const name = args._.shift() || 'help'
1337
+ const cmd = commands[name] || (args.help ? commands.help : null)
1338
+ if (!cmd) { console.error(`Unknown command "${name}".\n`); say(HELP); process.exitCode = 2; return }
1339
+ try {
1340
+ await cmd(args)
1341
+ } catch (error) {
1342
+ if (error instanceof UserError) { console.error(error.message); process.exitCode = 1 } else throw error
1343
+ }
1344
+ }