kaloko 0.21.1 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.21.1",
3
+ "version": "0.21.3",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
package/src/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { startRefs, normalizePurpose, RUN_PURPOSES } from './lib/run-refs.mjs'
12
12
  import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
13
13
  import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, launchWithRetry, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
14
14
  import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
15
- import { observeConsole } from './lib/console.mjs'
15
+ import { observeConsole, pageTarget } from './lib/console.mjs'
16
16
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
17
17
  import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateForRequests } from './lib/access.mjs'
18
18
  import { configureGate, disposeShared } from './lib/linkcheck.mjs'
@@ -810,7 +810,7 @@ async function exportScenario ({ args, config, root, client }) {
810
810
  * Walks a stack step: resolves its pages, then opens and captures each one with the step's criteria and index
811
811
  * fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
812
812
  */
813
- async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '', cache = null, gate = null }) {
813
+ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, consoleBound = null, args, keys = null, tag = '', cache = null, gate = null }) {
814
814
  // a rotating sample (items.rotate) remembers between runs how many runs walked it and when each page was checked
815
815
  const statePath = (l) => join(run.dir, '..', '.cache', 'stack', scenario.id, `${node.id}.${l}.json`)
816
816
  const state = node.items?.rotate ? await rotationState(statePath(locale), run.meta.id) : null
@@ -854,13 +854,17 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
854
854
  const streak = failureStreak(5)
855
855
  const failed = []; let ok = 0; const checkedNow = []
856
856
  const started = Date.now(); let told = started
857
+ let from = consoleBound?.since(page) || new Date().toISOString()
857
858
  try {
858
859
  for (const [i, item] of chosen.entries()) {
859
860
  if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
860
861
  status = null
861
- const since = Date.now()
862
+ // the console counts from the previous page's last capture on this tab (an ISO time: a number never matched an
863
+ // entry's time, so stack pages never reported a console error)
864
+ const since = from
862
865
  let captured = false
863
- const capture = (opts = {}) => { captured = true; return captureStep({ page, node, locale, viewports: opts.viewports ? scopeKeys(run.meta.scope, passKeys, opts.viewports) : viewports, run, consoleFile, since, item, fields, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { http_status: status } }) }
866
+ let lastCapture = null
867
+ const capture = (opts = {}) => { captured = true; lastCapture = new Date().toISOString(); return captureStep({ page, node, locale, viewports: opts.viewports ? scopeKeys(run.meta.scope, passKeys, opts.viewports) : viewports, run, consoleFile, since, item, fields, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { http_status: status } }) }
864
868
  try {
865
869
  if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
866
870
  else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
@@ -870,10 +874,11 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
870
874
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
871
875
  if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
872
876
  }
877
+ from = lastCapture || new Date().toISOString()
873
878
  // long stacks: progress every 25 pages (or 30 s) with the time left, estimated from the pages so far
874
879
  if (i + 1 < chosen.length && ((i + 1) % 25 === 0 || Date.now() - told > 30000)) { told = Date.now(); const left = timeLeft(i + 1, chosen.length, told - started); say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}${left ? ` · ${left}` : ''}`) }
875
880
  }
876
- } finally { page.off('response', onResponse) }
881
+ } finally { page.off('response', onResponse); consoleBound?.done(page, from) }
877
882
  // the rotation of this locale goes into the run (the stack index shows it) and the pages checked into the state
878
883
  if (state && rotation) {
879
884
  const checked = await state.save(checkedNow, all)
@@ -1531,7 +1536,11 @@ const commands = {
1531
1536
  say(`Attached ${entry.file} to ${node.id}`)
1532
1537
  return
1533
1538
  }
1534
- const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
1539
+ // the console counts from the run's start, or from the run's last capture on this tab (#197): what the shared
1540
+ // browser logged before belongs to another run or to the step captured then. A capture that names no tab (an
1541
+ // own browser, an older version) bounds every tab; one on another tab does not (its errors are not ours)
1542
+ const tab = conn ? await pageTarget(page) : null
1543
+ const since = (await readCaptures(run.dir)).filter((c) => c.type !== 'attachment' && c.at && !c.carried_from && (!c.tab || !tab || c.tab === tab)).map((c) => c.at).reduce((a, b) => (b > a ? b : a), run.meta.started_at || '') || undefined
1535
1544
  const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: inState(run, node, manualKeys(run, list(args.viewports)), args.state), 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, stability: Number(args.stability) || 0 })
1536
1545
  for (const r of results) {
1537
1546
  const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
@@ -1689,6 +1698,10 @@ const commands = {
1689
1698
  const page = await lctx.newPage()
1690
1699
  const shared = conn && !parallel && plain // the shared browser's own context logs its console to a file
1691
1700
  if (!shared) observeConsole(page)
1701
+ // where each page's console counts from (#197): from the walk's start on it, then from the previous step's
1702
+ // last capture on it, so what a page logs between two steps counts in the next one and nothing older does
1703
+ const consoleFrom = new WeakMap([[page, new Date().toISOString()]])
1704
+ const consoleBound = { since: (p) => consoleFrom.get(p) || run.meta.started_at, done: (p, at) => consoleFrom.set(p, at || new Date().toISOString()) }
1692
1705
  // one recorder per page (the walk's tab and each named session's), started on its first step;
1693
1706
  // recordings and traces need CDP: Chromium passes only
1694
1707
  const recorders = new Map()
@@ -1699,7 +1712,7 @@ const commands = {
1699
1712
  }
1700
1713
  try {
1701
1714
  // every locale starts as a new visitor on its first walk in the run (an own context is new already)
1702
- if (shared && !walked.some((c) => c.locale === locale)) { await resetSession(lctx, page, baseUrl(env, locale)); say(`session reset (new visitor, ${locale}${tag})`) }
1715
+ if (shared && !walked.some((c) => c.locale === locale)) { await resetSession(lctx, page, baseUrl(env, locale)); consoleBound.done(page); say(`session reset (new visitor, ${locale}${tag})`) }
1703
1716
  for (const node of scenario.nodes) {
1704
1717
  if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
1705
1718
  if (node.kind !== 'stack' && node.path && !urlFor(env, node, locale)) continue // step does not exist in this locale
@@ -1710,7 +1723,7 @@ const commands = {
1710
1723
  const consoleFile = shared && !sess ? consoleLog(root, config.output, env.name) : null
1711
1724
  if (node.kind === 'stack') {
1712
1725
  try {
1713
- const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag, cache: stackCache, gate: stackGate })
1726
+ const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, consoleBound, args, keys: pass.keys, tag, cache: stackCache, gate: stackGate })
1714
1727
  for (const b of bad) failed.push(`${node.id} (${locale}${tag}) ${b}`)
1715
1728
  } catch (error) {
1716
1729
  failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
@@ -1745,11 +1758,15 @@ const commands = {
1745
1758
  const rec = state === 'default' ? rec0 : null
1746
1759
  rec?.beforeStep(node)
1747
1760
  const records = []
1748
- const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, stateKeys(run.meta.scope, Object.keys(scopeKeys(run.meta.scope, pass.keys, opts.viewports || null)), state)), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
1761
+ // the console counts from the previous step's last capture on this page (#197), for every capture the
1762
+ // step takes: a later capture of the same view replaces the earlier one and must still name what it logged
1763
+ const since = consoleBound.since(sPage)
1764
+ let lastCapture = null
1765
+ const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); lastCapture = new Date().toISOString(); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, stateKeys(run.meta.scope, Object.keys(scopeKeys(run.meta.scope, pass.keys, opts.viewports || null)), state)), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, since, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
1749
1766
  try {
1750
1767
  await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, 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, options: captureOptions(scenario, node) }), acceptDialogs: () => acceptDialogs(sPage) })
1751
1768
  if (!captured) await capture()
1752
- } catch (error) { await rec?.afterStep(node, { error }); throw error }
1769
+ } catch (error) { await rec?.afterStep(node, { error }); throw error } finally { consoleBound.done(sPage, lastCapture) }
1753
1770
  await rec?.afterStep(node, { records })
1754
1771
  }
1755
1772
  try {
@@ -91,7 +91,7 @@ export async function prepareContext (context, env, { log } = {}) {
91
91
  const request = route.request()
92
92
  const reason = policyViolation(env, { method: request.method(), url: request.url() })
93
93
  if (reason) {
94
- log?.({ type: 'blocked', url: request.url(), text: reason })
94
+ log?.({ type: 'blocked', url: request.url(), text: reason }, request)
95
95
  return route.abort('blockedbyclient')
96
96
  }
97
97
  if (toGate(request.url())) {
@@ -3,9 +3,10 @@
3
3
  // policy and gate on every request and records console errors for captures.
4
4
  // Secrets are resolved here from the inherited environment, never passed in argv.
5
5
  import { chromium } from 'playwright'
6
- import { writeJson, appendJsonl } from './util.mjs'
6
+ import { writeJson } from './util.mjs'
7
7
  import { prepareContext, clientCertificates } from './access.mjs'
8
8
  import { launchPersistentWithRetry } from './browser.mjs'
9
+ import { consoleWriter, pageTarget } from './console.mjs'
9
10
 
10
11
  const opts = JSON.parse(process.argv[2])
11
12
  const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
@@ -18,10 +19,29 @@ const context = await launchPersistentWithRetry(chromium, opts.profile, {
18
19
  args: [`--remote-debugging-port=${opts.port}`]
19
20
  })
20
21
 
21
- const log = (entry) => appendJsonl(opts.consoleFile, { ts: new Date().toISOString(), ...entry }).catch(() => {})
22
+ // the console log starts empty with the browser (once it is up): what an earlier one logged belongs to no page of it
23
+ const consoleLog = await consoleWriter(opts.consoleFile)
24
+
25
+ // every entry names its tab (#197): a capture counts only the errors of the page it captures, not of other tabs
26
+ const tabs = new WeakMap()
27
+ const tabOf = (page) => {
28
+ if (!page) return Promise.resolve(null)
29
+ // a lookup that failed or timed out is tried again with the next entry
30
+ if (!tabs.has(page)) tabs.set(page, pageTarget(page).then((id) => { if (!id) tabs.delete(page); return id }))
31
+ return tabs.get(page)
32
+ }
33
+ const pageOf = (request) => { try { return request.frame().page() } catch { return null } }
34
+ const log = (entry, request = null) => {
35
+ const ts = new Date().toISOString()
36
+ return consoleLog.append(tabOf(entry.page || pageOf(request)).then((tab) => {
37
+ const { page, ...rest } = entry
38
+ return { ts, ...rest, ...(tab ? { tab } : {}) }
39
+ }))
40
+ }
22
41
  const watch = (page) => {
23
- page.on('console', (m) => { if (m.type() === 'error') log({ type: 'console', url: page.url(), text: m.text(), src: m.location()?.url || null }) })
24
- page.on('pageerror', (e) => log({ type: 'pageerror', url: page.url(), text: e.message }))
42
+ tabOf(page)
43
+ page.on('console', (m) => { if (m.type() === 'error') log({ page, type: 'console', url: page.url(), text: m.text(), src: m.location()?.url || null }) })
44
+ page.on('pageerror', (e) => log({ page, type: 'pageerror', url: page.url(), text: e.message }))
25
45
  }
26
46
  context.pages().forEach(watch)
27
47
  context.on('page', watch)
@@ -3,7 +3,7 @@ import { dirname, relative, join } from 'node:path'
3
3
  import { buildHtmlView } from './htmlview.mjs'
4
4
  import { probe } from './probe.mjs'
5
5
  import { pageSpecScript } from './devspec.mjs'
6
- import { writeJson, readJsonl } from './util.mjs'
6
+ import { writeJson } from './util.mjs'
7
7
  import { addCapture, runPaths } from './run.mjs'
8
8
  import { maskSpec, maskList, inPage, maskInPage, rememberPii, knownPii, maskMailHeaders, maskHtml, scrubDeep, scrubText, updateMasks, loadKnownPii, saveKnownPii, RECORD_PLAIN_KEYS } from './pii.mjs'
9
9
 
@@ -98,7 +98,7 @@ export function captureOptions (scenario, node) {
98
98
  }
99
99
 
100
100
  import { criteriaHash } from './evaluate.mjs'
101
- import { consoleEntriesFor } from './console.mjs'
101
+ import { consoleEntriesFor, readConsoleLog, pageTarget, entriesSince } from './console.mjs'
102
102
  import { redact, redactDeep } from './secrets.mjs'
103
103
  import { readFields } from './stack.mjs'
104
104
  import { checkLinks, checkHreflang, checkLlmsTxt, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, anonymousRequest, cacheScope } from './linkcheck.mjs'
@@ -203,6 +203,10 @@ export async function captureStep ({ page, node, locale, viewports, run, console
203
203
  // batch E: the engine of this page, and per key its colour scheme, motion and display mode (run scope variants)
204
204
  const engine = engineOf(page)
205
205
  const scope = run.meta?.scope
206
+ // console entries (#197): only those since `since` (an ISO time; a number is a time in ms) and, in the shared
207
+ // browser, only those of this page's tab
208
+ if (typeof since === 'number') since = new Date(since).toISOString()
209
+ const tab = consoleFile ? await pageTarget(page) : null
206
210
  for (const [viewport, [width, height]] of Object.entries(viewports)) {
207
211
  const now = page.viewportSize()
208
212
  if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
@@ -230,8 +234,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
230
234
  Object.assign(result.checks, localeChecks(node.criteria, { locale, meta: result.meta, url: page.url(), state: result.state }))
231
235
  // the console is read before the mask: the page's own reaction to masked text never counts as its error
232
236
  const url = page.url()
233
- const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
234
- const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
237
+ const collected = consoleFile ? await readConsoleLog(consoleFile) : consoleEntriesFor(page)
238
+ const consoleEntries = entriesSince(collected, { since, tab })
235
239
  // requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
236
240
  // an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
237
241
  const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
@@ -386,7 +390,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
386
390
  if (mask.active) await saveKnownPii(run.dir)
387
391
  const file = paths.step(stepId, locale, viewport, 'capture.json')
388
392
  await writeJson(file, stored)
389
- await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at })
393
+ await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at, ...(tab ? { tab } : {}) })
390
394
  results.push(stored)
391
395
  } finally {
392
396
  // the page gets its own text back, also when the capture failed half-way
@@ -1,3 +1,6 @@
1
+ import { appendFile, mkdir, readFile, rename, rm } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
1
4
  // In-memory collection for browsers that are not managed by the daemon.
2
5
  const logs = new WeakMap()
3
6
 
@@ -11,3 +14,77 @@ export function observeConsole (page) {
11
14
  }
12
15
 
13
16
  export const consoleEntriesFor = (page) => logs.get(page) ?? null
17
+
18
+ // The shared browser's console log (#197): one file per environment, written by the browser daemon only. It starts
19
+ // empty with every browser start and keeps at most two parts of CONSOLE_LIMIT bytes (the current one and the one
20
+ // before), so a browser that runs for days does not grow it without end and a capture reads a few MB at most.
21
+ export const CONSOLE_LIMIT = 2 * 1024 * 1024
22
+ const previousPart = (file) => file.replace(/(\.jsonl)?$/, '.old.jsonl')
23
+
24
+ /** Appends entries in call order; starts the log afresh and moves it aside as the previous part past `limit` bytes. */
25
+ export async function consoleWriter (file, { limit = CONSOLE_LIMIT } = {}) {
26
+ await mkdir(dirname(file), { recursive: true })
27
+ await rm(previousPart(file), { force: true })
28
+ await rm(file, { force: true })
29
+ let size = 0
30
+ let queue = Promise.resolve()
31
+ const write = async (entry) => {
32
+ const line = JSON.stringify(entry) + '\n'
33
+ const bytes = Buffer.byteLength(line)
34
+ if (size > 0 && size + bytes > limit) { await rename(file, previousPart(file)).catch(() => {}); size = 0 }
35
+ await appendFile(file, line)
36
+ size += bytes
37
+ }
38
+ // an entry may be a promise (its tab is looked up first): the file keeps the order of the calls
39
+ const append = (entry) => { queue = queue.then(async () => write(await entry)).catch(() => {}); return queue }
40
+ return { append, flushed: () => queue }
41
+ }
42
+
43
+ // one part of the log, line by line: a line cut short (the daemon was writing it) is skipped, never the whole part
44
+ async function readPart (file) {
45
+ const text = await readFile(file, 'utf8').catch(() => '')
46
+ const lines = []
47
+ for (const line of text.split('\n')) {
48
+ if (!line) continue
49
+ try { lines.push({ line, entry: JSON.parse(line) }) } catch {}
50
+ }
51
+ return lines
52
+ }
53
+
54
+ /** Every entry the shared browser's console log still holds, the previous part first. */
55
+ export async function readConsoleLog (file) {
56
+ // the current part first: when the daemon moves it aside between the two reads, its entries are in the previous
57
+ // part then and are read twice (and kept once), never lost
58
+ const current = await readPart(file)
59
+ const previous = await readPart(previousPart(file))
60
+ const moved = new Set(previous.map(({ line }) => line))
61
+ return [...previous, ...current.filter(({ line }) => !moved.has(line))].map(({ entry }) => entry)
62
+ }
63
+
64
+ /**
65
+ * The browser's own id of a page's tab (a CDP target id). The daemon and every process connected over CDP see the
66
+ * same id, so a capture keeps only the entries of the tab it captures. Null outside Chromium.
67
+ */
68
+ export async function pageTarget (page, { timeout = 3000 } = {}) {
69
+ let session, timer
70
+ const lookup = (async () => {
71
+ session = await page.context().newCDPSession(page)
72
+ const { targetInfo } = await session.send('Target.getTargetInfo')
73
+ return targetInfo?.targetId || null
74
+ })()
75
+ try {
76
+ // a page that hangs (a modal dialog, a busy renderer) leaves its entries untagged rather than holding the log
77
+ return await Promise.race([lookup, new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeout) })])
78
+ } catch { return null } finally {
79
+ clearTimeout(timer)
80
+ lookup.then(() => session?.detach(), () => {}).catch(() => {})
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The entries a capture may count: written at or after `since` (an ISO time) and, when the tab is known, on that
86
+ * tab. Entries without a tab (a browser started by an older version) are kept: the time bound still holds for them.
87
+ */
88
+ export function entriesSince (entries, { since = null, tab = null } = {}) {
89
+ return (entries || []).filter((e) => (!since || e.ts >= since) && (!tab || !e.tab || e.tab === tab))
90
+ }
@@ -175,6 +175,9 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
175
175
  // sharing runs needs the Developer / Tester role (or admin) and, on tokens with scope sets, the upload scope
176
176
  const canShare = ['tester', 'admin'].includes(me.role) && (!Array.isArray(me.scopes) || me.scopes.includes('upload'))
177
177
  add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${canShare ? '' : ' (this token cannot share runs)'}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
178
+ // doctor --json carries the scopes, so a script can tell before a long walk whether the token may also make
179
+ // a run public (full), not only share it (upload): scripts/demo-run.mjs (#62)
180
+ if (Array.isArray(me.scopes)) checks[checks.length - 1].scopes = me.scopes
178
181
  if (!canShare) add('token role', false, Array.isArray(me.scopes) ? `${me.role} · ${me.scopes.join(', ')}` : me.role, 'kaloko share needs a Developer / Tester token with the upload scope.')
179
182
  }
180
183
  } catch (e) { add('token', false, `${service} unreachable: ${e.cause?.code || e.message}`, 'Check your network or VPN; KALOKO_URL points the CLI to another service.') }
package/viewer/index.html CHANGED
@@ -573,7 +573,7 @@ html.is-panning,html.is-panning *{user-select:none;-webkit-user-select:none}
573
573
  /* how a criterion was checked: kind, meaning, AI score between the thresholds */
574
574
  .how{grid-column:2;display:grid;gap:4px;margin-top:4px;font-size:12.5px}.how__kind{justify-self:start;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:1px 7px}.how--ai{color:var(--ink);border-color:var(--ink)}.how__q{color:var(--ink)}.how__line{color:var(--muted)}.how__why{color:var(--ink)}.how__why b{font-weight:600;margin-right:4px}
575
575
  .gauge{position:relative;display:block;height:8px;border-radius:4px;background:var(--skip-bg);overflow:visible;margin:2px 0}.gauge__lo{position:absolute;left:0;top:0;bottom:0;background:var(--fail-bg);border-radius:4px 0 0 4px}.gauge__hi{position:absolute;right:0;top:0;bottom:0;background:var(--pass-bg);border-radius:0 4px 4px 0}.gauge b{position:absolute;top:-3px;width:3px;height:14px;margin-left:-1.5px;background:var(--ink);border-radius:2px}
576
- .ac li .tech{grid-column:2;font-size:11.5px}.ac li .tech summary{cursor:pointer;color:var(--muted)}
576
+ .ac li .tech{font-size:11.5px}.ac li .tech summary{cursor:pointer;color:var(--muted)}
577
577
  .ac{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
578
578
  .ac li{display:grid;grid-template-columns:22px 1fr;gap:2px 8px;padding:8px;border-radius:6px;border:1px solid var(--line)}
579
579
  .ac li:hover{border-color:var(--ink)}