@hraness/dawg 0.3.0 → 0.4.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 (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +280 -278
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1119 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +281 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -0,0 +1,501 @@
1
+ /**
2
+ * The audition controller: a looping preview of the focused track, and the
3
+ * staged edits it plays. Menus, pickers and `/try` call it; it owns no keys
4
+ * of its own beyond what `auditionKey` maps, so any screen can plug it in.
5
+ *
6
+ * Space start or stop the loop (the track solo, or in context)
7
+ * a A/B: the committed sound (A) or the staged one (B)
8
+ * c context: the track alone or the full mix with it
9
+ * Enter keep: every staged edit becomes ONE score operation
10
+ * Esc revert the staged edits (then back, as usual)
11
+ *
12
+ * References: Ableton's browser preview and hot-swap (every hovered item is
13
+ * heard on the loop; Esc puts the old one back), Elektron's compare and
14
+ * reload (A/B against the saved kit, one key to restore it), Bitwig's
15
+ * preset audition (the track's own notes, or a phrase when it has none),
16
+ * and the OP-1/Torso habit of never stopping the sound between nudges.
17
+ *
18
+ * Staged edits are prompt commands replayed on top of the committed score,
19
+ * so a staged change and a committed one are made by exactly the same code.
20
+ * When another window commits meanwhile, `rebase` replays the commands on
21
+ * its score (the staged values win on the rows they touch, like a later
22
+ * edit would), and reports commands that no longer apply.
23
+ *
24
+ * Renders go through the host's `play`, which is the ordinary engine and
25
+ * render worker with its stem cache: only the edited track's stem is
26
+ * re-rendered, and the engine swaps the new loop in place at the same
27
+ * position. At most one render is in flight; a held key's repeats coalesce
28
+ * into the newest value, and a burst inside `debounceMs` renders once on
29
+ * its trailing edge.
30
+ */
31
+ import type { TrackScore } from "../../core/score.ts";
32
+ import {
33
+ meterBar,
34
+ previewScore,
35
+ type Preview,
36
+ type SoundLevel,
37
+ } from "../audio/preview.ts";
38
+
39
+ /** What applying one command to a score (without committing) produced. */
40
+ export type StageResult = Readonly<{
41
+ /** The new score; undefined when the command changed nothing or failed. */
42
+ next?: TrackScore | undefined;
43
+ ok: boolean;
44
+ message: string;
45
+ }>;
46
+
47
+ export type AuditionHost = {
48
+ /** Apply one prompt command to `base` without committing it. */
49
+ apply(base: TrackScore, command: string): Promise<StageResult>;
50
+ /** Start the loop, or swap the playing one in place. */
51
+ play(score: TrackScore): Promise<void>;
52
+ stop(): void;
53
+ /** How far ahead of now new audio is queued (the engine lead). */
54
+ leadMs(): number;
55
+ now(): number;
56
+ /** Playhead or cursor beat, to pick the bars a long song loops. */
57
+ beat?(): number;
58
+ setTimer?(callback: () => void, ms: number): unknown;
59
+ clearTimer?(handle: unknown): void;
60
+ /** Called after any state change the screen should redraw for. */
61
+ changed?(): void;
62
+ /** Level of the loop now playing, for the title's meter. */
63
+ level?(): SoundLevel | undefined;
64
+ };
65
+
66
+ export type AuditionOptions = Readonly<{
67
+ /** Renders within this window of the last one wait for its trailing edge. */
68
+ debounceMs?: number;
69
+ }>;
70
+
71
+ /** A staged command; hovered items replace the previous hover of a kind. */
72
+ type Staged = Readonly<{ command: string; replaceKey?: string | undefined }>;
73
+
74
+ export type RebaseResult = Readonly<{
75
+ /** Commands that no longer apply to the new score and were dropped. */
76
+ dropped: readonly string[];
77
+ }>;
78
+
79
+ const DEFAULT_DEBOUNCE_MS = 40;
80
+ /** A stage still running after this long shows "fetching…". */
81
+ const FETCHING_AFTER_MS = 150;
82
+ /** The message of a hover skipped because a newer one replaced it. */
83
+ export const SUPERSEDED = "superseded";
84
+ const LATENCY_SAMPLES = 32;
85
+
86
+ export class Audition {
87
+ /** True while the loop plays. */
88
+ public looping = false;
89
+ /** Full mix with the track instead of the track alone. */
90
+ public context = false;
91
+ /** Which sound plays while edits are staged: committed (A) or staged (B). */
92
+ public showing: "A" | "B" = "B";
93
+ /** Key-to-hear of the most recent render: render + swap + engine lead. */
94
+ public lastLatencyMs: number | undefined;
95
+ /** What the loop played last (region, notes or a default phrase). */
96
+ public lastPreview: Preview | undefined;
97
+ public readonly latencies: number[] = [];
98
+
99
+ private committedScore: TrackScore;
100
+ private stagedScore: TrackScore | undefined;
101
+ private staged: Staged[] = [];
102
+ private work: Promise<unknown> = Promise.resolve();
103
+ private rendering = false;
104
+ private dirty = false;
105
+ private pendingKeyAt: number | undefined;
106
+ private lastRenderAt = -Infinity;
107
+ private timer: unknown;
108
+ private readonly debounceMs: number;
109
+ private readonly hoverSeq = new Map<string, number>();
110
+ private pendingStages = 0;
111
+ private pendingSince: number | undefined;
112
+ /** Renders actually started (coalesced repeats do not count). */
113
+ public renders = 0;
114
+
115
+ public constructor(
116
+ private readonly host: AuditionHost,
117
+ committed: TrackScore,
118
+ public trackId: string,
119
+ options: AuditionOptions = {},
120
+ ) {
121
+ this.committedScore = committed;
122
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
123
+ }
124
+
125
+ /** The committed score staged edits are made on. */
126
+ public get committed(): TrackScore {
127
+ return this.committedScore;
128
+ }
129
+
130
+ /** The staged score, or the committed one when nothing is staged. */
131
+ public get score(): TrackScore {
132
+ return this.stagedScore ?? this.committedScore;
133
+ }
134
+
135
+ /** What the loop plays now: A (committed) or B (staged). */
136
+ public get sounding(): TrackScore {
137
+ return this.showing === "A" ? this.committedScore : this.score;
138
+ }
139
+
140
+ public get dirtyEdits(): boolean {
141
+ return this.stagedScore !== undefined && this.staged.length > 0;
142
+ }
143
+
144
+ public get commands(): readonly string[] {
145
+ return this.staged.map((entry) => entry.command);
146
+ }
147
+
148
+ /** Edits stage (instead of committing) while the loop plays or some are staged. */
149
+ public get staging(): boolean {
150
+ return this.looping || this.dirtyEdits;
151
+ }
152
+
153
+ /** Resolves once every queued stage/rebase has finished. */
154
+ public settled(): Promise<void> {
155
+ return this.work.then(() => undefined);
156
+ }
157
+
158
+ // ── the loop ────────────────────────────────────────────────────────
159
+
160
+ public start(): void {
161
+ if (this.looping) return;
162
+ this.looping = true;
163
+ this.lastRenderAt = -Infinity;
164
+ this.request();
165
+ }
166
+
167
+ public stop(): void {
168
+ if (!this.looping) return;
169
+ this.looping = false;
170
+ this.dirty = false;
171
+ this.pendingKeyAt = undefined;
172
+ this.cancelTimer();
173
+ this.host.stop();
174
+ this.host.changed?.();
175
+ }
176
+
177
+ public toggle(): void {
178
+ if (this.looping) this.stop();
179
+ else this.start();
180
+ }
181
+
182
+ public toggleContext(): void {
183
+ this.context = !this.context;
184
+ this.request();
185
+ this.host.changed?.();
186
+ }
187
+
188
+ /** A/B; a no-op (stays on B) while nothing is staged. */
189
+ public toggleAB(): void {
190
+ if (!this.dirtyEdits) {
191
+ this.showing = "B";
192
+ return;
193
+ }
194
+ this.showing = this.showing === "A" ? "B" : "A";
195
+ this.request();
196
+ this.host.changed?.();
197
+ }
198
+
199
+ /** Focus moved to another track: staged edits stay with their track. */
200
+ public focus(trackId: string): void {
201
+ if (trackId === this.trackId) return;
202
+ this.trackId = trackId;
203
+ this.request();
204
+ }
205
+
206
+ // ── staging ─────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Stage `command` on top of the staged score. With `replaceKey`, a
210
+ * previous staged command with the same key is replaced instead (moving
211
+ * through a picker auditions one item at a time, not a stack of them).
212
+ */
213
+ public stage(
214
+ command: string,
215
+ options: { replaceKey?: string; superseded?: () => boolean } = {},
216
+ ): Promise<StageResult> {
217
+ const keyAt = this.host.now();
218
+ if (this.pendingStages++ === 0) this.pendingSince = keyAt;
219
+ const run = this.work.then(async (): Promise<StageResult> => {
220
+ if (options.superseded?.()) return { ok: false, message: SUPERSEDED };
221
+ const replaceKey = options.replaceKey;
222
+ const replacing =
223
+ replaceKey !== undefined &&
224
+ this.staged.some((entry) => entry.replaceKey === replaceKey);
225
+ if (replacing) {
226
+ const kept = this.staged.filter(
227
+ (entry) => entry.replaceKey !== replaceKey,
228
+ );
229
+ const replay = await this.replay(this.committedScore, kept);
230
+ const result = await this.host.apply(replay.score, command);
231
+ if (options.superseded?.()) return { ok: false, message: SUPERSEDED };
232
+ if (!result.ok) return result;
233
+ this.staged = [...replay.kept, { command, replaceKey }];
234
+ this.stagedScore = result.next ?? replay.score;
235
+ } else {
236
+ const result = await this.host.apply(this.score, command);
237
+ if (options.superseded?.()) return { ok: false, message: SUPERSEDED };
238
+ if (!result.ok || !result.next) return result;
239
+ this.staged.push({ command, replaceKey });
240
+ this.stagedScore = result.next;
241
+ }
242
+ this.showing = "B";
243
+ this.request(keyAt);
244
+ this.host.changed?.();
245
+ return { ok: true, message: command, next: this.stagedScore };
246
+ });
247
+ const done = run.finally(() => {
248
+ if (--this.pendingStages === 0) this.pendingSince = undefined;
249
+ });
250
+ this.work = done.catch(() => undefined);
251
+ return done;
252
+ }
253
+
254
+ /**
255
+ * Audition a picker's highlighted item: it replaces the previous hover
256
+ * of `key`, and a hover overtaken by a newer one before it ran is
257
+ * skipped, so moving fast through a list (or a slow pack fetch) never
258
+ * queues a backlog: the latest item plays.
259
+ */
260
+ public hover(command: string, key: string): Promise<StageResult> {
261
+ const seq = (this.hoverSeq.get(key) ?? 0) + 1;
262
+ this.hoverSeq.set(key, seq);
263
+ return this.stage(command, {
264
+ replaceKey: key,
265
+ superseded: () => this.hoverSeq.get(key) !== seq,
266
+ });
267
+ }
268
+
269
+ /** Leave a picker without choosing: drop its hover, keep other edits. */
270
+ public unhover(key: string): Promise<void> {
271
+ this.hoverSeq.set(key, (this.hoverSeq.get(key) ?? 0) + 1);
272
+ const run = this.work.then(async () => {
273
+ if (!this.staged.some((entry) => entry.replaceKey === key)) return;
274
+ const replay = await this.replay(
275
+ this.committedScore,
276
+ this.staged.filter((entry) => entry.replaceKey !== key),
277
+ );
278
+ this.staged = replay.kept;
279
+ this.stagedScore = replay.kept.length ? replay.score : undefined;
280
+ this.showing = "B";
281
+ this.request();
282
+ this.host.changed?.();
283
+ });
284
+ this.work = run.catch(() => undefined);
285
+ return run;
286
+ }
287
+
288
+ /** Choosing a hovered item makes it an ordinary staged edit. */
289
+ public settle(key: string): void {
290
+ this.hoverSeq.set(key, (this.hoverSeq.get(key) ?? 0) + 1);
291
+ this.staged = this.staged.map((entry) =>
292
+ entry.replaceKey === key ? { command: entry.command } : entry,
293
+ );
294
+ }
295
+
296
+ /** A stage (a pack fetch, say) has been running long enough to show. */
297
+ public get fetching(): boolean {
298
+ return (
299
+ this.pendingSince !== undefined &&
300
+ this.host.now() - this.pendingSince >= FETCHING_AFTER_MS
301
+ );
302
+ }
303
+
304
+ /** Drop every staged edit; the loop plays the committed sound again. */
305
+ public revert(): void {
306
+ const had = this.dirtyEdits;
307
+ this.staged = [];
308
+ this.stagedScore = undefined;
309
+ this.showing = "B";
310
+ if (had) this.request();
311
+ this.host.changed?.();
312
+ }
313
+
314
+ /**
315
+ * Take the staged edits for one commit, leaving nothing staged. The caller
316
+ * commits `score` as one operation, then calls `committedNow` with it.
317
+ */
318
+ public take(): { score: TrackScore; commands: string[] } | undefined {
319
+ if (!this.dirtyEdits || !this.stagedScore) return undefined;
320
+ const taken = { score: this.stagedScore, commands: [...this.commands] };
321
+ this.staged = [];
322
+ this.stagedScore = undefined;
323
+ this.showing = "B";
324
+ return taken;
325
+ }
326
+
327
+ /** The committed score moved (this window's commit, undo, a reload). */
328
+ public committedNow(score: TrackScore): void {
329
+ if (score === this.committedScore) return;
330
+ this.committedScore = score;
331
+ if (!this.dirtyEdits) this.request();
332
+ }
333
+
334
+ /**
335
+ * Another window committed: replay the staged commands on its score.
336
+ * Commands that fail there are dropped and reported.
337
+ */
338
+ public rebase(score: TrackScore): Promise<RebaseResult> {
339
+ const run = this.work.then(async (): Promise<RebaseResult> => {
340
+ this.committedScore = score;
341
+ if (this.staged.length === 0) {
342
+ this.stagedScore = undefined;
343
+ this.request();
344
+ return { dropped: [] };
345
+ }
346
+ const replay = await this.replay(score, this.staged);
347
+ this.staged = replay.kept;
348
+ this.stagedScore = replay.kept.length ? replay.score : undefined;
349
+ if (!this.stagedScore) this.showing = "B";
350
+ this.request();
351
+ this.host.changed?.();
352
+ return { dropped: replay.dropped };
353
+ });
354
+ this.work = run.catch(() => undefined);
355
+ return run;
356
+ }
357
+
358
+ private async replay(
359
+ base: TrackScore,
360
+ entries: readonly Staged[],
361
+ ): Promise<{ score: TrackScore; kept: Staged[]; dropped: string[] }> {
362
+ let score = base;
363
+ const kept: Staged[] = [];
364
+ const dropped: string[] = [];
365
+ for (const entry of entries) {
366
+ const result = await this.host.apply(score, entry.command);
367
+ if (!result.ok) {
368
+ dropped.push(entry.command);
369
+ continue;
370
+ }
371
+ kept.push(entry);
372
+ if (result.next) score = result.next;
373
+ }
374
+ return { score, kept, dropped };
375
+ }
376
+
377
+ // ── rendering ───────────────────────────────────────────────────────
378
+
379
+ /** Re-render the loop (when it plays), debounced and coalesced. */
380
+ public request(keyAt = this.host.now()): void {
381
+ if (!this.looping) return;
382
+ this.pendingKeyAt ??= keyAt;
383
+ if (this.rendering) {
384
+ this.dirty = true;
385
+ return;
386
+ }
387
+ const wait = this.lastRenderAt + this.debounceMs - this.host.now();
388
+ if (wait > 0 && this.host.setTimer) {
389
+ if (this.timer === undefined)
390
+ this.timer = this.host.setTimer(() => {
391
+ this.timer = undefined;
392
+ void this.flush();
393
+ }, wait);
394
+ return;
395
+ }
396
+ void this.flush();
397
+ }
398
+
399
+ private cancelTimer(): void {
400
+ if (this.timer !== undefined) this.host.clearTimer?.(this.timer);
401
+ this.timer = undefined;
402
+ }
403
+
404
+ private async flush(): Promise<void> {
405
+ if (!this.looping || this.rendering) return;
406
+ this.cancelTimer();
407
+ const preview = previewScore(this.sounding, this.trackId, {
408
+ context: this.context,
409
+ beat: this.host.beat?.() ?? 0,
410
+ });
411
+ if (!preview) return;
412
+ this.rendering = true;
413
+ this.dirty = false;
414
+ const keyAt = this.pendingKeyAt;
415
+ this.pendingKeyAt = undefined;
416
+ this.lastRenderAt = this.host.now();
417
+ this.renders += 1;
418
+ this.lastPreview = preview;
419
+ try {
420
+ await this.host.play(preview.score);
421
+ if (keyAt !== undefined && this.looping) {
422
+ const latency = this.host.now() - keyAt + this.host.leadMs();
423
+ this.lastLatencyMs = latency;
424
+ this.latencies.push(latency);
425
+ if (this.latencies.length > LATENCY_SAMPLES) this.latencies.shift();
426
+ }
427
+ } catch {
428
+ // A failed render keeps the previous loop; the next edit retries.
429
+ } finally {
430
+ this.rendering = false;
431
+ }
432
+ this.host.changed?.();
433
+ if (this.dirty && this.looping) this.request(this.pendingKeyAt);
434
+ }
435
+
436
+ // ── what the screen shows ───────────────────────────────────────────
437
+
438
+ /**
439
+ * One status line: `♪ solo · B staged 2 · 42 ms`, or undefined when the
440
+ * loop is off and nothing is staged.
441
+ */
442
+ public status(): string | undefined {
443
+ if (!this.looping && !this.dirtyEdits && !this.fetching) return undefined;
444
+ const parts: string[] = [];
445
+ if (this.looping) parts.push(`♪ ${this.context ? "in context" : "solo"}`);
446
+ else parts.push("loop off");
447
+ if (this.fetching) parts.push("fetching…");
448
+ if (this.dirtyEdits)
449
+ parts.push(
450
+ this.showing === "A" ? "A committed" : `B staged ${this.staged.length}`,
451
+ );
452
+ if (this.looping && this.lastLatencyMs !== undefined)
453
+ parts.push(`${Math.round(this.lastLatencyMs)} ms`);
454
+ const level = this.looping ? this.host.level?.() : undefined;
455
+ if (level) parts.push(levelMeter(level));
456
+ return parts.join(" · ");
457
+ }
458
+
459
+ /** Footer keys for an auditioning screen (fits 80 columns). */
460
+ public hint(): string {
461
+ const loop = this.looping ? "space stop" : "space hear";
462
+ return this.dirtyEdits
463
+ ? ` ${loop} · a A/B · c context · enter keep · esc revert · ? keys `
464
+ : ` ${loop} · ←→ adjust · c context · esc back · ? keys `;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * `█████··· -9 dB`: RMS as an 8-cell bar over -48..0 dBFS, the peak as a
470
+ * number, and `!` in the last cell when the loop clips.
471
+ */
472
+ export function levelMeter(level: SoundLevel): string {
473
+ const peak = level.peakDb <= -119 ? "-∞" : String(Math.round(level.peakDb));
474
+ return `${meterBar(level.rmsDb, 8, level.clipped > 0)} ${peak} dB`;
475
+ }
476
+
477
+ /** What an audition key does in a screen that hosts the controller. */
478
+ export type AuditionKey = "loop" | "ab" | "context";
479
+
480
+ /** Space, `a` and `c`; undefined for every other key. */
481
+ export function auditionKey(value: string): AuditionKey | undefined {
482
+ if (value === " ") return "loop";
483
+ if (value === "a") return "ab";
484
+ if (value === "c") return "context";
485
+ return undefined;
486
+ }
487
+
488
+ /**
489
+ * Commands that change how the focused track sounds, and so stage while
490
+ * auditioning; everything else (transport, notes, tracks, tempo) runs as
491
+ * before.
492
+ */
493
+ const STAGEABLE =
494
+ /^\/?(?:(fx|effects|filter|lowpass|synth|wt|wavetable|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern)\s+\S|pack\s+use\s+\S)/i;
495
+ /** Subcommands that list or show instead of changing the sound. */
496
+ const READ_ONLY = /^\/?\S+\s+(list|show|info|help)\s*$/i;
497
+
498
+ export function isStageable(command: string): boolean {
499
+ const text = command.trim();
500
+ return STAGEABLE.test(text) && !READ_ONLY.test(text);
501
+ }