@erclx/aitk 3.6.0 → 3.8.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 (59) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-autoship/SKILL.md +5 -5
  3. package/claude/skills/claude-docs/SKILL.md +15 -10
  4. package/claude/skills/claude-memory-review/SKILL.md +7 -7
  5. package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
  6. package/claude/skills/claude-orchestrate/SKILL.md +2 -2
  7. package/claude/skills/claude-pr-review/SKILL.md +25 -7
  8. package/claude/skills/claude-review/SKILL.md +5 -3
  9. package/claude/skills/claude-screencast/SKILL.md +9 -4
  10. package/claude/skills/claude-tasks/SKILL.md +2 -2
  11. package/claude/skills/create-rule/REQUIREMENT.md +2 -1
  12. package/claude/skills/create-rule/SKILL.md +8 -8
  13. package/claude/skills/create-snippet/REQUIREMENT.md +3 -0
  14. package/claude/skills/create-snippet/SKILL.md +2 -2
  15. package/claude/skills/git-pr/references/pr.md +3 -0
  16. package/claude/skills/git-ship/SKILL.md +1 -1
  17. package/claude/skills/git-split/references/pr.md +3 -0
  18. package/claude/skills/restate/REQUIREMENT.md +41 -0
  19. package/claude/skills/restate/SKILL.md +39 -0
  20. package/claude/skills/toolkit-feedback/SKILL.md +2 -2
  21. package/claude/skills/write-human/REQUIREMENT.md +1 -1
  22. package/claude/skills/write-human/SKILL.md +1 -1
  23. package/docs/agents/capture.md +3 -1
  24. package/docs/agents/commands.md +25 -21
  25. package/docs/agents/demo.md +82 -0
  26. package/docs/agents/index.md +2 -0
  27. package/docs/agents/install-and-sync.md +6 -2
  28. package/docs/agents/records.md +2 -2
  29. package/docs/agents/routing.md +61 -0
  30. package/docs/agents/tasks.md +1 -1
  31. package/docs/ai-workflow.md +8 -5
  32. package/docs/operating-model.md +13 -4
  33. package/governance/rules/claude/558-plan.md +1 -2
  34. package/governance/rules/lib/300-testing-ts.md +1 -0
  35. package/package.json +3 -2
  36. package/src/claude/routing.ts +283 -0
  37. package/src/cli.ts +4 -1
  38. package/src/commands/claude.ts +130 -1
  39. package/src/commands/demo.ts +373 -0
  40. package/src/commands/feedback.ts +10 -3
  41. package/src/commands/tasks.ts +1 -1
  42. package/src/demo/beats.ts +135 -0
  43. package/src/demo/compile.ts +295 -0
  44. package/src/demo/cursors.ts +55 -0
  45. package/src/demo/drive.ts +256 -0
  46. package/src/demo/pointer.ts +178 -0
  47. package/src/demo/theme.ts +112 -0
  48. package/src/gov/adapter.ts +1 -0
  49. package/src/records/backup.ts +34 -8
  50. package/src/snippets/adapter.ts +1 -0
  51. package/src/sync/engine.ts +25 -1
  52. package/src/tasks/archive.ts +11 -4
  53. package/standards/bundled/pr.md +3 -0
  54. package/standards/plan.md +1 -1
  55. package/standards/tasks.md +4 -4
  56. package/tooling/claude/manifest.toml +1 -1
  57. package/tooling/claude/reference.md +6 -5
  58. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +4 -1
  59. package/tooling/claude/seeds/CLAUDE.md +1 -1
@@ -0,0 +1,373 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path'
3
+ import type { Command } from 'commander'
4
+ import { parseDraft } from '@/demo/beats'
5
+ import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
6
+ import { DEFAULT_CURSORS } from '@/demo/cursors'
7
+ import { loadCursorTheme } from '@/demo/theme'
8
+ import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
9
+
10
+ const DEFAULT_OUT = 'demos'
11
+ const INSTALL_BROWSER = 'bunx playwright install chromium'
12
+
13
+ /**
14
+ * Holds wiring only. Every browser reference sits behind `loadDriver`, because
15
+ * `src/cli.ts` imports this module at startup and resolving the engine there
16
+ * would put a browser launch in front of every other command.
17
+ */
18
+ type Driver = typeof import('@/demo/drive')
19
+
20
+ interface CompileOptions {
21
+ readonly out: string
22
+ readonly slug?: string
23
+ readonly force?: boolean
24
+ readonly json?: boolean
25
+ }
26
+
27
+ interface RunOptions {
28
+ readonly out?: string
29
+ readonly cursor?: string
30
+ readonly video: boolean
31
+ readonly still: boolean
32
+ readonly json?: boolean
33
+ }
34
+
35
+ export function register(program: Command): void {
36
+ const demo = program
37
+ .command('demo')
38
+ .description('Drive a running application and record what it did')
39
+ .helpOption('-h, --help', 'Show this help message')
40
+
41
+ demo
42
+ .command('compile')
43
+ .description('Turn a screencast draft into a plan a run can drive')
44
+ .argument('<draft>', 'Screencast draft written by claude-screencast')
45
+ .helpOption('-h, --help', 'Show this help message')
46
+ .option('-o, --out <dir>', 'Directory the plan is written to', DEFAULT_OUT)
47
+ .option('-s, --slug <slug>', 'Plan name, defaulting to the draft filename')
48
+ .option('--force', 'Overwrite a plan that already exists')
49
+ .option('--json', 'Add a machine-readable record on stdout')
50
+ .addHelpText(
51
+ 'after',
52
+ [
53
+ '',
54
+ 'The plan is committed rather than scratch. It carries the target, the',
55
+ 'wait condition, and the timing a beat lacks, and the timing is a',
56
+ 'starting point you tune, which is why a recompile refuses to overwrite.',
57
+ '',
58
+ 'Exit codes:',
59
+ ' 0 a plan was written',
60
+ ' 1 refused, with the reason on stderr',
61
+ '',
62
+ 'Examples:',
63
+ ' aitk demo compile .claude/.tmp/screencast/inline-edit.md',
64
+ ' aitk demo compile draft.md --out demos --force',
65
+ '',
66
+ ].join('\n'),
67
+ )
68
+ .action(async (draft: string, opts: CompileOptions) => {
69
+ process.exitCode = runCompile(draft, opts)
70
+ })
71
+
72
+ demo
73
+ .command('run')
74
+ .description('Drive the application a plan names and write the recording')
75
+ .argument('<plan>', 'Plan written by aitk demo compile')
76
+ .helpOption('-h, --help', 'Show this help message')
77
+ .option('-o, --out <dir>', 'Directory to write into, overriding the plan')
78
+ .option(
79
+ '-c, --cursor <dir>',
80
+ 'Cursor theme folder to draw the pointer from',
81
+ )
82
+ .option('--no-video', 'Skip the recording and write only the still')
83
+ .option('--no-still', 'Skip the still and write only the recording')
84
+ .option('--json', 'Add a machine-readable record on stdout')
85
+ .addHelpText(
86
+ 'after',
87
+ [
88
+ '',
89
+ 'Needs a browser binary. Install it with:',
90
+ ` ${INSTALL_BROWSER}`,
91
+ '',
92
+ 'Exit codes:',
93
+ ' 0 the recording and the still were written',
94
+ ' 1 refused, with the reason on stderr',
95
+ '',
96
+ 'Examples:',
97
+ ' aitk demo run demos/inline-edit.json',
98
+ ' aitk demo run demos/inline-edit.json --cursor ~/cursors/theme',
99
+ '',
100
+ ].join('\n'),
101
+ )
102
+ .action(async (plan: string, opts: RunOptions) => {
103
+ process.exitCode = await runDrive(plan, opts)
104
+ })
105
+ }
106
+
107
+ function runCompile(draftPath: string, opts: CompileOptions): number {
108
+ intro('aitk demo compile')
109
+
110
+ const source = resolve(process.cwd(), draftPath)
111
+ if (!existsSync(source)) {
112
+ logStep('Draft')
113
+ logError(`${draftPath} not found`)
114
+ outro()
115
+ emit(opts.json, { draft: source, reason: 'draft-missing' })
116
+ return 1
117
+ }
118
+
119
+ const parsed = parseDraft(readFileSync(source, 'utf8'))
120
+ if (parsed.status === 'failed') {
121
+ logStep('Draft')
122
+ logError(`${display(source)}: ${parsed.reason}`)
123
+ outro()
124
+ emit(opts.json, {
125
+ draft: source,
126
+ reason: 'draft-unreadable',
127
+ message: parsed.reason,
128
+ })
129
+ return 1
130
+ }
131
+
132
+ const slug = opts.slug ?? basename(source, extname(source))
133
+ const target = resolve(process.cwd(), opts.out, `${slug}.json`)
134
+
135
+ if (existsSync(target) && !opts.force) {
136
+ logStep('Plan')
137
+ logError(`${display(target)} already exists`)
138
+ // Stated rather than implied, because the value at risk is timing the
139
+ // operator tuned by watching a recording and the draft cannot reproduce it.
140
+ logWarn('Pass --force to overwrite it, losing any timing tuned by hand.')
141
+ outro()
142
+ emit(opts.json, { plan: target, reason: 'plan-exists' })
143
+ return 1
144
+ }
145
+
146
+ const plan = compilePlan(parsed.draft, { slug, outDir: opts.out })
147
+ mkdirSync(dirname(target), { recursive: true })
148
+ writeFileSync(target, `${JSON.stringify(plan, null, 2)}\n`)
149
+
150
+ logStep('Draft')
151
+ logInfo(`${display(source)} ${plural(parsed.draft.beats.length, 'beat')}`)
152
+
153
+ logStep('Plan')
154
+ logInfo(display(target))
155
+
156
+ const outstanding = unresolved(plan)
157
+ logStep('Outstanding')
158
+ if (outstanding.length === 0) {
159
+ logInfo('Nothing to fill, so the plan runs as written.')
160
+ } else {
161
+ logWarn(
162
+ `${plural(outstanding.length, 'field')} the draft could not supply:`,
163
+ )
164
+ for (const field of outstanding) logWarn(` ${field}`)
165
+ }
166
+ outro()
167
+
168
+ emit(opts.json, {
169
+ draft: source,
170
+ plan: target,
171
+ beats: parsed.draft.beats.length,
172
+ unresolved: outstanding,
173
+ })
174
+ return 0
175
+ }
176
+
177
+ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
178
+ intro('aitk demo run')
179
+
180
+ const source = resolve(process.cwd(), planPath)
181
+ if (!existsSync(source)) {
182
+ logStep('Plan')
183
+ logError(`${planPath} not found`)
184
+ outro()
185
+ emit(opts.json, { plan: source, reason: 'plan-missing' })
186
+ return 1
187
+ }
188
+
189
+ const parsed = parsePlan(readFileSync(source, 'utf8'))
190
+ if (parsed.status === 'failed') {
191
+ logStep('Plan')
192
+ logError(`${display(source)}: ${parsed.reason}`)
193
+ outro()
194
+ emit(opts.json, {
195
+ plan: source,
196
+ reason: 'plan-unreadable',
197
+ message: parsed.reason,
198
+ })
199
+ return 1
200
+ }
201
+
202
+ if (!opts.video && !opts.still) {
203
+ logStep('Output')
204
+ logError('--no-video and --no-still together leave nothing to write')
205
+ outro()
206
+ emit(opts.json, { plan: source, reason: 'no-output-requested' })
207
+ return 1
208
+ }
209
+
210
+ const outstanding = unresolved(parsed.plan)
211
+ if (outstanding.length) {
212
+ logStep('Plan')
213
+ logError(
214
+ `${display(source)} has ${plural(outstanding.length, 'field')} to fill`,
215
+ )
216
+ for (const field of outstanding) logWarn(` ${field}`)
217
+ outro()
218
+ emit(opts.json, {
219
+ plan: source,
220
+ reason: 'plan-unresolved',
221
+ unresolved: outstanding,
222
+ })
223
+ return 1
224
+ }
225
+
226
+ logStep('Plan')
227
+ logInfo(`${display(source)} ${plural(parsed.plan.steps.length, 'step')}`)
228
+
229
+ const cursors = resolveCursors(opts.cursor)
230
+ if (cursors.status === 'failed') {
231
+ logError(cursors.reason)
232
+ outro()
233
+ emit(opts.json, { plan: source, reason: 'cursor-unreadable' })
234
+ return 1
235
+ }
236
+ logInfo(cursors.label)
237
+
238
+ const driver = await loadDriver()
239
+ if (!driver) {
240
+ logStep('Browser')
241
+ logError('the browser engine is not installed in this project')
242
+ logWarn(`Install it with: ${INSTALL_BROWSER}`)
243
+ outro()
244
+ emit(opts.json, {
245
+ plan: source,
246
+ reason: 'engine-missing',
247
+ install: INSTALL_BROWSER,
248
+ })
249
+ return 1
250
+ }
251
+
252
+ logStep('Recording')
253
+ const result = await driver.drive({
254
+ plan: parsed.plan,
255
+ cursors: cursors.value,
256
+ ...(opts.video
257
+ ? { videoPath: outputPath(parsed.plan.output.video, opts.out) }
258
+ : {}),
259
+ ...(opts.still
260
+ ? { stillPath: outputPath(parsed.plan.output.still, opts.out) }
261
+ : {}),
262
+ })
263
+
264
+ if (result.status === 'failed') {
265
+ logError(result.message.split('\n')[0] ?? 'the run failed')
266
+ if (result.reason === 'browser-missing') {
267
+ logWarn(`Install the browser binary with: ${INSTALL_BROWSER}`)
268
+ }
269
+ outro()
270
+ emit(opts.json, {
271
+ plan: source,
272
+ reason: result.reason,
273
+ message: result.message,
274
+ ...(result.reason === 'browser-missing'
275
+ ? { install: INSTALL_BROWSER }
276
+ : {}),
277
+ })
278
+ return 1
279
+ }
280
+
281
+ if (result.videoPath) logInfo(display(result.videoPath))
282
+ if (result.stillPath) logInfo(`${display(result.stillPath)} still`)
283
+ logInfo(
284
+ `${result.steps} steps in ${Math.round(result.durationMs / 100) / 10}s`,
285
+ )
286
+ outro()
287
+
288
+ emit(opts.json, {
289
+ plan: source,
290
+ video: result.videoPath ?? null,
291
+ still: result.stillPath ?? null,
292
+ steps: result.steps,
293
+ durationMs: result.durationMs,
294
+ })
295
+ return 0
296
+ }
297
+
298
+ type CursorChoice =
299
+ | { status: 'ready'; value: typeof DEFAULT_CURSORS; label: string }
300
+ | { status: 'failed'; reason: string }
301
+
302
+ /**
303
+ * A theme contributes per state rather than per folder, so a folder carrying an
304
+ * arrow and no hand still supplies its arrow and the bundled artwork covers the
305
+ * rest.
306
+ */
307
+ function resolveCursors(dir: string | undefined): CursorChoice {
308
+ if (!dir) {
309
+ return {
310
+ status: 'ready',
311
+ value: DEFAULT_CURSORS,
312
+ label: 'pointer drawn from the bundled artwork',
313
+ }
314
+ }
315
+
316
+ const loaded = loadCursorTheme(resolve(process.cwd(), dir), DEFAULT_CURSORS)
317
+ if (loaded.status === 'failed')
318
+ return { status: 'failed', reason: loaded.reason }
319
+
320
+ return {
321
+ status: 'ready',
322
+ value: loaded.cursors,
323
+ label: `pointer drawn from ${display(resolve(process.cwd(), dir))} for ${loaded.states.join(', ')}`,
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Reports absence only when the module or its engine cannot be resolved, which
329
+ * is the case a target hits before installing the browser package. Any other
330
+ * import failure is a defect inside the driver and propagates, rather than
331
+ * being reported as a missing dependency.
332
+ */
333
+ async function loadDriver(): Promise<Driver | undefined> {
334
+ try {
335
+ return await import('@/demo/drive')
336
+ } catch (error) {
337
+ if (isModuleNotFound(error)) return undefined
338
+ throw error
339
+ }
340
+ }
341
+
342
+ function isModuleNotFound(error: unknown): boolean {
343
+ return (
344
+ typeof error === 'object' &&
345
+ error !== null &&
346
+ 'code' in error &&
347
+ error.code === 'ERR_MODULE_NOT_FOUND'
348
+ )
349
+ }
350
+
351
+ /**
352
+ * Resolves where one artifact lands. `--out` replaces the directory the plan
353
+ * names rather than acting as a root the plan's own directory hangs off, which
354
+ * would nest the output path inside itself on every run that passes both.
355
+ */
356
+ function outputPath(planned: string, out: string | undefined): string {
357
+ const relativePath = out ? join(out, basename(planned)) : planned
358
+ return resolve(process.cwd(), relativePath)
359
+ }
360
+
361
+ function emit(json: boolean | undefined, record: unknown): void {
362
+ if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
363
+ }
364
+
365
+ /**
366
+ * Keeps a path clickable in the operator's terminal. A path outside the project
367
+ * reports absolute, since a relative path to it is a run of `..` segments no
368
+ * editor resolves.
369
+ */
370
+ function display(path: string): string {
371
+ const fromCwd = relative(process.cwd(), path)
372
+ return fromCwd.startsWith('..') ? path : fromCwd
373
+ }
@@ -32,13 +32,20 @@ function isToolkitSource(): boolean {
32
32
  return existsSync(join(PROJECT_ROOT, '.claude'))
33
33
  }
34
34
 
35
+ /**
36
+ * One producer, one subfolder. The review folder carries the output of four
37
+ * unrelated producers, and the filename prefix was doing the folder's job by
38
+ * hand, so each writes under its own name and the enclosing folder keeps the
39
+ * single ignore entry and the single backed-folder entry it already had.
40
+ */
35
41
  function writeLocal(body: string): string {
36
- const reviewDir = join(PROJECT_ROOT, '.claude', 'review')
42
+ const relativeDir = join('.claude', 'review', 'feedback')
43
+ const reviewDir = join(PROJECT_ROOT, relativeDir)
37
44
  mkdirSync(reviewDir, { recursive: true })
38
45
  const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
39
46
  const filePath = join(reviewDir, filename)
40
47
  writeFileSync(filePath, `${body}\n`, 'utf8')
41
- frameSuccess('aitk feedback', `.claude/review/${filename}`)
48
+ frameSuccess('aitk feedback', join(relativeDir, filename))
42
49
  return filePath
43
50
  }
44
51
 
@@ -46,7 +53,7 @@ export function register(program: Command): void {
46
53
  program
47
54
  .command('feedback')
48
55
  .description(
49
- 'Write toolkit feedback from stdin to .claude/review/, or open a GitHub issue with --github',
56
+ 'Write toolkit feedback from stdin to .claude/review/feedback/, or open a GitHub issue with --github',
50
57
  )
51
58
  .option(
52
59
  '--github',
@@ -151,7 +151,7 @@ export function register(program: Command): void {
151
151
  'Locations:',
152
152
  ' unstated the task carries no Plan: line',
153
153
  ' live the target resolves inside .claude/plans/',
154
- ' archived the target resolves inside .claude/plans-archive/',
154
+ ' archived the target resolves inside .claude/plans/archive/',
155
155
  ' outside the target resolves somewhere else',
156
156
  '',
157
157
  'Exit codes:',
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Reads the human-facing draft `claude-screencast` writes. Nothing here knows
3
+ * about a browser: the draft is prose aimed at a person, and turning it into
4
+ * something executable is `@/demo/compile`'s job.
5
+ */
6
+
7
+ export interface Beat {
8
+ readonly index: number
9
+ readonly name: string
10
+ readonly onScreen: string
11
+ readonly action: string
12
+ readonly watchFor: string
13
+ readonly emphasis: string
14
+ readonly caption: string
15
+ readonly transitionOut?: string
16
+ }
17
+
18
+ export interface Draft {
19
+ readonly title: string
20
+ readonly beats: readonly Beat[]
21
+ }
22
+
23
+ export type DraftParse =
24
+ | { status: 'parsed'; draft: Draft }
25
+ | { status: 'failed'; reason: string }
26
+
27
+ const TITLE = /^#\s+Screencast:\s*(.+)$/m
28
+ const BEAT_SHEET = /^##\s+.*Beat sheet.*$/im
29
+ const BEAT_HEADING = /^###\s+Beat\s+(\d+)\s*:\s*(.*)$/i
30
+ const FIELD = /^-\s+([A-Za-z][A-Za-z\s]*?)\s*:\s*(.*)$/
31
+
32
+ /**
33
+ * Labels are matched case-insensitively with whitespace collapsed, because the
34
+ * draft is hand-edited between being written and being compiled and a
35
+ * capitalization change there is not a reason to refuse the whole file.
36
+ */
37
+ const FIELDS: Record<string, keyof Beat> = {
38
+ 'on screen': 'onScreen',
39
+ action: 'action',
40
+ 'watch for': 'watchFor',
41
+ emphasis: 'emphasis',
42
+ caption: 'caption',
43
+ 'transition out': 'transitionOut',
44
+ }
45
+
46
+ export function parseDraft(markdown: string): DraftParse {
47
+ const sheet = beatSheetSection(markdown)
48
+ if (sheet === undefined) {
49
+ return {
50
+ status: 'failed',
51
+ reason: 'no "Beat sheet" section, so the draft carries no beats to run',
52
+ }
53
+ }
54
+
55
+ const beats = readBeats(sheet)
56
+ if (!beats.length) {
57
+ return {
58
+ status: 'failed',
59
+ reason: 'the beat sheet holds no "### Beat" heading',
60
+ }
61
+ }
62
+
63
+ return {
64
+ status: 'parsed',
65
+ draft: { title: TITLE.exec(markdown)?.[1]?.trim() ?? '', beats },
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Returns the lines between the beat sheet heading and the next `##`, so a
71
+ * `### Beat` heading elsewhere in the draft cannot be read as a beat.
72
+ */
73
+ function beatSheetSection(markdown: string): string[] | undefined {
74
+ const lines = markdown.split('\n')
75
+ const start = lines.findIndex((line) => BEAT_SHEET.test(line))
76
+ if (start === -1) return undefined
77
+
78
+ const rest = lines.slice(start + 1)
79
+ const end = rest.findIndex((line) => /^##\s/.test(line))
80
+ return end === -1 ? rest : rest.slice(0, end)
81
+ }
82
+
83
+ function readBeats(lines: string[]): Beat[] {
84
+ const beats: Beat[] = []
85
+ let open: Partial<Beat> | undefined
86
+
87
+ for (const line of lines) {
88
+ const heading = BEAT_HEADING.exec(line)
89
+ if (heading) {
90
+ if (open) beats.push(sealBeat(open))
91
+ open = {
92
+ index: Number(heading[1]),
93
+ name: (heading[2] ?? '').trim(),
94
+ }
95
+ continue
96
+ }
97
+
98
+ if (!open) continue
99
+ const field = FIELD.exec(line)
100
+ if (!field) continue
101
+
102
+ const key = FIELDS[normalizeLabel(field[1] ?? '')]
103
+ if (key) open = { ...open, [key]: (field[2] ?? '').trim() }
104
+ }
105
+
106
+ if (open) beats.push(sealBeat(open))
107
+ return beats
108
+ }
109
+
110
+ /**
111
+ * Fills the five required fields with empty strings rather than leaving them
112
+ * undefined, so a downstream reader distinguishes "the operator left it blank"
113
+ * from "the label was misspelt" by the presence of the key alone.
114
+ *
115
+ * `transitionOut` stays optional, because the draft adds it only when it is
116
+ * not the default and an empty one would claim a decision nobody made.
117
+ */
118
+ function sealBeat(open: Partial<Beat>): Beat {
119
+ const beat: Beat = {
120
+ index: open.index ?? 0,
121
+ name: open.name ?? '',
122
+ onScreen: open.onScreen ?? '',
123
+ action: open.action ?? '',
124
+ watchFor: open.watchFor ?? '',
125
+ emphasis: open.emphasis ?? '',
126
+ caption: open.caption ?? '',
127
+ }
128
+ return open.transitionOut === undefined
129
+ ? beat
130
+ : { ...beat, transitionOut: open.transitionOut }
131
+ }
132
+
133
+ function normalizeLabel(label: string): string {
134
+ return label.trim().toLowerCase().replace(/\s+/g, ' ')
135
+ }