@alexkroman1/aai-cli 6.10.0 → 6.11.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 (29) hide show
  1. package/dist/scaffold/CLAUDE.md +58 -0
  2. package/dist/scaffold/package.json +3 -3
  3. package/dist/scaffold/server.mjs +12 -3
  4. package/dist/scaffold/vite.config.ts +1 -1
  5. package/dist/templates/call-audit/agent.test.ts +965 -0
  6. package/dist/templates/call-audit/agent.ts +158 -0
  7. package/dist/templates/call-audit/client.tsx +235 -0
  8. package/dist/templates/call-audit/workflows/audit.ts +305 -0
  9. package/dist/templates/call-audit/workflows/ingest.ts +259 -0
  10. package/dist/templates/call-audit/workflows/media.ts +647 -0
  11. package/dist/templates/call-audit/workflows/summarize.ts +206 -0
  12. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  13. package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
  14. package/dist/templates/recap-workflow/agent.test.ts +11 -3
  15. package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
  16. package/dist/templates/spoken-summary/agent.test.ts +343 -0
  17. package/dist/templates/spoken-summary/agent.ts +142 -0
  18. package/dist/templates/spoken-summary/client.tsx +225 -0
  19. package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
  20. package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
  21. package/dist/templates/transcription-workflow/agent.test.ts +241 -18
  22. package/dist/templates/transcription-workflow/agent.ts +20 -6
  23. package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
  24. package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
  25. package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
  26. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  27. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
  28. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
  29. package/package.json +3 -3
@@ -0,0 +1,206 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The two steps after the transcript: a model reads the call, and a voice reads
4
+ * the model back — through ffmpeg on the way out.
5
+ *
6
+ * ```text
7
+ * summarize one step, LLM Gateway → headline, risks, actions, script
8
+ * narrate one step, TTS + ffmpeg → an MP3, stored, and its id
9
+ * ```
10
+ *
11
+ * `spoken-summary` owns the audio ROUND TRIP and is the template to read for it:
12
+ * why `stepSpeak` exists at all (a `TtsSession` is an event stream wired into a
13
+ * live pipeline's playback, and a step has no turn to be part of and has to return
14
+ * a VALUE), why `writeUpload` is its other half, and why speaking and storing must
15
+ * be one step. None of that is restated here.
16
+ *
17
+ * **What this file adds is the pass AFTER the synthesis**, and it is the second
18
+ * half of what having a decoder in the pipeline buys. `stepSpeak` answers with a
19
+ * 24 kHz WAV, which is correct and is not a deliverable:
20
+ *
21
+ * - **It is uncompressed.** A 90-second summary is 4.3 MB, and the page downloads
22
+ * the whole thing through `api.download` before it can play a note of it. The
23
+ * same summary as VBR MP3 is ~110 KB, which is a fortieth.
24
+ * - **Its level is whatever the voice service chose.** Played straight after a
25
+ * recording this desk levelled to −16 LUFS, a summary at −24 sounds broken. The
26
+ * mastering pass puts both on the same scale, which is the whole reason to have
27
+ * one number for the desk rather than one per stage.
28
+ *
29
+ * So the audio the page plays has been through ffmpeg twice — once on the way in
30
+ * to make it analysable, once on the way out to make it shippable. `media.ts`'s
31
+ * `masterArgs` is the second, and it explains why that one is a SINGLE `loudnorm`
32
+ * pass where the ingest is two.
33
+ */
34
+
35
+ import { stat, writeFile } from "node:fs/promises";
36
+ import { join } from "node:path";
37
+ import { runFfmpeg } from "@alexkroman1/aai/ffmpeg";
38
+ import { throwStepError } from "@alexkroman1/aai/step-errors";
39
+ import {
40
+ omitUndefined,
41
+ report,
42
+ stepGenerateJson,
43
+ stepSpeak,
44
+ writeUpload,
45
+ } from "@alexkroman1/aai/utils";
46
+ import { z } from "zod";
47
+ import { classifyFfmpeg } from "./ingest.ts";
48
+ import { clock, masterArgs } from "./media.ts";
49
+ import { fileChunks, withTempDir } from "./temp-media.ts";
50
+
51
+ /** Risks the summary is reduced to. Enough to be useful, few enough to act on. */
52
+ const MAX_RISKS = 4;
53
+
54
+ /** Actions the summary is reduced to. */
55
+ const MAX_ACTIONS = 4;
56
+
57
+ /**
58
+ * Characters of transcript handed to the model.
59
+ *
60
+ * The pass-an-id-not-a-payload rule meeting a case where the payload IS the work:
61
+ * the text has to cross the queue between two steps, so it is bounded rather than
62
+ * trusted. 40k characters is roughly four hours of speech — past where another
63
+ * paragraph changes a four-point summary.
64
+ */
65
+ const MAX_TRANSCRIPT_CHARS = 40_000;
66
+
67
+ /** How long the mastering pass may run. Seconds of work; the bound is for a pathological input. */
68
+ const MASTER_TIMEOUT_MS = 5 * 60_000;
69
+
70
+ /**
71
+ * The shape the model must answer in, as something that CHECKS.
72
+ *
73
+ * `stepGenerateJson` validates against this and throws plainly when the reply
74
+ * misses, which is what a retry is for: a model that answered with prose may well
75
+ * obey on the next attempt.
76
+ */
77
+ const AuditReply = z.object({
78
+ headline: z.string().trim().min(1),
79
+ // Allowed to be EMPTY, unlike `spoken` below, and the asymmetry is deliberate: a
80
+ // call with nothing worrying in it is a real call, and a schema that demanded a
81
+ // risk would get an invented one. An empty array is an answer.
82
+ risks: z.array(z.string().trim().min(1)),
83
+ actions: z.array(z.string().trim().min(1)),
84
+ // NOT `.default("")` — the whole second half of this workflow has nothing to say
85
+ // without it, and a default would turn a missing field into a silent half-second
86
+ // of audio rather than a retry.
87
+ spoken: z.string().trim().min(1),
88
+ });
89
+
90
+ /** What the model made of the call. */
91
+ export type CallSummary = {
92
+ headline: string;
93
+ risks: string[];
94
+ actions: string[];
95
+ /** The same summary, written to be READ ALOUD — see {@link summarize}. */
96
+ spoken: string;
97
+ };
98
+
99
+ /**
100
+ * Reduce the transcript to a headline, the risks, the actions, and a script.
101
+ *
102
+ * **The model is asked for TWO summaries, and the difference is the point.**
103
+ * `risks`/`actions` are for reading and `spoken` is for hearing; a template that
104
+ * synthesized its own bullet list produces a voice reading "one. two. three." with
105
+ * no connective tissue. So the schema asks for a script as well, in sentences, and
106
+ * that is what {@link narrate} is handed. It is a decision a prompt alone does not
107
+ * hold, which is why the field is required rather than defaulted.
108
+ */
109
+ export async function summarize(
110
+ transcript: string,
111
+ source: string,
112
+ durationMs: number,
113
+ ): Promise<CallSummary> {
114
+ "use step";
115
+
116
+ await report("Reading the transcript.");
117
+ const reply = await stepGenerateJson(
118
+ `Audit this transcript of a recorded call (${source}, ${clock(durationMs)}).\n\n` +
119
+ "Answer with JSON only, in this shape:\n" +
120
+ `{"headline": "...", "risks": ["..."], "actions": ["..."], "spoken": "..."}\n\n` +
121
+ "- headline: one line naming what the call was about.\n" +
122
+ `- risks: at most ${MAX_RISKS} things a reader should worry about — a ` +
123
+ "commitment nobody owns, a number that was guessed at, a disagreement left " +
124
+ "unresolved. Quote or name the specifics. An EMPTY array if the call really " +
125
+ "had none; never invent one.\n" +
126
+ `- actions: at most ${MAX_ACTIONS} things somebody has to do next, each ` +
127
+ "naming who if the call named them.\n" +
128
+ "- spoken: the same audit written to be READ ALOUD. Full sentences that " +
129
+ "flow, under 150 words, no bullet markers, no headings, no markdown. " +
130
+ "Someone will hear this without seeing the lists.\n\n" +
131
+ `Transcript:\n${transcript.slice(0, MAX_TRANSCRIPT_CHARS)}`,
132
+ {
133
+ system: "You audit recorded calls. You answer with JSON and nothing else.",
134
+ schema: AuditReply,
135
+ },
136
+ // Classified off the gateway's own status: a 429 is worth another attempt and a
137
+ // 400 is not, and `throwStepError` is what tells the DevKit which.
138
+ ).catch(throwStepError);
139
+
140
+ await report(
141
+ `Found ${reply.risks.length} risk${reply.risks.length === 1 ? "" : "s"} and ` +
142
+ `${reply.actions.length} action${reply.actions.length === 1 ? "" : "s"}.`,
143
+ );
144
+ return {
145
+ headline: reply.headline,
146
+ risks: reply.risks.slice(0, MAX_RISKS),
147
+ actions: reply.actions.slice(0, MAX_ACTIONS),
148
+ spoken: reply.spoken,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Read the audit aloud, master it, store it, and answer with its id.
154
+ *
155
+ * **All four halves belong in ONE step**, and the reason is what a journal
156
+ * records: a step is replayed by its RETURN VALUE, so an id is replayed and bytes
157
+ * are not. Split in two, the audio would have to cross the queue between them —
158
+ * megabytes of it, on every resume — and the temp file the mastering pass needs
159
+ * cannot cross a step boundary at all (see `temp-media.ts`). Together, a resumed
160
+ * run replays the id and re-reads a file that is already there.
161
+ *
162
+ * The cost is that a retried step writes a second upload and abandons the first.
163
+ * Cheap next to a step that cannot retry.
164
+ */
165
+ export async function narrate(
166
+ script: string,
167
+ voice?: string,
168
+ ): Promise<{ audio: string; durationMs: number; bytes: number }> {
169
+ "use step";
170
+
171
+ const spoken = await stepSpeak(script, omitUndefined({ voice }));
172
+
173
+ return await withTempDir(async (dir) => {
174
+ const wav = join(dir, "spoken.wav");
175
+ const mp3 = join(dir, "summary.mp3");
176
+
177
+ // `writeFile` rather than a stream, and this is the one place in the template
178
+ // where holding the whole thing in memory is right: `stepSpeak` already
179
+ // returned it as a single `Uint8Array`, so streaming it to disk would be
180
+ // copying from the heap to the heap on the way. It is bounded by the script,
181
+ // which the schema keeps under 150 words.
182
+ await writeFile(wav, spoken.audio);
183
+
184
+ await runFfmpeg(masterArgs(wav, mp3), { timeoutMs: MASTER_TIMEOUT_MS }).catch(classifyFfmpeg);
185
+ const bytes = (await stat(mp3)).size;
186
+
187
+ const stored = await writeUpload(fileChunks(mp3), {
188
+ // Named, because this is what a person sees on the download link rather than
189
+ // an opaque id — and typed, because the byte route serves the type it was
190
+ // given and a browser will not play a file it was handed as bytes.
191
+ name: "audit.mp3",
192
+ type: "audio/mpeg",
193
+ });
194
+
195
+ await report(
196
+ `Recorded a ${Math.round(spoken.durationMs / 1000)}s audit in ${spoken.voice}'s voice — ` +
197
+ `${kb(bytes)} of MP3, from ${kb(spoken.audio.byteLength)} of WAV.`,
198
+ );
199
+ return { audio: stored.id, durationMs: spoken.durationMs, bytes };
200
+ });
201
+ }
202
+
203
+ /** A size a person can read, in the unit this file's output actually lands in. */
204
+ function kb(bytes: number): string {
205
+ return `${Math.round(bytes / 1024)} KB`;
206
+ }
@@ -0,0 +1,44 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The one transcription request, and its classification.
4
+ *
5
+ * No directive, so it sits under `workflows/` untransformed and is called FROM a
6
+ * step, inheriting its environment. It is its own module for the same reason
7
+ * `transcription-workflow` has one: `stepTranscribeSync` is the SDK's — the URL,
8
+ * the raw-key auth (no `Bearer`, which is a 401 that reads like a wrong key), the
9
+ * multipart shape, the deadline and the three-way failure verdict all live there —
10
+ * so what is left at the call site is the `.catch` that hands the verdict to the
11
+ * DevKit, and that belongs somewhere a spec can reach it.
12
+ */
13
+
14
+ import { throwStepError } from "@alexkroman1/aai/step-errors";
15
+ import { stepTranscribeSync } from "@alexkroman1/aai/utils";
16
+
17
+ /**
18
+ * Transcribe one complete WAV.
19
+ *
20
+ * `bytes` must be a whole file, header included — the endpoint decodes each
21
+ * request independently, so a headerless span is bytes it will refuse. This desk
22
+ * stores headerless PCM on purpose (see `media.ts`) and puts a header back with
23
+ * `encodeWav` for exactly this call.
24
+ *
25
+ * `.catch(throwStepError)` is the whole of what this adds, and it is where the
26
+ * three-way call is made: a `FatalError` stops the DevKit retrying something that
27
+ * will answer the same way, a bare `RetryableError` retries in ONE SECOND (that
28
+ * class's own default), and a `RetryableError` carrying `retryAfter` waits exactly
29
+ * as long as the far side asked. The last matters here because a whole fan-out
30
+ * hits a rate limit together — a second later all of them ask again, where on the
31
+ * server's number they drain.
32
+ *
33
+ * @param label - How this piece is named in a failure. The CALLER's vocabulary (a
34
+ * segment's timestamp), because it is what a reader of the log has in front of
35
+ * them.
36
+ */
37
+ export async function transcribeSpan(
38
+ bytes: Uint8Array,
39
+ filename: string,
40
+ label: string,
41
+ ): Promise<string> {
42
+ const { text } = await stepTranscribeSync(bytes, { filename, label }).catch(throwStepError);
43
+ return text;
44
+ }
@@ -0,0 +1,138 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Moving bytes between the upload store and a local file, which is what an
4
+ * ffmpeg step spends most of its lines on.
5
+ *
6
+ * No directive, so it sits under `workflows/` untransformed and is called FROM
7
+ * steps, inheriting their environment. It exists because both ffmpeg steps in
8
+ * this template need the same three things and the third one is the one that is
9
+ * easy to get wrong.
10
+ *
11
+ * ## Why a temp file at all
12
+ *
13
+ * `@alexkroman1/aai/ffmpeg` takes bytes as happily as a path, and for a short
14
+ * clip bytes are the better call. This desk uses paths, for two reasons that are
15
+ * both properties of real recordings rather than preferences:
16
+ *
17
+ * - **A pipe cannot seek.** An `.m4a` off a phone usually carries its `moov`
18
+ * index at the END of the file, so ffmpeg reading it from `pipe:0` fails with
19
+ * `moov atom not found`. That is the flagship input.
20
+ * - **Piped output is capped**, at `DEFAULT_MAX_FFMPEG_OUTPUT_BYTES` (64 MiB),
21
+ * which is about half an hour of this desk's 16 kHz mono PCM. The desk exists
22
+ * for the two-hour call.
23
+ *
24
+ * ## A temp file may not outlive its step
25
+ *
26
+ * A step is journaled by its RETURN VALUE and may be dispatched into a different
27
+ * process than its neighbours, so a path in a return value is a path that is
28
+ * replayed after the file behind it is gone — and the failure mode is a resumed
29
+ * run reading a directory that another run is using. {@link withTempDir} makes
30
+ * the lifetime a lexical scope: the directory is created on entry, removed on
31
+ * exit, and what crosses the step boundary is an upload id.
32
+ */
33
+
34
+ import { mkdtemp, open, rm } from "node:fs/promises";
35
+ import { tmpdir } from "node:os";
36
+ import { join } from "node:path";
37
+ import { readUpload } from "@alexkroman1/aai/utils";
38
+
39
+ /**
40
+ * Bytes moved per `readUpload`, and per write.
41
+ *
42
+ * Large enough that a two-hour recording is a few hundred round trips rather
43
+ * than tens of thousands, and small enough that a step's resident set is a
44
+ * constant rather than a function of the recording. The number this must NOT be
45
+ * is "the whole file", which is the shape every first draft has.
46
+ */
47
+ export const WINDOW_BYTES = 8 * 1024 * 1024;
48
+
49
+ /**
50
+ * Run `work` with a private temp directory, and remove it afterwards.
51
+ *
52
+ * `join(tmpdir(), …)` rather than a `/tmp` literal, which is this repo's rule
53
+ * (`guard-invariants` rule 11) and not merely portability theatre: on Windows a
54
+ * literal `/tmp/x` is DRIVE-RELATIVE, so it resolves somewhere that does not
55
+ * exist and every write fails with ENOENT. A step runs in a Linux guest when it
56
+ * is deployed and on the developer's own machine under `aai dev`, which is the
57
+ * half that makes it matter.
58
+ *
59
+ * The removal is in a `finally`, so it also runs on the failure paths — a guest's
60
+ * disk is small, and a step that leaves a copy of every recording it touched
61
+ * fills it. `force` so a run that never created its output does not fail HERE and
62
+ * replace the real error with this one.
63
+ */
64
+ export async function withTempDir<T>(work: (dir: string) => Promise<T>): Promise<T> {
65
+ const dir = await mkdtemp(join(tmpdir(), "aai-call-audit-"));
66
+ try {
67
+ return await work(dir);
68
+ } finally {
69
+ await rm(dir, { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Write an upload to a local path, a window at a time.
75
+ *
76
+ * A `for` loop rather than a fan-out deliberately: the bytes land in one file at
77
+ * one offset each, so concurrency buys nothing and costs exactly the memory the
78
+ * windows are here to bound.
79
+ *
80
+ * `windowBytes` defaults to {@link WINDOW_BYTES}; see {@link fileChunks} for why
81
+ * it is a parameter at all.
82
+ */
83
+ export async function materializeUpload(
84
+ uploadId: string,
85
+ size: number,
86
+ path: string,
87
+ windowBytes: number = WINDOW_BYTES,
88
+ ): Promise<void> {
89
+ const handle = await open(path, "w");
90
+ try {
91
+ for (let at = 0; at < size; at += windowBytes) {
92
+ const slice = await readUpload(uploadId, {
93
+ start: at,
94
+ end: Math.min(at + windowBytes, size),
95
+ });
96
+ await handle.write(slice.bytes);
97
+ }
98
+ } finally {
99
+ await handle.close();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * A local file as the stream `writeUpload` takes.
105
+ *
106
+ * A generator rather than `readFile`, for the same reason the windows above
107
+ * exist: the normalized PCM is the largest thing this desk touches, and handing
108
+ * the store an `AsyncIterable` is what keeps it off the heap.
109
+ *
110
+ * **The `.slice()` is load-bearing.** One buffer is reused across reads, so
111
+ * yielding a view of it hands the consumer memory the next read overwrites — a
112
+ * bug whose symptom is a stored file made of the LAST chunk repeated, and which
113
+ * does not reproduce whenever the consumer happens to copy before the next
114
+ * iteration.
115
+ *
116
+ * `windowBytes` is a parameter with a default for exactly that reason, and it is
117
+ * the one testability seam in this template. The aliasing bug above only manifests
118
+ * across MULTIPLE reads, so at the real 8 MiB window a spec would have to write a
119
+ * 16 MB file to reach it — and a first draft of that spec used 200 KB, passed with
120
+ * the `.slice()` deleted, and would have shipped a test proving nothing. A small
121
+ * window makes the multi-chunk path a few kilobytes instead.
122
+ */
123
+ export async function* fileChunks(
124
+ path: string,
125
+ windowBytes: number = WINDOW_BYTES,
126
+ ): AsyncIterable<Uint8Array> {
127
+ const handle = await open(path, "r");
128
+ try {
129
+ const buffer = new Uint8Array(windowBytes);
130
+ for (;;) {
131
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
132
+ if (bytesRead === 0) return;
133
+ yield buffer.subarray(0, bytesRead).slice();
134
+ }
135
+ } finally {
136
+ await handle.close();
137
+ }
138
+ }
@@ -408,13 +408,21 @@ describe("submitRecording", () => {
408
408
 
409
409
  const call = calls[0];
410
410
  expect(call?.init.method).toBe("POST");
411
+ // `speaker_labels` is this desk's own request, carried through the SDK's
412
+ // `params` passthrough; the model field is the SDK's and is PLURAL.
411
413
  expect(JSON.parse(String(call?.init.body))).toMatchObject({
412
414
  audio_url: "https://example.com/a.mp3",
415
+ speaker_labels: true,
413
416
  });
414
- // AssemblyAI's `authorization` takes the key RAW — no `Bearer` prefix,
415
- // unlike the OpenAI-compatible LLM gateway `summarize` calls.
417
+ // AssemblyAI takes the key RAW — no `Bearer` prefix, unlike the
418
+ // OpenAI-compatible LLM gateway `summarize` calls. The SDK spells the
419
+ // header `Authorization`; HTTP header names are case-insensitive, so the
420
+ // lookup is too rather than pinning one casing.
416
421
  const headers = call?.init.headers as Record<string, string> | undefined;
417
- expect(headers?.authorization).toBe("sk-test");
422
+ const auth = Object.entries(headers ?? {}).find(
423
+ ([name]) => name.toLowerCase() === "authorization",
424
+ );
425
+ expect(auth?.[1]).toBe("sk-test");
418
426
  });
419
427
 
420
428
  test("fails FATALLY on a bad key and plainly on a rate limit", async () => {
@@ -76,6 +76,7 @@ import {
76
76
  requireStepEnv,
77
77
  stepFetch,
78
78
  stepGenerateJson,
79
+ stepTranscribeSubmit,
79
80
  } from "@alexkroman1/aai/utils";
80
81
  import { createHook, FatalError, sleep } from "workflow";
81
82
  import { z } from "zod";
@@ -358,14 +359,13 @@ export async function submitRecording(url: string): Promise<{ id: string }> {
358
359
 
359
360
  await report(`Submitting ${new URL(url).hostname} for transcription…`);
360
361
 
361
- const response = await request(TRANSCRIPT_ENDPOINT, {
362
- method: "POST",
363
- body: JSON.stringify({ audio_url: url, speaker_labels: true }),
364
- });
365
- const body = await response.json();
366
- const id = readString(body, "id");
367
- if (!id) throw new Error("The provider accepted the recording but named no transcript id.");
368
- return { id };
362
+ // `stepTranscribeSubmit` owns the endpoint, the raw-key auth, the PLURAL
363
+ // `speech_models` field and the failure classification. `speaker_labels` is
364
+ // this desk's own request, which is what `params` is for — the async API's
365
+ // surface is large and the SDK deliberately does not mirror it.
366
+ return await stepTranscribeSubmit(url, { params: { speaker_labels: true } }).catch(
367
+ throwStepError,
368
+ );
369
369
  }
370
370
 
371
371
  /**
@@ -374,6 +374,17 @@ export async function submitRecording(url: string): Promise<{ id: string }> {
374
374
  * One poll is one step, so each attempt is journaled on its own: a run that dies
375
375
  * mid-wait resumes knowing what the last answer was instead of starting the
376
376
  * recording over.
377
+ *
378
+ * **Deliberately NOT `stepTranscribePoll`, though its sibling above did move to
379
+ * the SDK.** That helper answers `done` and THROWS on a job the provider gave
380
+ * up on, which is the right shape for a flow whose only question is "is the
381
+ * text ready". This desk's question is different: `status` is a VALUE here,
382
+ * read by the Query port (`recap_status`) while the run is still going, and an
383
+ * `error` status is the branch that unwinds the saga's compensation stack
384
+ * rather than a failure to propagate. Converting this would trade a documented
385
+ * state machine — the thing this template is actually about — for a throw.
386
+ * The provider's status union is the template's subject, so it stays in the
387
+ * template.
377
388
  */
378
389
  export async function checkTranscript(id: string): Promise<TranscriptState> {
379
390
  "use step";