kaloko 0.16.0 → 0.17.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.
@@ -50,13 +50,21 @@ export const HIDDEN = {
50
50
  */
51
51
  export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync'])
52
52
 
53
+ /**
54
+ * Options a command takes more than once (kaloko start --ref A --ref B): their value is the list of every value given.
55
+ * Elsewhere the same name keeps the last value (kaloko spec --ref design).
56
+ */
57
+ export const REPEATABLE = { start: ['ref'] }
58
+
53
59
  /**
54
60
  * argv → { _: positional, <option>: value | true }. `--key=value` splits at the first `=` only; `--key value` takes
55
- * the next word unless it is another option; `-h` and `-v` are --help and --version.
61
+ * the next word unless it is another option; `-h` and `-v` are --help and --version. An option REPEATABLE for the
62
+ * command (the first positional) collects its values in a list.
56
63
  */
57
64
  export function parseArgs (argv) {
58
65
  const args = { _: [] }
59
66
  const short = { '-h': 'help', '-v': 'version' }
67
+ const seen = {}
60
68
  for (let i = 0; i < argv.length; i++) {
61
69
  const a = argv[i]
62
70
  if (a.startsWith('--')) {
@@ -66,9 +74,11 @@ export function parseArgs (argv) {
66
74
  if (eq >= 0) args[k] = a.slice(eq + 1)
67
75
  else if (next && !next.startsWith('--') && !short[next]) args[k] = argv[++i]
68
76
  else args[k] = true
77
+ ;(seen[k] ||= []).push(args[k])
69
78
  } else if (short[a]) args[short[a]] = true
70
79
  else args._.push(a)
71
80
  }
81
+ for (const k of REPEATABLE[args._[0]] || []) if (seen[k]) args[k] = seen[k]
72
82
  return args
73
83
  }
74
84
 
@@ -136,13 +146,76 @@ export function closest (word, words) {
136
146
 
137
147
  /**
138
148
  * Options `args` has that `name` does not take: a UsageError for commands in STRICT (nothing has happened yet), a
139
- * list of warning lines for the others (they run as before, without the unknown option).
149
+ * list of warning lines for the others (they run as before, without the unknown option). `update` is the line that
150
+ * says a newer kaloko takes the options in `newer`: those get no "Did you mean", and the line comes last (as the next
151
+ * step of the error, or as the last warning).
140
152
  */
141
- export function checkOptions (help, name, args) {
153
+ export function checkOptions (help, name, args, { update = null, newer = [] } = {}) {
142
154
  const known = optionsFor(help, name)
143
- const unknown = Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
155
+ const unknown = unknownOptions(help, name, args)
144
156
  if (!unknown.length) return []
145
- const lines = unknown.map((k) => { const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}` })
146
- if (STRICT.has(name) || STRICT.has(`${name} ${args._?.[0]}`)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
147
- return lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`)
157
+ const lines = unknown.map((k) => {
158
+ if (update && newer.includes(k)) return `kaloko ${name} does not take --${k} in this kaloko.`
159
+ const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}`
160
+ })
161
+ if (STRICT.has(name) || STRICT.has(`${name} ${args._?.[0]}`)) {
162
+ if (update) throw Object.assign(new UsageError(`${lines.join('\n')}\nNothing was changed.`, { next: update }), { update: true })
163
+ throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
164
+ }
165
+ return [...lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`), ...(update ? [update] : [])]
166
+ }
167
+
168
+ /** The usage part of an entry's first line: what follows " <name> " up to the description column. */
169
+ const usageOf = (entry, name) => entry.lines[0].slice(name.length + 3).split(/\s{3,}/)[0]
170
+
171
+ /** The help entries that start with `name` (not the ones that only mention it after " | "). */
172
+ const ownEntries = (help, name) => helpEntries(help).filter((e) => e.lines[0].startsWith(` ${name} `) || e.lines[0] === ` ${name}`)
173
+
174
+ /**
175
+ * The sub-commands of a command with the options each one's help names: "tokens pull|push|check|validate",
176
+ * "export run …", and after the first one the words after " | " ("video list | trace --step <id> | tools").
177
+ */
178
+ export function subcommandsFor (help, name) {
179
+ const subs = new Map()
180
+ for (const e of ownEntries(help, name)) {
181
+ const [first, ...rest] = usageOf(e, name).split(' | ')
182
+ const words = first.match(/^([a-z][a-z-]*(?:\|[a-z][a-z-]*)*)(?=\s|$)/)?.[1].split('|') || []
183
+ if (words.length) for (const seg of rest) { const w = seg.match(/^([a-z][a-z-]*)(?=\s|$)/)?.[1]; if (w) words.push(w) }
184
+ const flags = e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || []
185
+ for (const w of words) { if (!subs.has(w)) subs.set(w, new Set()); for (const f of flags) subs.get(w).add(f.slice(2)) }
186
+ }
187
+ return subs
188
+ }
189
+
190
+ /**
191
+ * Whether a command takes a word of its own after its name other than a sub-command: "validate <scenario.yml>",
192
+ * "spec [step]", "release [<version>]". "projects [--json]" and "doctor [--json] [--fix]" take none.
193
+ */
194
+ export function takesWords (help, name) {
195
+ return ownEntries(help, name).some((e) => { const u = usageOf(e, name); return !/^[a-z]/.test(u) && /(^|[\s[(])<|\[(?!-)/.test(u) })
196
+ }
197
+
198
+ /**
199
+ * The commands of this kaloko as data (kaloko help --json, and the service's /cli/latest.json): each documented
200
+ * command with its documented and common options and its sub-commands with theirs. Hidden aliases stay out.
201
+ */
202
+ export function catalogue (help, names, version) {
203
+ const commands = {}
204
+ for (const name of [...names].sort()) {
205
+ const entries = helpEntries(help).filter((e) => e.commands.has(name))
206
+ if (!entries.length && name !== 'help' && name !== 'version') continue
207
+ const named = entries.flatMap((e) => e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || []).map((f) => f.slice(2))
208
+ const subs = [...subcommandsFor(help, name)].sort(([a], [b]) => a.localeCompare(b))
209
+ commands[name] = {
210
+ flags: [...new Set([...named, ...COMMON_OPTIONS])].sort(),
211
+ ...(subs.length ? { subcommands: Object.fromEntries(subs.map(([s, f]) => [s, { flags: [...f].sort() }])) } : {})
212
+ }
213
+ }
214
+ return { version, commands }
215
+ }
216
+
217
+ /** The options in `args` that `name` does not take. */
218
+ export function unknownOptions (help, name, args) {
219
+ const known = optionsFor(help, name)
220
+ return Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
148
221
  }
@@ -419,12 +419,14 @@ function blocks (lines, o) {
419
419
 
420
420
  /** A list item: its first paragraph inline (tight lists), the rest as blocks (nested lists, pictures, code). */
421
421
  function listItem (lines, o) {
422
+ // lists nest at most 16 deep, like quotes; deeper items are text (1 000 levels of "- - -" would overflow the stack)
423
+ if ((o.listDepth || 0) >= 16) return inlineHtml(lines.join(' ').trim(), o)
422
424
  const first = []
423
425
  let i = 0
424
426
  while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^\s{0,3}(```|~~~)/.test(lines[i])) first.push(lines[i++])
425
427
  const { imgs, words } = picturesOf(first.join('\n'))
426
428
  const rest = lines.slice(i)
427
- return `${words ? inlineHtml(words.replace(/\n/g, ' '), o) : ''}${imgs.length ? pics(imgs, o) : ''}${rest.some((l) => l.trim()) ? blocks(rest, { ...o, notice: null }) : ''}`
429
+ return `${words ? inlineHtml(words.replace(/\n/g, ' '), o) : ''}${imgs.length ? pics(imgs, o) : ''}${rest.some((l) => l.trim()) ? blocks(rest, { ...o, notice: null, listDepth: (o.listDepth || 0) + 1 }) : ''}`
428
430
  }
429
431
 
430
432
  function pics (imgs, o) {
@@ -15,6 +15,8 @@ import { visionSettings, VISION_WEIGHT } from './vision-core.mjs'
15
15
  import { serviceUrl } from './service.mjs'
16
16
  import { videoTools, recordingChecks } from './video.mjs'
17
17
  import { productChecks } from './changelog.mjs'
18
+ import { versionCheck } from './update-check.mjs'
19
+ import { packageRoot } from './schema.mjs'
18
20
 
19
21
  const portFree = (port) => new Promise((done) => {
20
22
  const s = createServer().once('error', () => done(false)).once('listening', () => s.close(() => done(true)))
@@ -43,8 +45,10 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
43
45
  * @param {boolean} [o.fix] repair what can be repaired here: install missing Playwright browsers, create the run and
44
46
  * scenario folders, write org: from the token into the config, ask macOS for permissions
45
47
  * @param {Function} [o.installBrowser] async (engine) => void — installs a Playwright engine (--fix)
48
+ * @param {Function} [o.newest] async () => { detail } — this kaloko and the newest one (default: the service's
49
+ * /cli/latest.json, cached for a day; never a failure)
46
50
  */
47
- export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [], installBrowser = defaultInstall, legacy = [] }) {
51
+ export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [], installBrowser = defaultInstall, legacy = [], newest = null }) {
48
52
  const checks = []
49
53
  const add = (name, ok, detail = '', hint = '', fixed = null) => checks.push({ name, ok, detail, hint, ...(fixed ? { fixed } : {}) })
50
54
  // --fix: install an engine, then look again; returns the version or throws what the install said
@@ -52,6 +56,12 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
52
56
 
53
57
  const major = Number(process.versions.node.split('.')[0])
54
58
  add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
59
+ // this kaloko, and the newest released one when it is newer: information, never a failure
60
+ try {
61
+ const installed = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
62
+ const v = newest ? await newest(installed) : await versionCheck({ installed, config, env: { ...process.env, ...env }, fetchImpl, root: packageRoot })
63
+ add('kaloko', true, v.detail)
64
+ } catch {}
55
65
 
56
66
  add('config', true, `${file || `${root}/kaloko.config.yml`} · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
57
67
  // names from before the rename that still work (legacy.mjs): listed with their new names, never a failure
@@ -6,12 +6,14 @@
6
6
  // evidenceOf(record, extras) the evidence document of one run, derived from its signed acceptance record
7
7
  // releaseEvidenceOf(...) the evidence document of a release: its runs and one trace matrix over all of them
8
8
  // traceRows(evidence) the trace matrix: requirement → criterion → step/locale/viewport → evidence → result
9
- // → human decision; TRACE_COLUMNS is the column order of the CSV
9
+ // → human decision, with the step's session (role) and lane; TRACE_COLUMNS is the
10
+ // column order of the CSV
10
11
  // signatureSubject(...) what an electronic signature is bound to; verifySignatures(record) recomputes it
11
12
  // packageManifest / verifyPackageFiles the MANIFEST.json of an evidence package and its check
12
13
  //
13
14
  // The JSON is the contract; CSV is a convenience for people (cells that start like a formula get a leading ').
14
15
  import { canonicalJson, sha256Hex, recordSha256, verifyRecord } from './signoff-core.mjs'
16
+ import { stepPlaces, flowStructure } from './lanes-core.mjs'
15
17
 
16
18
  export const EVIDENCE_SCHEMA = 'qawalk.evidence.v1'
17
19
  export const MANIFEST_FILE = 'MANIFEST.json'
@@ -26,7 +28,9 @@ export const TRACE_COLUMNS = [
26
28
  'criterion_id', 'criterion_text', 'criterion_type', 'locale', 'viewport', 'evidence_file', 'evidence_sha256',
27
29
  'result', 'assessed_by', 'assessor', 'advisory',
28
30
  'step_decision', 'step_decided_by', 'step_decided_at', 'step_decided_via', 'step_signature_meaning', 'step_signer_name', 'step_signed_at', 'step_signer_time_zone', 'step_comment',
29
- 'run_verdict', 'run_decided_by', 'run_decided_at', 'run_decided_via', 'run_signature_meaning', 'run_signer_name', 'run_signed_at', 'run_signer_time_zone'
31
+ 'run_verdict', 'run_decided_by', 'run_decided_at', 'run_decided_via', 'run_signature_meaning', 'run_signer_name', 'run_signed_at', 'run_signer_time_zone',
32
+ // added later, so they come last: a reader that takes columns by position keeps working
33
+ 'session', 'session_title', 'lane', 'lane_label'
30
34
  ]
31
35
  export const AUDIT_COLUMNS = ['at', 'actor', 'action', 'target', 'via', 'agent', 'country', 'source', 'detail']
32
36
 
@@ -87,11 +91,15 @@ const sigCols = (s, prefix) => ({ [`${prefix}_signature_meaning`]: s?.meaning ??
87
91
  /**
88
92
  * The evidence document of a run (qawalk.evidence.v1, kind run) from its acceptance record. extras: { record_sha256,
89
93
  * signed (the record carries the service's signature), audit (rows of the run's audit trail), generated_at,
90
- * public_keys (URL of the well-known keys) }.
94
+ * public_keys (URL of the well-known keys), manifest (the run's run.json: the record names its SHA-256; it gives each
95
+ * step its session and lane, and the scenario its lanes and sessions) }.
91
96
  */
92
- export function evidenceOf (record, { record_sha256: recordSha = null, signed = !!record?.signature, audit = [], generated_at: at = new Date().toISOString(), public_keys: keysUrl = null } = {}) {
97
+ export function evidenceOf (record, { record_sha256: recordSha = null, signed = !!record?.signature, audit = [], generated_at: at = new Date().toISOString(), public_keys: keysUrl = null, manifest = null } = {}) {
93
98
  const r = record || {}
94
99
  const run = r.run || {}
100
+ const places = manifest ? stepPlaces(manifest) : null
101
+ const flow = manifest ? flowStructure(manifest) : null
102
+ const placed = (s) => ({ session: places?.get(s.id)?.session ?? s.session ?? null, lane: places?.get(s.id)?.lane ?? s.lane ?? null })
95
103
  const ev = {
96
104
  schema: EVIDENCE_SCHEMA,
97
105
  kind: 'run',
@@ -102,8 +110,8 @@ export function evidenceOf (record, { record_sha256: recordSha = null, signed =
102
110
  acceptance: r.acceptance ?? (r.verdict?.verdict === 'accepted' ? 'accepted' : r.verdict?.verdict === 'returned' ? 'returned' : 'none'),
103
111
  signing: r.signing ?? ((r.signatures || []).some((x) => !x.withdrawn) ? 'in_kaloko' : 'none'),
104
112
  record: { schema: r.schema ?? null, kind: r.kind ?? (r.verdict?.verdict === 'accepted' ? 'acceptance' : 'execution'), sha256: recordSha, signed: !!signed, kid: r.signature?.kid ?? null, issued_at: r.issued_at ?? null, exported_by: r.exported_by ?? null },
105
- run: { id: run.id ?? null, url: run.url ?? null, project: run.project ?? null, started_at: run.started_at ?? null, finished_at: run.finished_at ?? null, version: run.version ?? null, app_version: run.app_version ?? null },
106
- scenario: { id: run.scenario?.id ?? null, title: run.scenario?.title ?? null, version: run.scenario?.version ?? null, file: run.scenario?.file ?? null, sha256: run.scenario?.sha256 ?? null, refs: cleanRefs(run.scenario?.refs) },
113
+ run: { id: run.id ?? null, url: run.url ?? null, project: run.project ?? null, started_at: run.started_at ?? null, finished_at: run.finished_at ?? null, version: run.version ?? null, app_version: run.app_version ?? null, refs: Array.isArray(run.refs) ? run.refs.filter((x) => typeof x === 'string') : [], purpose: run.purpose ?? null },
114
+ scenario: { id: run.scenario?.id ?? null, title: run.scenario?.title ?? null, version: run.scenario?.version ?? null, file: run.scenario?.file ?? null, sha256: run.scenario?.sha256 ?? null, refs: cleanRefs(run.scenario?.refs), lanes: flow?.lanes ?? null, sessions: flow?.sessions ?? null },
107
115
  environment: run.environment || null,
108
116
  git: run.git || null,
109
117
  tools: r.tools || null,
@@ -113,7 +121,7 @@ export function evidenceOf (record, { record_sha256: recordSha = null, signed =
113
121
  summary: r.summary || null,
114
122
  ai: r.ai || null,
115
123
  advisory: r.advisory ?? null,
116
- steps: (r.steps || []).map((s) => ({ ...s, refs: cleanRefs(s.refs), criteria: (s.criteria || []).map((c) => ({ ...c, refs: cleanRefs(c.refs) })) })),
124
+ steps: (r.steps || []).map((s) => ({ ...s, ...placed(s), refs: cleanRefs(s.refs), criteria: (s.criteria || []).map((c) => ({ ...c, refs: cleanRefs(c.refs) })) })),
117
125
  signatures: r.signatures || [],
118
126
  decision_changes: r.decision_changes || [],
119
127
  audit: (audit || []).map(auditEntry)
@@ -130,7 +138,7 @@ export function auditEntry (a) {
130
138
  }
131
139
 
132
140
  /** The evidence document of a release (kind release): its runs' evidence and one trace matrix over all of them. */
133
- export function releaseEvidenceOf ({ release, runs, missing = [], org = null, issuer = null, generated_at: at = new Date().toISOString() }) {
141
+ export function releaseEvidenceOf ({ release, runs, missing = [], org = null, issuer = null, generated_at: at = new Date().toISOString(), filter = null }) {
134
142
  return {
135
143
  schema: EVIDENCE_SCHEMA,
136
144
  kind: 'release',
@@ -141,6 +149,8 @@ export function releaseEvidenceOf ({ release, runs, missing = [], org = null, is
141
149
  runs,
142
150
  // runs of the release with no evidence: not accepted, deleted or out of reach, each with the reason
143
151
  missing,
152
+ // ?ref= / ?purpose= narrowed the release to the runs of one record or purpose (#126)
153
+ ...(filter && Object.keys(filter).length ? { filter } : {}),
144
154
  trace: runs.flatMap((r) => r.trace || [])
145
155
  }
146
156
  }
@@ -161,6 +171,7 @@ export function traceRows (ev) {
161
171
  const d = s.decision || (s.carried ? { state: 'approved', by: s.carried.approved_by, at: s.carried.approved_at, via: 'carried' } : null)
162
172
  const stepCols = { step_decision: d?.state ?? null, step_decided_by: d?.by ?? null, step_decided_at: d?.at ?? null, step_decided_via: d?.via ?? null, ...sigCols(stepSig, 'step'), step_comment: d?.note || null }
163
173
  const stepRefs = cleanRefs(s.refs)
174
+ const placeCols = { session: s.session?.key ?? null, session_title: s.session?.title ?? null, lane: s.lane?.index ?? null, lane_label: s.lane?.label ?? null }
164
175
  const caps = new Map((s.captures || []).map((c) => [`${c.locale}|${c.viewport}`, c]))
165
176
  for (const c of s.criteria || []) {
166
177
  const refs = [...cleanRefs(c.refs).map((x) => [x, 'criterion']), ...stepRefs.map((x) => [x, 'step']), ...scenarioRefs.map((x) => [x, 'scenario'])]
@@ -175,7 +186,7 @@ export function traceRows (ev) {
175
186
  step_id: s.id, step_title: s.title ?? null, criterion_id: c.id, criterion_text: c.text ?? null, criterion_type: c.type ?? null,
176
187
  locale: cell.locale ?? null, viewport: cell.viewport ?? null, evidence_file: cap?.path ?? null, evidence_sha256: cap?.sha256 ?? null,
177
188
  result: cell.verdict ?? null, assessed_by: cell.assessed_by ?? null, assessor: cell.assessor ?? null, advisory: !!cell.advisory,
178
- ...stepCols, ...runCols
189
+ ...stepCols, ...runCols, ...placeCols
179
190
  })
180
191
  }
181
192
  }
@@ -287,7 +298,7 @@ export function packageReadme ({ run, service, recordSha }) {
287
298
  'record/kaloko-signoff.json the acceptance record, signed by Kaloko (Ed25519)',
288
299
  'record/kaloko-signoff.pdf the same record for people, with the JSON attached',
289
300
  'evidence.json everything below in one machine-readable file (schema qawalk.evidence.v1)',
290
- 'trace.json, trace.csv requirement → criterion → step, locale, viewport → screenshot (SHA-256) → result → decision',
301
+ 'trace.json, trace.csv requirement → criterion → step (role, lane), locale, viewport → screenshot (SHA-256) → result → decision',
291
302
  'audit.csv the audit trail of the run',
292
303
  'captures/ the screenshots the record names',
293
304
  'decisions/ files attached to step decisions (approve / return), as the record names them',
@@ -0,0 +1,61 @@
1
+ // Lanes (sub-processes) and sessions (roles) of a flow: which lane and which person a step belongs to, with the names
2
+ // the canvas shows. Pure, no Node APIs: shared by the scenario layout (scenario.mjs), the quality evidence
3
+ // (evidence-core.mjs, CLI and service) and the MCP run summary. The canvas (viewer/index.html) keeps a byte-identical
4
+ // copy of laneLabels; test/lanes.test.mjs checks that the two stay the same.
5
+ //
6
+ // A scenario names its lanes in `lanes:`. Without them, its sessions draw the lanes: one per session, in declaration
7
+ // order. The lane index of a step is resolved once, by the layout when the run is built (run.json nodes[].lane), and
8
+ // every reader takes it from there.
9
+
10
+ /** Lane index of each session when the sessions draw the lanes (no `lanes` declared); null when `lanes` are declared. */
11
+ export function sessionLanes (lanes, sessions) {
12
+ if (!sessions || (Array.isArray(lanes) && lanes.length)) return null
13
+ return new Map(Object.keys(sessions).map((k, i) => [k, i]))
14
+ }
15
+
16
+ /**
17
+ * Names of the lanes, index = lane: the scenario's `lanes`, or (none declared) the titles of its sessions in
18
+ * declaration order. A localized title reads in `locale`, else its default, else its first language. Self-contained:
19
+ * the canvas carries the same function.
20
+ */
21
+ export function laneLabels (lanes, sessions, locale) {
22
+ const text = (v) => v == null ? '' : typeof v === 'string' ? v : (v[locale] ?? v.default ?? Object.values(v)[0] ?? '')
23
+ return Array.isArray(lanes) && lanes.length ? lanes.map((l) => text(l)) : Object.entries(sessions || {}).map(([k, s]) => text(s?.title) || k)
24
+ }
25
+
26
+ /** A plain or localized title as text (the same reading as laneLabels). */
27
+ export const textOf = (v, locale = null) => v == null ? '' : typeof v === 'string' ? v : (v[locale] ?? v.default ?? Object.values(v)[0] ?? '')
28
+
29
+ /**
30
+ * Where each step of a run manifest (qawalk.run.v1) sits: Map step id → { session: { key, title } | null,
31
+ * lane: { index, label } | null }. label is null for a lane without a name (a flow without lanes or sessions, or an
32
+ * extra row the layout opened for an alternative branch).
33
+ */
34
+ export function stepPlaces (manifest, locale = null) {
35
+ const m = manifest || {}
36
+ const labels = laneLabels(m.lanes, m.sessions, locale)
37
+ const out = new Map()
38
+ for (const n of m.nodes || []) {
39
+ const key = typeof n.session === 'string' && n.session ? n.session : null
40
+ out.set(n.id, {
41
+ session: key ? { key, title: textOf(m.sessions?.[key]?.title, locale) || key } : null,
42
+ lane: Number.isInteger(n.lane) ? { index: n.lane, label: labels[n.lane] || null } : null
43
+ })
44
+ }
45
+ return out
46
+ }
47
+
48
+ /**
49
+ * The lanes and sessions of a run manifest as a portal lists them: lanes [{ index, label }] (every named lane and
50
+ * every lane a step sits in) and sessions [{ key, title }] in declaration order.
51
+ */
52
+ export function flowStructure (manifest, locale = null) {
53
+ const m = manifest || {}
54
+ const labels = laneLabels(m.lanes, m.sessions, locale)
55
+ const used = new Set(labels.map((_, i) => i))
56
+ for (const n of m.nodes || []) if (Number.isInteger(n.lane)) used.add(n.lane)
57
+ return {
58
+ lanes: [...used].sort((a, b) => a - b).map((index) => ({ index, label: labels[index] || null })),
59
+ sessions: Object.entries(m.sessions || {}).map(([key, s]) => ({ key, title: textOf(s?.title, locale) || key }))
60
+ }
61
+ }
@@ -0,0 +1,55 @@
1
+ // Run references and purpose (#126): the external records a run serves (a change-control record CC-2026-001, a
2
+ // validation protocol) and why it was walked (dry_run, formal, smoke). Set with kaloko start --ref / --purpose or
3
+ // KALOKO_REFS / KALOKO_PURPOSE, kept in the run manifest (refs, purpose) and checked again by the service
4
+ // (worker/src/manifest.js), which stores them so GET …/runs?ref= and ?purpose= can filter. No node: imports: the
5
+ // Worker uses it too.
6
+
7
+ /** A reference as people write record ids: letters, digits and . _ - / : # +, starting with a letter or digit. */
8
+ export const RUN_REF = /^[A-Za-z0-9][A-Za-z0-9._:/#+-]{0,79}$/
9
+ export const MAX_RUN_REFS = 20
10
+ export const RUN_PURPOSES = ['dry_run', 'formal', 'smoke']
11
+
12
+ /**
13
+ * References from options or the environment: an array of values (--ref repeated) or one string, each split at
14
+ * commas and white space. Trimmed, empty pieces dropped, duplicates removed in order. Not validated: see refProblems.
15
+ */
16
+ export function splitRunRefs (value) {
17
+ const list = Array.isArray(value) ? value : value == null || value === true || value === false ? [] : [value]
18
+ const out = []
19
+ for (const v of list) for (const piece of String(v).split(/[\s,]+/)) if (piece && !out.includes(piece)) out.push(piece)
20
+ return out
21
+ }
22
+
23
+ /** What is wrong with a list of references ([] when it is fine). */
24
+ export function refProblems (refs) {
25
+ if (!Array.isArray(refs)) return ['refs must be a list of references.']
26
+ const problems = []
27
+ if (refs.length > MAX_RUN_REFS) problems.push(`A run takes at most ${MAX_RUN_REFS} references; this one has ${refs.length}.`)
28
+ for (const r of refs) if (typeof r !== 'string' || !RUN_REF.test(r)) problems.push(`"${String(r).slice(0, 100)}" is not a reference Kaloko keeps: up to 80 letters, digits and . _ - / : # +, starting with a letter or digit.`)
29
+ if (new Set(refs).size !== refs.length) problems.push('refs names a reference twice.')
30
+ return problems
31
+ }
32
+
33
+ /**
34
+ * The references and purpose of kaloko start: --ref (repeated or comma separated) or else KALOKO_REFS, --purpose or
35
+ * else KALOKO_PURPOSE. Returns { refs, purpose, problems }; problems name the option or variable they came from.
36
+ */
37
+ export function startRefs (args = {}, env = {}) {
38
+ const fromFlag = args.ref !== undefined && args.ref !== true && !(Array.isArray(args.ref) && args.ref.every((v) => v === true))
39
+ const refs = splitRunRefs(fromFlag ? [].concat(args.ref).filter((v) => v !== true) : env.KALOKO_REFS)
40
+ const problems = []
41
+ if (args.ref === true || (Array.isArray(args.ref) && args.ref.includes(true))) problems.push('--ref takes a reference, e.g. --ref CC-2026-001.')
42
+ for (const p of refProblems(refs)) problems.push(`${fromFlag ? '--ref' : 'KALOKO_REFS'}: ${p}`)
43
+ const given = typeof args.purpose === 'string' ? args.purpose : args.purpose === true ? '' : env.KALOKO_PURPOSE
44
+ const purpose = normalizePurpose(given)
45
+ if (args.purpose === true) problems.push(`--purpose takes one of ${RUN_PURPOSES.join(', ')}.`)
46
+ else if (purpose === undefined) problems.push(`${typeof args.purpose === 'string' ? '--purpose' : 'KALOKO_PURPOSE'} is "${String(given).slice(0, 40)}"; use ${RUN_PURPOSES.join(', ')}.`)
47
+ return { refs, purpose: purpose || null, problems }
48
+ }
49
+
50
+ /** dry_run | formal | smoke from what people type (dry-run, Formal); null when empty; undefined when not a purpose. */
51
+ export function normalizePurpose (value) {
52
+ if (value == null || value === '') return null
53
+ const p = String(value).trim().toLowerCase().replace(/-/g, '_')
54
+ return RUN_PURPOSES.includes(p) ? p : undefined
55
+ }
package/src/lib/run.mjs CHANGED
@@ -37,7 +37,7 @@ export function newRunId (scenario, date = new Date()) {
37
37
  return { id: `${today(date)}-${slugify(scenario.id)}-${short}`, short }
38
38
  }
39
39
 
40
- export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, appVersion = null, product = null, pii = null }) {
40
+ export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, refs = [], purpose = null, appVersion = null, product = null, pii = null }) {
41
41
  const { id, short } = newRunId(scenario)
42
42
  const dir = resolve(root, output, id)
43
43
  await mkdir(dir, { recursive: true })
@@ -54,6 +54,9 @@ export async function createRun ({ output, scenario, scenarioFile, env, scope, g
54
54
  executor,
55
55
  project,
56
56
  issue,
57
+ // the records the run serves (change control, protocol) and why it was walked (#126; src/lib/run-refs.mjs)
58
+ ...(refs?.length ? { refs } : {}),
59
+ ...(purpose ? { purpose } : {}),
57
60
  app_version: appVersion,
58
61
  // personal data hidden in every capture (#64): { mode: redact | pseudonymize, allow }
59
62
  ...(pii ? { pii } : {}),
@@ -4,6 +4,7 @@ import { schemaErrors } from './schema.mjs'
4
4
  import { UserError } from './util.mjs'
5
5
  import { expandPacks } from './packs.mjs'
6
6
  import { jsGuard } from './assertions.mjs'
7
+ import { sessionLanes } from './lanes-core.mjs'
7
8
 
8
9
  // a scenario holds thousands of values, not millions: aliases (&a, *a) can expand a few kilobytes of YAML into
9
10
  // billions that validation and its JSON copy would walk ("billion laughs"), so the expanded size has a ceiling
@@ -156,7 +157,7 @@ export function layout (scenario) {
156
157
  const taken = new Set()
157
158
  const claim = (c, l) => { while (taken.has(`${c}:${l}`)) l++; taken.add(`${c}:${l}`); return l }
158
159
  // sessions without explicit lanes: one swimlane per person, in declaration order
159
- const sessionLane = scenario.sessions && !(scenario.lanes || []).length ? new Map(Object.keys(scenario.sessions).map((k, i) => [k, i])) : null
160
+ const sessionLane = sessionLanes(scenario.lanes, scenario.sessions)
160
161
  for (const n of [...nodes].sort((a, b) => col.get(a.id) - col.get(b.id) || order.get(a.id) - order.get(b.id))) {
161
162
  let wanted = n.lane
162
163
  if (wanted == null && sessionLane && n.session != null && sessionLane.has(n.session)) wanted = sessionLane.get(n.session)
@@ -143,6 +143,10 @@ export async function evidenceCmd ({ config, args, say, client, runId = null })
143
143
  const product = typeof args.product === 'string' ? args.product : Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : (config.project || null)
144
144
  if (!product) throw new UsageError('Name the product of the release: --product <slug>.')
145
145
  path = `/orgs/${config.org}/evidence?product=${encodeURIComponent(product)}&version=${encodeURIComponent(release)}`
146
+ // only the release's runs of one record or purpose (#126)
147
+ const ref = typeof args.ref === 'string' ? args.ref : null
148
+ if (ref) path += `&ref=${encodeURIComponent(ref)}`
149
+ if (typeof args.purpose === 'string') path += `&purpose=${encodeURIComponent(args.purpose)}`
146
150
  } else path = `/orgs/${config.org}/runs/${encodeURIComponent(runId)}/evidence`
147
151
  const ev = await client.request('GET', path)
148
152
  const text = args.csv ? traceCsv(ev.trace || []) : JSON.stringify(ev, null, 2)
@@ -0,0 +1,132 @@
1
+ // "Is this in a newer kaloko?": when someone types a command, sub-command or option this kaloko does not know, the
2
+ // commands of the newest released kaloko (GET <service>/cli/latest.json, cached for a day) say whether updating
3
+ // helps. A line says so only when that version is newer than this one and has exactly what was typed; offline, a
4
+ // slow answer, an odd answer or KALOKO_NO_UPDATE_CHECK=1 mean no line, never an error or a wait over 1.5 s.
5
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join, dirname, sep } from 'node:path'
9
+ import { serviceUrl } from './service.mjs'
10
+ import { compareVersions } from './changelog-core.mjs'
11
+
12
+ export const CATALOGUE_PATH = '/cli/latest.json'
13
+ export const TIMEOUT_MS = 1500
14
+ const FRESH_MS = 24 * 3600e3 // a catalogue read from the service
15
+ const RETRY_MS = 3600e3 // no answer: ask again after an hour, not on every typo
16
+ const NAME = /^[a-z][a-z0-9-]*$/
17
+
18
+ /** Off with KALOKO_NO_UPDATE_CHECK=1 (or NO_UPDATE_NOTIFIER, the npm convention) and with --offline. */
19
+ export const updateCheckOff = (env = process.env, args = {}) => !!(truthy(env.KALOKO_NO_UPDATE_CHECK) || truthy(env.NO_UPDATE_NOTIFIER) || args.offline)
20
+ const truthy = (v) => v !== undefined && v !== '' && v !== '0' && v !== 'false'
21
+
22
+ /** Where the catalogue is kept: KALOKO_CACHE_DIR, else the OS cache folder (~/.cache/kaloko, %LOCALAPPDATA%\kaloko). */
23
+ export function cacheDir (env = process.env, os = process.platform) {
24
+ if (env.KALOKO_CACHE_DIR) return env.KALOKO_CACHE_DIR
25
+ if (os === 'win32' && env.LOCALAPPDATA) return join(env.LOCALAPPDATA, 'kaloko', 'cache')
26
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'kaloko')
27
+ }
28
+
29
+ /** A catalogue as the service sends it, or null when it is not one: { version, commands: { name: { flags, subcommands } } }. */
30
+ export function validCatalogue (c) {
31
+ if (!c || typeof c !== 'object' || typeof c.version !== 'string' || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(c.version)) return null
32
+ if (!c.commands || typeof c.commands !== 'object' || Array.isArray(c.commands)) return null
33
+ const names = (list) => Array.isArray(list) && list.every((f) => typeof f === 'string' && NAME.test(f))
34
+ for (const [name, cmd] of Object.entries(c.commands)) {
35
+ if (!NAME.test(name) || !cmd || !names(cmd.flags)) return null
36
+ if (cmd.subcommands !== undefined && (typeof cmd.subcommands !== 'object' || !Object.entries(cmd.subcommands).every(([s, v]) => NAME.test(s) && v && names(v.flags)))) return null
37
+ }
38
+ return c
39
+ }
40
+
41
+ /**
42
+ * The newest kaloko's catalogue: from the cache while it is fresh, else from the service (one try, `timeout` ms).
43
+ * Null when the check is off or nothing usable came back.
44
+ */
45
+ export async function newestCatalogue ({ config = {}, env = process.env, fetchImpl = fetch, now = Date.now(), timeout = TIMEOUT_MS, args = {} } = {}) {
46
+ if (updateCheckOff(env, args)) return null
47
+ let url
48
+ try { url = `${serviceUrl(config, env)}${CATALOGUE_PATH}` } catch { return null }
49
+ const file = join(cacheDir(env), 'cli-latest.json')
50
+ let cached = null
51
+ try { cached = JSON.parse(await readFile(file, 'utf8')) } catch {}
52
+ if (cached?.url === url && typeof cached.fetched_at === 'number' && cached.fetched_at <= now) {
53
+ const age = now - cached.fetched_at
54
+ if (cached.catalogue && age < FRESH_MS) return validCatalogue(cached.catalogue)
55
+ if (!cached.catalogue && age < RETRY_MS) return null
56
+ }
57
+ let catalogue = null
58
+ const ctl = new AbortController()
59
+ const timer = setTimeout(() => ctl.abort(), timeout)
60
+ try {
61
+ const res = await fetchImpl(url, { signal: ctl.signal, redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': 'kaloko-cli' } })
62
+ if (res.ok) catalogue = validCatalogue(JSON.parse(await res.text()))
63
+ } catch {} finally { clearTimeout(timer) }
64
+ try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, JSON.stringify({ url, fetched_at: now, catalogue })) } catch {}
65
+ return catalogue
66
+ }
67
+
68
+ /**
69
+ * What a newer kaloko has of what was typed, or null: `catalogue` must be newer than `installed` and have the command,
70
+ * the sub-command or the options exactly. { words: 'projects create', version } or { words: 'init --site', version }.
71
+ */
72
+ export function newerHas (catalogue, installed, { command, sub = null, options = [] }) {
73
+ if (!validCatalogue(catalogue) || !installed || compareVersions(catalogue.version, installed) <= 0) return null
74
+ const cmd = Object.hasOwn(catalogue.commands, command) ? catalogue.commands[command] : null
75
+ if (!cmd) return null
76
+ const out = (words) => ({ words: `${command} ${words}`.trim(), version: catalogue.version })
77
+ if (sub && cmd.subcommands && Object.hasOwn(cmd.subcommands, sub)) return out(sub)
78
+ const known = options.filter((o) => cmd.flags.includes(o))
79
+ if (known.length) return { ...out(known.map((o) => `--${o}`).join(' ')), options: known }
80
+ if (!sub && !options.length) return out('')
81
+ return null
82
+ }
83
+
84
+ /**
85
+ * How this project gets a newer kaloko: the package manager's add command when package.json lists kaloko (lockfile
86
+ * decides npm, pnpm, yarn or bun), npx kaloko@latest when this kaloko runs from the npx cache, else both.
87
+ */
88
+ export function installCommand ({ cwd = process.cwd(), root = null, words = '' } = {}) {
89
+ const npx = `npx kaloko@latest${words ? ` ${words}` : ''}`
90
+ if (root && root.split(sep).includes('_npx')) return { run: npx }
91
+ for (let dir = cwd; ; dir = dirname(dir)) {
92
+ const pkg = join(dir, 'package.json')
93
+ if (existsSync(pkg)) {
94
+ let p = null
95
+ try { p = JSON.parse(readFileSync(pkg, 'utf8')) } catch {}
96
+ const dev = !!p?.devDependencies?.kaloko
97
+ if (!dev && !p?.dependencies?.kaloko) break
98
+ const d = dev ? ' -D' : ''
99
+ const pm = existsSync(join(dir, 'pnpm-lock.yaml')) ? `pnpm add${d}` : existsSync(join(dir, 'yarn.lock')) ? `yarn add${d}` : existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock')) ? `bun add${dev ? ' -d' : ''}` : `npm i${d}`
100
+ return { update: `${pm} kaloko@latest` }
101
+ }
102
+ if (dirname(dir) === dir) break
103
+ }
104
+ return { update: 'npm i -D kaloko@latest', run: npx }
105
+ }
106
+
107
+ /** "kaloko projects create is in kaloko 0.16.0 (you have 0.15.0). Update: npm i -D kaloko@latest" */
108
+ export function updateLine ({ words, version }, installed, how) {
109
+ const what = `kaloko ${words} ${/ --\S+ --/.test(` ${words}`) ? 'are' : 'is'} in kaloko ${version} (you have ${installed}).`
110
+ if (how.update && how.run) return `${what} Update: ${how.update} (or run ${how.run})`
111
+ return how.update ? `${what} Update: ${how.update}` : `${what} Run it with ${how.run}`
112
+ }
113
+
114
+ /**
115
+ * For something this kaloko does not know (`typed`: { command, sub, options }), or null: the cached or fetched
116
+ * catalogue must be newer and have it. { line, update: line, newer: [the options it takes] }.
117
+ */
118
+ export async function updateHint (typed, { installed, config = {}, env = process.env, fetchImpl = fetch, cwd = process.cwd(), root = null, args = {}, now } = {}) {
119
+ const catalogue = await newestCatalogue({ config, env, fetchImpl, args, ...(now ? { now } : {}) })
120
+ const has = catalogue && newerHas(catalogue, installed, typed)
121
+ if (!has) return null
122
+ const line = updateLine(has, installed, installCommand({ cwd, root, words: has.words }))
123
+ return { line, update: line, newer: has.options || [] }
124
+ }
125
+
126
+ /** The doctor line: this version, and the newest one when it is newer (from the same cached catalogue). */
127
+ export async function versionCheck ({ installed, config = {}, env = process.env, fetchImpl = fetch, cwd = process.cwd(), root = null }) {
128
+ const catalogue = await newestCatalogue({ config, env, fetchImpl })
129
+ if (!catalogue || compareVersions(catalogue.version, installed) <= 0) return { installed, newest: catalogue?.version || null, newer: false, detail: catalogue ? `${installed} (the newest)` : installed }
130
+ const how = installCommand({ cwd, root })
131
+ return { installed, newest: catalogue.version, newer: true, detail: `${installed} · a newer kaloko is available: ${catalogue.version} (${how.update ? how.update : how.run})` }
132
+ }