@aicopycoders/exodus 2026.9.1900-beta.1 → 2026.9.2000-beta.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.
@@ -61,7 +61,7 @@ These are the **Exodus** pipelines — the automatic system that drives the Gene
61
61
  | **Meme** | `exodus-meme` | Meme ads — recommends formats for a brief, then one batched run renders AI-image and classic Imgflip-template memes server-side. |
62
62
  | **Workflow** | `exodus-workflow` | Run and build saved multi-node workflow automations from the CLI, resolve checkpoint/repair parks from the inbox, continue bot sessions, fire triggers, and read banks or promote winners. |
63
63
  | **Hooks** | `exodus-hooks` | Read the Scout library — the hook cards captured from Instagram outliers. Compose filters (score, lane, validation status, language, hook type, recency), inspect one card, find cards sharing a hook pattern, export to CSV. Read-only. |
64
- | **Video** | `exodus-video` | Make a video ad from a script. Main path: run a saved video workflow (`npx @aicopycoders/exodus workflow run`, no Show); starting from a Show (`video start --show`) is the other way in. Then read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, narration and per-scene voice, keyframes, cast stills, word timings, music bed) plus a manifest, cut them in whatever editor you use (a bundled script is one option), upload the cut and approve it. Admin-only. |
64
+ | **Video** | `exodus-video` | Make a video ad from a script. Main path: run a saved video workflow (`npx @aicopycoders/exodus workflow run`, no Show); starting from a Show (`video start --show`) is the other way in. Choose the voices as the run starts: `--voices` gives each speaker an ElevenLabs voice, `--voice-treatment` says how the voices are made (including having the video model speak a voice you wrote). Then read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, narration and per-scene voice, keyframes, cast stills, word timings, music bed) plus a manifest, cut them in whatever editor you use (a bundled script is one option), upload the cut and approve it. Admin-only. |
65
65
  | **Browse** | `exodus-browse` | View history and retrieve past outputs |
66
66
  | **Drive** | `exodus-drive` | Read/write Google Docs, Sheets, and Drive files via `npx @aicopycoders/exodus drive` (uses the dashboard's Google OAuth — no local CLI) |
67
67
  | **Swipe / Mining** | (no skill; CLI only) | The competitor watch list behind the Mining page: `npx @aicopycoders/exodus swipe brands list`, `swipe brands bulk-import <csv>` (checks each brand and starts collecting its ads), `swipe brands add "<Name>" --fb <pageId>` (adds one brand and starts checking it the same way), `swipe run` / `swipe mine`. There is no `competitors` verb. |
@@ -215,6 +215,13 @@ Representative commands:
215
215
  exodus session list | show | chat
216
216
  exodus bank list | show <key> | promote <key>
217
217
  Every workflow/session/bank verb takes --json — that output is the machine API.
218
+
219
+ A video workflow whose Rig box points at a rig with saved format rules follows
220
+ them — how many speakers the script may have, which checks run, how the cast is
221
+ built. The start receipt and `workflow status` name the rulebook and its version;
222
+ `video pull` writes the same facts into manifest.json. Every Rig box in the
223
+ workflow has to point at the same saved rig, or the launch refuses and nothing
224
+ is spent.
218
225
  ```
219
226
 
220
227
  ---
@@ -247,10 +254,14 @@ Returns: a table, --json data, or an exported CSV/JSON file
247
254
 
248
255
  **What it does:** Makes a video ad from a script, in pieces. Main path is a saved video workflow with no Show (`exodus workflow run <name> --input <field>=@script.txt`); a locked Show (`exodus video start --show`) is the other way in and is the only route with `video flag`. On the workflow route the run stops for your cut only if its video node sets `finalWatch: true` (defaults to off — check with `exodus workflow export`, not `describe`), and video gates are approved with `exodus video approve`, not `workflow checkpoint approve`. Either way the run writes the storyboard, draws a picture per scene, records the voice, renders a clip per scene and generates a music bed, then parks for a cut. The CLI pulls the pieces to a folder with a `manifest.json`; you cut them in whatever editor you use; the upload attaches the cut for approval. Admin-only.
249
256
 
257
+ **Voices, on the workflow route.** Two separate choices, both made when the run starts and both refused before the run is created if anything is wrong, so a mistake costs nothing. `--voices @voices.json` says WHICH ElevenLabs voice each speaker gets, keyed by the names the script uses (`HOST 1`) — the same file `exodus video voices <run> --from` reads at the review, so one file per brand serves both moments. `--voice-treatment` says HOW the voices are made: a name on its own for a way of working that plays a voice you picked, or a `voice-treatment.json` shaped `{"path": "...", "describe": {"HOST 1": "Dry, tired baritone."}}` for the ways where the video model speaks the CAST's lines itself in voices you WROTE. A written voice needs one description per speaker, and it cannot be changed at the storyboard review — that is what the flag at launch is for. The two never combine: written voices plus `--voices` is refused, because those ElevenLabs voices would be paid for and never heard. A written voice covers the CAST's lines only: a narrated scene is still recorded by ElevenLabs on the member's own key, so a script with `NARRATOR:` turns still needs the key and still costs ElevenLabs money. Ways of working beyond the two shipped ones are switched on only on the test stack while they are being tried out.
258
+
250
259
  ```operator-guide
251
260
  Start (workflow route, no Show):
252
261
  exodus workflow list / describe <name> find it and see what it needs
253
262
  exodus workflow run <workflowId|name> --input <field>=@script.txt [--wait] start it; --wait stops at the storyboard gate
263
+ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json ... and give each speaker an ElevenLabs voice up front
264
+ exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json ... or have the video model speak voices you WROTE
254
265
  Start (Show route):
255
266
  exodus video shows [--json] Shows and whether each is ready
256
267
  exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
@@ -77,6 +77,8 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
77
77
  start, stop at the storyboard gate
78
78
  exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json --wait
79
79
  same, with the brand's voices already chosen
80
+ exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
81
+ same, with the video model speaking voices you WROTE
80
82
  exodus video status <runId> where it is, per scene
81
83
  ```
82
84
 
@@ -145,9 +147,15 @@ minutes with "Still running after 60 minutes" and exit 0; that is not done, so
145
147
  check `status` before treating it as finished. Do not poll `status` in a tight
146
148
  loop; a full run is several minutes.
147
149
 
148
- **What costs money.** Starting the run creates the storyboard. Approving the
149
- storyboard is the cost gate: it releases the paid rendering of every picture,
150
- voice and clip. `shows`, `workflow list`, `workflow describe`, `video status`,
150
+ **What costs money.** Starting the run writes the storyboard and then draws the
151
+ reference, cast and scene pictures, and only then pauses. Each picture is a few
152
+ cents and each scene draws a few to choose from: about 25 cents on a small test,
153
+ and it can approach a dollar on a longer ad. Approving releases the expensive
154
+ part: the voices and the clips. So a run that has not paused yet is already
155
+ spending a little, and a run still drawing pictures is healthy. `exodus workflow
156
+ status` and `exodus video status` both print "This run will pause for your
157
+ approval once the frames are ready" on a run that is headed for the gate. Do not
158
+ cancel it because the pause has not arrived. `shows`, `workflow list`, `workflow describe`, `video status`,
151
159
  `storyboard` and `pull` are free reads; run them as often as you like. Never
152
160
  start a second run to "retry" without the user asking; flag the storyboard (Show
153
161
  runs) or tell them what failed instead.
@@ -155,7 +163,7 @@ runs) or tell them what failed instead.
155
163
  **`--auto-approve` does not cover either video gate.** It releases Checkpoint
156
164
  boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
157
165
  and the storyboard gate and the final watch carry no pause reason. An unattended
158
- `workflow run --auto-approve` still stops dead at the cost gate — which is the
166
+ `workflow run --auto-approve` still stops dead at the storyboard gate — which is the
159
167
  safe behaviour, since nothing should approve paid rendering or ship an uncut ad
160
168
  on its own.
161
169
 
@@ -256,6 +264,13 @@ CTA:
256
264
  THE SOFTGEL: Tap below and give your eyes ClearBlink.
257
265
  ```
258
266
 
267
+ **How many speakers you may have depends on the rig.** Three by default. A rig
268
+ that carries saved format rules sets its own number: an ensemble rig seats more,
269
+ a talking-head rig seats one. Over the number, the launch refuses before
270
+ anything is spent and says both figures ("4 speakers — this format supports 3").
271
+ The rulebook the run followed is on the start receipt, on `workflow status`, and
272
+ in `manifest.json` under `provenance`.
273
+
259
274
  ## Reading the storyboard gate
260
275
 
261
276
  `storyboard <runId>` prints one card per scene on both routes: who speaks, the
@@ -267,8 +282,9 @@ for the user, then ask one question.
267
282
  On a **workflow run** the question is approve or don't — there is no flag. If
268
283
  the storyboard is wrong, say so plainly and let the user decide between
269
284
  approving anyway, cancelling (`exodus workflow cancel <runId>`) and re-running
270
- with a better script, or changing the workflow. Approving is the cost gate, so
271
- do not approve a storyboard you have just told the user is wrong.
285
+ with a better script, or changing the workflow. Approving is what releases the
286
+ voices and the clips — the expensive part — so do not approve a storyboard you
287
+ have just told the user is wrong.
272
288
 
273
289
  On a **Show run** you can also send it back: `video flag <runId> --note "…"`.
274
290
  The note is read by the model that rewrites the storyboard, so make it concrete
@@ -357,6 +373,88 @@ written by a bot is refused with a pointer at the review-time command, because
357
373
  nobody knows the speaker names yet. When the storyboard arrives, the voices are
358
374
  already set and `exodus video voices <runId>` shows them.
359
375
 
376
+ **Choosing how the voices are made.** `--voice-treatment` on `workflow run` says
377
+ HOW a run makes its voices, and it is the only door on the workflow route. Two
378
+ kinds of answer.
379
+
380
+ A name on its own picks a way of working that plays a voice you already have,
381
+ and still pairs with `--voices`:
382
+
383
+ ```
384
+ exodus workflow run "Podcast Ad" --input script=@script.txt \
385
+ --voice-treatment lipsync-retarget --voices @voices.json
386
+ ```
387
+
388
+ The other kind has the video model speak the CAST's lines itself, in voices you
389
+ WRITE. Those take a `voice-treatment.json`, which is the whole request body:
390
+
391
+ ```json
392
+ {
393
+ "path": "native-prompt",
394
+ "describe": {
395
+ "BUDGET": "Dry, tired middle-aged baritone. Slight rasp. Deadpan, unhurried.",
396
+ "PIGGY BANK": "Bright quick alto, a little smug, crisp consonants."
397
+ }
398
+ }
399
+ ```
400
+
401
+ ```
402
+ exodus workflow run "Podcast Ad" --input script=@script.txt \
403
+ --voice-treatment @voice-treatment.json
404
+ ```
405
+
406
+ Every speaker in the script needs a description. One left out gets a voice the
407
+ model invents afresh on every clip, and that drift only shows up once the clips
408
+ are paid for, so the launch refuses instead.
409
+
410
+ **The narration is not part of this.** A describing treatment changes how the
411
+ CAST's lines are voiced. A scene with narration (`voText`) is still recorded by
412
+ ElevenLabs on the member's own key by the Voiceover step, exactly as it always
413
+ was — so a run with `NARRATOR:` turns still needs an ElevenLabs key, and still
414
+ costs ElevenLabs money. `exodus video voices <runId>` says which of the two
415
+ shapes a run is, and only claims nothing is sent to ElevenLabs when nothing is.
416
+
417
+ **What refuses, all before the run is created, so nothing is spent.**
418
+
419
+ - A treatment name that does not exist. The message lists the ones that do.
420
+ - A describing treatment with a speaker undescribed, a speaker the script does
421
+ not have, the same speaker described twice, or an empty description.
422
+ - Written voices plus `--voices`. The video model owns the voice on those runs,
423
+ so the ElevenLabs voices would be paid for and never heard.
424
+ - Descriptions on a treatment that plays voices you picked instead.
425
+ - A workflow whose script is written by a bot. Nobody knows the speaker names
426
+ yet, so there is nothing to describe.
427
+ - A workflow with no Video step, a Video step whose model cannot do what was
428
+ asked, or a Video step with nothing wired into its voiceover port when the
429
+ treatment needs one.
430
+ - No Google AI key on the account, for the treatment that renders through
431
+ Google directly.
432
+ - A rig that shows every line on screen as text. No clip speaks, so there is no
433
+ voice to treat.
434
+
435
+ **Still being tested.** Everything beyond the two shipped ways of working is
436
+ switched on only on the test stack while it is being tried out. On the live
437
+ stack those names refuse by name, and the message says which two to use
438
+ instead. That is a property of the stack, not of the workflow, so the same
439
+ saved workflow behaves differently on each — and a retry asks again rather than
440
+ copying, so a run retried after a treatment was switched off refuses.
441
+
442
+ **A sub-workflow chooses none of this.** When a Call box runs another workflow
443
+ inside this one, the child run carries no voice treatment: it renders the
444
+ shipped default, whatever the parent was started with. A treatment is a choice a
445
+ person made at a keyboard for one run, and nobody made it for the sub-workflow.
446
+
447
+ **Written voices cannot be changed at the review.** They are chosen when the run
448
+ starts. `exodus video voices <runId>` on a described run names the treatment and
449
+ prints each written voice, and says plainly that `--voice-treatment` at launch is
450
+ what changes them. Do not tell anyone to create a Show for this, and do not set a
451
+ voice path in node config — neither is a door.
452
+
453
+ **Where it shows up afterwards.** The launch receipt and `exodus workflow status`
454
+ both print one line naming the treatment and the speakers who got a written
455
+ voice. `manifest.json` from `exodus video pull` carries the same under
456
+ `provenance.voice`, and each cast entry carries its `voiceDescription`.
457
+
360
458
  **The JSON receipt** (`--json`), the same shape for reading and for setting:
361
459
 
362
460
  ```
@@ -365,12 +463,18 @@ runId
365
463
  changed[] the characters whose voice actually moved (set only)
366
464
  canChange false once the run has left the storyboard review
367
465
  whyNot why, in one sentence, when canChange is false
466
+ treatment.path which way of making voices this run froze, or null
467
+ treatment.kind "performance-then-conversion", "voiceover-then-lipsync" or
468
+ "render-owns-voice"
469
+ treatment.pinsHeard true when the chosen ElevenLabs voices are heard on this
470
+ run; false only for "render-owns-voice"
368
471
  cast[] one per character
369
472
  characterId, name
370
473
  voice { voiceId, label }: the voice this character's clips are
371
474
  converted to. null only when they have no voice of their
372
475
  own AND the run has no default voice
373
476
  voiceFrom "own-pin" or "run-default"; absent when voice is null
477
+ description the WRITTEN voice, when the run was given one
374
478
  availability { state: "available" | "missing" | "not-checked", … }
375
479
  spokenScenes how many scenes this character speaks in
376
480
  spokenSeconds those scenes' planned seconds — what ElevenLabs bills on
@@ -456,6 +560,15 @@ scenes[] one per scene, in order
456
560
  on record when the voice pass replaced the
457
561
  clip with a new file; null when the clip IS
458
562
  that file, or there is no clip
563
+ provenance which saved rulebook this run followed:
564
+ {format: {via, nodeId, rigId, rigName,
565
+ rulesFrom, specVersion}}. Absent when the
566
+ run followed none. `nodeId` is the Rig box
567
+ that chose it (absent on a Show ad, which
568
+ has no box); `rulesFrom` names the rig the
569
+ rules were borrowed from when the picked
570
+ rig is a copy. Names, ids and a version
571
+ only — never the rules themselves
459
572
  failed[] files that did not download: {file, url, error}
460
573
  ```
461
574
 
@@ -504,13 +617,16 @@ own ElevenLabs render.
504
617
  **Which scenes carry their voice where.** A scene whose storyboard entry has a
505
618
  non-empty `voText` is a narrator scene: its clip is rendered silent and the
506
619
  voice arrives as a separate track. A scene with on-camera `dialogue[]` has the
507
- speech inside the clip — under the default voice path the video model's invented
508
- voice is then replaced, in place, with the cast member's pinned ElevenLabs voice
509
- (that is `revoiced: true`). Other voice paths leave the render owning its own
510
- audio, e.g. lip-sync retargeting a VO track onto the video. The path is frozen
511
- on the run — `--voice-path` on `video start` sets it on a Show run, the node
512
- config sets it on a workflow run. Read `wordsFrom` and `revoiced` per scene
513
- rather than assuming.
620
+ speech inside the clip — under the default voice treatment the video model's
621
+ invented voice is then replaced, in place, with the cast member's pinned
622
+ ElevenLabs voice (that is `revoiced: true`). Other treatments leave the render
623
+ owning its own audio, e.g. lip-sync retargeting a VO track onto the video. The
624
+ treatment is frozen on the run when it starts, and there are exactly two doors
625
+ that set one: `--voice-path` on `video start` for a Show run, and
626
+ `--voice-treatment` on `workflow run` for a workflow run (see **Choosing how the
627
+ voices are made**). Node config is never a door — nothing on the canvas sets a
628
+ voice treatment, and a run that was given none renders the default. Read
629
+ `wordsFrom` and `revoiced` per scene rather than assuming.
514
630
 
515
631
  **Word timings** (`scene-NN.words.json`). Seconds from the start of the file
516
632
  `wordsFrom` names, always. `wordsFrom: "clip"` is an ElevenLabs Scribe
@@ -8,7 +8,7 @@ Core — discover & run:
8
8
  exodus workflow list [--json] List the brand's saved workflows
9
9
  exodus workflow describe <workflowId|name> [--json] Inputs, prerequisites, outputs
10
10
  exodus workflow bots [--category <cat>] [--slug <slug>] [--json] Bot catalog / one bot's spec
11
- exodus workflow run <workflowId|name> [--input key=value ...] [--fill <name>] [--auto-approve] [--wait] [--out <dir>] [--json] Run it
11
+ exodus workflow run <workflowId|name> [--input key=value ...] [--fill <name>] [--voices <json|@file>] [--voice-treatment <name|json|@file>] [--auto-approve] [--wait] [--out <dir>] [--json] Run it
12
12
  exodus workflow status [--id <runId>] [--out <dir>] [--json] Poll a run / read its outputs (no --id → the brand's 25 newest runs + their ids)
13
13
  exodus workflow cancel <runId> [--reason "..."] [--json] Stop a run that hasn't finished — queued, running, or parked
14
14
  exodus workflow export <workflowId|name> [--version <n>] [--out <file>] [--json] Dump the contract (YAML)
@@ -51,6 +51,24 @@ Flag notes that add agent-level guidance (everything else is in --help):
51
51
  earlier upload is relayed as-is. Accepted: image PNG/JPEG/
52
52
  WebP/GIF ≤15MB, video MP4/MOV/WebM ≤200MB, audio MP3/M4A/
53
53
  WAV/OGG ≤50MB, document PDF/TXT/MD/DOC/DOCX ≤25MB.
54
+ --voices <v> (run, video workflows) Give each speaker in the script an
55
+ ElevenLabs voice from the moment the run starts, instead
56
+ of at the storyboard review. A JSON object keyed by the
57
+ names the SCRIPT uses ("HOST 1"), or @path to a file
58
+ holding one — the same voices.json "exodus video voices
59
+ <run> --from" reads, so a brand keeps one file for both.
60
+ --voice-treatment <v> (run, video workflows) Say HOW the run makes its voices.
61
+ A name on its own picks a way of working that plays a
62
+ voice you already have (and still takes --voices); a JSON
63
+ object, or @path to one, shaped {"path": "...",
64
+ "describe": {"HOST 1": "..."}} has the video model speak
65
+ the lines itself in a voice you WROTE, one description per
66
+ speaker. Written voices and --voices are refused together:
67
+ the ElevenLabs voices would be paid for and never heard.
68
+ Chosen at launch only — the storyboard review can show a
69
+ written voice but cannot change it. Anything the run could
70
+ not do stops the launch before the run exists. The full
71
+ flow is in the exodus-video skill.
54
72
  --auto-approve (run) Send this ONE run off unattended: every Checkpoint it
55
73
  reaches is approved for you, exactly as it stands, and the
56
74
  run records that nobody looked. Without it a Checkpoint
@@ -145,6 +163,20 @@ Fill every required input. For a free-text brief you can compose a **bespoke bri
145
163
 
146
164
  **A brief wired straight into a Storyboard is a script, not a prompt.** On a video workflow whose Storyboard takes its `script` port from a Brief node, the text you pass for that brief boards VERBATIM, the same way a Show ad's pasted script does: one speaker per scene, every line rendered once in order, long lines split across clips, narrated lines rendered silent under the voice track. So it must be in the script form the `exodus-video` skill documents (an optional `CAST:` block of `NAME — description` lines, then `NAME:` turns, then a `CTA:` block; `NARRATOR:` lines only for Narrated Story and Explainer). A brief that is not a script, or one that does not fit the wired rig's family, is refused at launch with the parser's own message and nothing is spent. Write the script to a file and pass it with `--input script=@script.txt`. A Storyboard fed by a bot is unaffected.
147
165
 
166
+ **A saved rig can bring a rulebook with it.** Some saved rigs carry saved format rules: how many speakers the script may have, which checks run on the clips, how the cast is built. Point a Rig box at one of those rigs and the run follows its rules — an ensemble rig seats more speakers than the usual three, a talking-head rig seats one. The start receipt says which rulebook ran:
167
+
168
+ ```
169
+ Format rules: Ensemble Talking Head, version ensemble-e1-provisional-2026-07 (from the "rig_1" Rig box).
170
+ ```
171
+
172
+ `workflow status` says the same line later, and `video pull` writes the same facts into `manifest.json` under `provenance`. Nothing prints the rules themselves — they are the house's, not the brand's — only whose they were and which version ran.
173
+
174
+ Three things to know before you wire one:
175
+
176
+ - **One run, one rulebook.** If any Rig box's rig carries rules, every Rig box in the workflow has to point at that same saved rig. A second box on a different rig, or a box with its knobs set in place, refuses the launch with a sentence naming every box. Nothing is spent.
177
+ - **Some rulebooks aren't released yet.** A rig that is still being built refuses a member's launch and names itself. Only an admin can start a run on one. Duplicating that rig doesn't get around it — the copy still reads the original's rules, so the answer is the same.
178
+ - **The rig's video model is advice, not a change.** When the rig is normally filmed with a different video model than the one your Video step is set to, the receipt says so in one line and the run uses YOUR setting. Change the Video step yourself if you want the rig's.
179
+
148
180
  For an **`asset` (file) input, pass the path to a local file** and the CLI does the uploading for you — mint, push the bytes, register, then start the run with the stored asset:
149
181
 
150
182
  ```bash
@@ -205,7 +237,7 @@ Two limits worth knowing before you treat an empty inbox as proof of anything:
205
237
 
206
238
  **What `inbox` does not list is work that isn't waiting on anybody.** A queued run, a running run, and any automation that ran start-to-finish without an approval step never appear there — not because they're hidden, but because there is nothing for you to decide. Plenty of workflows are built that way on purpose; the shipped **Winner Flywheel** starter is one (brief → splitter → output, no Checkpoint box anywhere), so its run *can never* show up in `inbox`. An empty inbox after a promote means "nothing needs you", not "nothing ran".
207
239
 
208
- **To see runs that aren't waiting on you, run `workflow status` with no `--id`.** That prints the brand's recent runs — newest first, one row each with the workflow name, its status, the **date** it started, and its **run id**. That bare list is how you discover a background run at all, including one that started and finished while you weren't looking; take the id from it and `status --id <runId>` for the full detail.
240
+ **To see runs that aren't waiting on you, run `workflow status` with no `--id`.** That prints the brand's recent runs — newest first, one row each with the run's own name (the name you gave it, else the first line of its brief, else the workflow's name), its type ("Copy run" or "Workflow"), its status, the **date** it started, and its **run id**. That bare list is how you discover a background run at all, including one that started and finished while you weren't looking; take the id from it and `status --id <runId>` for the full detail.
209
241
 
210
242
  Two things about that list you have to know before you answer a question with it:
211
243
 
@@ -1,5 +1,6 @@
1
1
  import { type ApiResponse } from "../lib/client.js";
2
2
  import type { FlagOccurrence } from "../lib/args.js";
3
+ import { type RunProvenance, type WorkflowRun } from "./workflow.js";
3
4
  export declare const helpText: string;
4
5
  export declare const VOICES_PATH = "/api/v2/video/voices";
5
6
  export interface ClipWord {
@@ -99,7 +100,9 @@ export interface VideoRun {
99
100
  pauseReason?: BuilderPauseReason;
100
101
  pausedNodeId?: string;
101
102
  nodes: VideoRunNode[];
103
+ pauseAhead?: WorkflowRun["pauseAhead"];
102
104
  castLock?: PullCastLock | null;
105
+ provenance?: RunProvenance;
103
106
  workflowId?: string;
104
107
  moduleOwned?: boolean;
105
108
  }
@@ -113,6 +116,7 @@ export interface NodeItem {
113
116
  attempt?: number;
114
117
  flagged?: boolean;
115
118
  findings?: ClipFinding[];
119
+ staleClaim?: boolean;
116
120
  artifact?: ArtifactSubset;
117
121
  }
118
122
  export interface ShowRow {
@@ -225,6 +229,7 @@ export interface ManifestCastRef {
225
229
  error: string | null;
226
230
  voiceId: string | null;
227
231
  voiceLabel: string | null;
232
+ voiceDescription: string | null;
228
233
  }
229
234
  export interface VideoManifest {
230
235
  runId: string;
@@ -240,6 +245,7 @@ export interface VideoManifest {
240
245
  } | null;
241
246
  scenes: ManifestScene[];
242
247
  failed: PullFailure[];
248
+ provenance?: RunProvenance;
243
249
  }
244
250
  export declare const CAST_LEDGER_BASE = 910000;
245
251
  export interface PullPlan {
@@ -292,6 +298,7 @@ export interface ClipRedoTarget {
292
298
  }
293
299
  export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
294
300
  export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
301
+ export declare const VOICE_PATHS_NEVER_HEARD: Set<string>;
295
302
  export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
296
303
  export type RevoiceTarget = ClipRedoTarget | {
297
304
  all: true;
@@ -314,6 +321,7 @@ export interface CastVoiceRow {
314
321
  state: "not-checked";
315
322
  why: string;
316
323
  };
324
+ description?: string;
317
325
  spokenScenes: number;
318
326
  spokenSeconds: number;
319
327
  }
@@ -327,6 +335,9 @@ export interface CastVoiceSheet {
327
335
  label?: string;
328
336
  } | null;
329
337
  treatment: {
338
+ kind?: string;
339
+ path?: string | null;
340
+ usesElevenLabs?: boolean;
330
341
  summary: string;
331
342
  provider: string;
332
343
  model: string;
@@ -5,7 +5,7 @@ import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
5
5
  import { displayRunStatus, formatApiError } from "../lib/format.js";
6
6
  import { missingRouteLine } from "../lib/route-support.js";
7
7
  import { hasBinary } from "../lib/preflight.js";
8
- import { ASSET_UPLOAD_POLICY } from "./workflow.js";
8
+ import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
9
9
  export const helpText = `
10
10
  exodus video — make an ad from a Show, pull every piece, upload your cut
11
11
 
@@ -407,10 +407,15 @@ function castVoicePins(artifact) {
407
407
  for (const member of cast) {
408
408
  const characterId = typeof member.characterId === "string" ? member.characterId.trim() : "";
409
409
  const voiceId = typeof member.voiceId === "string" ? member.voiceId.trim() : "";
410
- if (!characterId || !voiceId)
410
+ const description = typeof member.voiceDescription === "string" ? member.voiceDescription.trim() : "";
411
+ if (!characterId || (!voiceId && !description))
411
412
  continue;
412
413
  const label = typeof member.voiceLabel === "string" ? member.voiceLabel.trim() : "";
413
- pins.set(characterId, { voiceId, voiceLabel: label ? label : null });
414
+ pins.set(characterId, {
415
+ voiceId: voiceId ? voiceId : null,
416
+ voiceLabel: voiceId && label ? label : null,
417
+ voiceDescription: description ? description : null,
418
+ });
414
419
  }
415
420
  return pins;
416
421
  }
@@ -487,6 +492,7 @@ function planCastRefs(run, items, pins) {
487
492
  error: row.error,
488
493
  voiceId: pin?.voiceId ?? null,
489
494
  voiceLabel: pin?.voiceLabel ?? null,
495
+ voiceDescription: pin?.voiceDescription ?? null,
490
496
  });
491
497
  if (file && row.url)
492
498
  downloads.push({ file, url: row.url });
@@ -701,6 +707,9 @@ export function planPull(run, items, opts) {
701
707
  : null,
702
708
  scenes,
703
709
  failed: [],
710
+ ...(run.provenance?.format || run.provenance?.voice
711
+ ? { provenance: run.provenance }
712
+ : {}),
704
713
  },
705
714
  };
706
715
  }
@@ -912,6 +921,8 @@ const ITEM_STATUS_WORD = {
912
921
  function itemWord(item) {
913
922
  if (!item)
914
923
  return "—";
924
+ if (item.staleClaim === true)
925
+ return "stopped";
915
926
  if (item.flagged === true)
916
927
  return "flagged";
917
928
  return ITEM_STATUS_WORD[item.status] ?? item.status;
@@ -965,6 +976,7 @@ export async function statusFlow(runId, json, deps) {
965
976
  const lines = [
966
977
  headline,
967
978
  ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
979
+ ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
968
980
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
969
981
  ];
970
982
  if (byScene.size === 0) {
@@ -1167,6 +1179,7 @@ export function planClipRedo(run, items, target) {
1167
1179
  return { ok: false, reason: found.reason };
1168
1180
  const { row, uploadedCut } = found;
1169
1181
  const redoable = row.status === "failed" ||
1182
+ row.staleClaim === true ||
1170
1183
  (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1171
1184
  if (!redoable) {
1172
1185
  if (row.status === "done") {
@@ -1219,7 +1232,7 @@ function findClipRow(run, items, target) {
1219
1232
  "and one clip can only be redone on a step that finished.",
1220
1233
  };
1221
1234
  }
1222
- if (row.status === "running") {
1235
+ if (row.status === "running" && row.staleClaim !== true) {
1223
1236
  return { reason: `Scene ${target.sceneIndex} is already being redone.` };
1224
1237
  }
1225
1238
  return { row, uploadedCut };
@@ -1285,20 +1298,34 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1285
1298
  }
1286
1299
  function clipIsStillRaw(item) {
1287
1300
  const a = item.artifact;
1288
- return (item.status === "done" &&
1301
+ return ((item.status === "done" || item.staleClaim === true) &&
1289
1302
  a?.type === "video" &&
1290
1303
  a.revoiced !== true &&
1291
1304
  a.speechTrimmed !== true &&
1292
1305
  a.tailTrimmed !== true);
1293
1306
  }
1294
1307
  const VOICE_NOT_CHANGED_CODES = new Set(["voice-unpinned", "voice-not-applied"]);
1308
+ export const VOICE_PATHS_NEVER_HEARD = new Set(["native-prompt", "omni-audio-ids", "gemini-direct"]);
1309
+ async function neverHeardRunLine(runId, deps) {
1310
+ const res = await deps
1311
+ .get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
1312
+ .catch(() => null);
1313
+ if (!res?.ok)
1314
+ return null;
1315
+ const path = res.data?.treatment?.path;
1316
+ if (typeof path !== "string" || !VOICE_PATHS_NEVER_HEARD.has(path))
1317
+ return null;
1318
+ return (`This run's clips are not voiced by ElevenLabs — the "${path}" way settles each voice as ` +
1319
+ "the clip is rendered — so no clip on this run has a voice pass to redo. Nothing was queued " +
1320
+ "and nothing was spent. To make a clip again from scratch, redo the clip instead.");
1321
+ }
1295
1322
  export function planClipRevoice(run, items, target) {
1296
1323
  const found = findClipRow(run, items, target);
1297
1324
  if ("reason" in found)
1298
1325
  return { ok: false, reason: found.reason };
1299
1326
  const { row, uploadedCut } = found;
1300
1327
  const remake = `To make the clip again from scratch: exodus video retry-clip ${run._id} --scene ${target.sceneIndex}`;
1301
- if (row.status !== "done" || row.artifact?.type !== "video") {
1328
+ if ((row.status !== "done" && row.staleClaim !== true) || row.artifact?.type !== "video") {
1302
1329
  return {
1303
1330
  ok: false,
1304
1331
  reason: `Scene ${target.sceneIndex} has no finished clip, so there is no voice to redo. ${remake}`,
@@ -1342,10 +1369,11 @@ export async function revoiceFlow(runId, target, json, deps) {
1342
1369
  .map((i) => ({ sceneIndex: i.sceneIndex, nodeId: i.nodeId }))
1343
1370
  : [target];
1344
1371
  if (targets.length === 0) {
1345
- const nothing = "No clip on this run is waiting for a voice pass, so nothing was started.";
1372
+ const note = (await neverHeardRunLine(runId, deps)) ??
1373
+ "No clip on this run is waiting for a voice pass, so nothing was started.";
1346
1374
  return {
1347
1375
  code: 0,
1348
- lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note: nothing })] : [nothing],
1376
+ lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note })] : [note],
1349
1377
  };
1350
1378
  }
1351
1379
  const scenes = [];
@@ -1500,7 +1528,7 @@ export async function voicesFlow(runId, voices, json, deps) {
1500
1528
  }
1501
1529
  function availabilityWords(row) {
1502
1530
  if (!row.voice)
1503
- return "no voice yet";
1531
+ return row.description ? "voice written below" : "no voice yet";
1504
1532
  switch (row.availability?.state) {
1505
1533
  case "available":
1506
1534
  return "ElevenLabs has it";
@@ -1534,12 +1562,18 @@ function voiceSummaryLines(cast, notices) {
1534
1562
  }
1535
1563
  export function voiceSheetLines(sheet) {
1536
1564
  const lines = [`Voices for run ${sheet.runId}`];
1565
+ if (typeof sheet.treatment.path === "string" && sheet.treatment.path !== "") {
1566
+ lines.push(` Treatment: ${sheet.treatment.path}`);
1567
+ }
1537
1568
  if (sheet.cast.length === 0)
1538
1569
  lines.push(" Nobody is in this ad yet.");
1539
1570
  for (const row of sheet.cast) {
1540
1571
  const voice = voiceWords(row);
1541
1572
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1542
- `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1573
+ `speaks in ${row.spokenScenes} scene${row.spokenScenes === 1 ? "" : "s"}, ` +
1574
+ `about ${row.spokenSeconds}s`);
1575
+ if (row.description)
1576
+ lines.push(` written voice: ${row.description}`);
1543
1577
  }
1544
1578
  if (sheet.narrator) {
1545
1579
  const label = sheet.narrator.label ? `${sheet.narrator.label} ` : "";
@@ -1551,9 +1585,17 @@ export function voiceSheetLines(sheet) {
1551
1585
  : "Nothing changed — those voices were already set. Pictures and script were not touched.");
1552
1586
  }
1553
1587
  lines.push(`How voices are applied: ${sheet.treatment.summary}${sheet.treatment.speedChange ? "" : " No speed change."}`);
1554
- lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
1555
- `About ${sheet.treatment.usage.seconds} seconds of speech across ` +
1556
- `${sheet.treatment.usage.clips} clips will be converted.`);
1588
+ const playsPinnedVoices = sheet.treatment.kind !== "render-owns-voice";
1589
+ const convertsClips = playsPinnedVoices && sheet.treatment.usage.clips > 0;
1590
+ if (convertsClips || sheet.treatment.usesElevenLabs === undefined) {
1591
+ lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
1592
+ `About ${sheet.treatment.usage.seconds} seconds of speech across ` +
1593
+ `${sheet.treatment.usage.clips} clips will be converted.`);
1594
+ }
1595
+ else if (sheet.treatment.usesElevenLabs) {
1596
+ lines.push("Cost: the narration on this run is voiced by ElevenLabs and billed to your own key. " +
1597
+ `${sheet.treatment.costNote} The clips themselves are not converted.`);
1598
+ }
1557
1599
  for (const notice of sheet.notices)
1558
1600
  lines.push(notice);
1559
1601
  lines.push(sheet.canChange
@@ -290,6 +290,11 @@ export interface WorkflowRunOutput {
290
290
  durationSec?: number;
291
291
  sceneIndex?: number;
292
292
  final?: boolean;
293
+ words?: Array<{
294
+ w: string;
295
+ s: number;
296
+ e: number;
297
+ }>;
293
298
  frames?: Array<{
294
299
  sceneIndex: number;
295
300
  imageUrl?: string;
@@ -347,10 +352,43 @@ export interface WorkflowPendingSlot {
347
352
  value?: string;
348
353
  hint?: string;
349
354
  }
355
+ export interface RunFormatProvenance {
356
+ via: "show" | "rig-node";
357
+ nodeId?: string;
358
+ rigId: string;
359
+ rigName: string;
360
+ rulesFrom?: {
361
+ rigId: string;
362
+ rigName: string;
363
+ };
364
+ specVersion: string | null;
365
+ }
366
+ export type VoicePath = "sts-revoice" | "lipsync-retarget" | "native-prompt" | "omni-audio-ids" | "gemini-direct";
367
+ export type RunVoiceProvenance = {
368
+ source: "show";
369
+ path: VoicePath;
370
+ } | {
371
+ source: "launch";
372
+ path: VoicePath;
373
+ descriptions: {
374
+ speaker: string;
375
+ description: string;
376
+ }[];
377
+ } | {
378
+ source: "unreadable";
379
+ };
380
+ export interface RunProvenance {
381
+ format: RunFormatProvenance | null;
382
+ voice: RunVoiceProvenance | null;
383
+ }
384
+ export declare function formatRulesLine(provenance: unknown): string | undefined;
350
385
  export interface WorkflowRun {
351
386
  _id: string;
352
387
  workflowId: string;
353
388
  workflowName: string;
389
+ runTitle?: string;
390
+ title?: string;
391
+ moduleKey?: string;
354
392
  status: WorkflowRunStatus;
355
393
  error?: string;
356
394
  counts?: WorkflowCounts;
@@ -372,7 +410,13 @@ export interface WorkflowRun {
372
410
  nodeId: string;
373
411
  approvedAt: number;
374
412
  }[];
413
+ provenance?: RunProvenance;
414
+ pauseAhead?: {
415
+ nodeId: string;
416
+ after: "frames" | "storyboard";
417
+ };
375
418
  }
419
+ export declare function pauseAheadLine(ahead: NonNullable<WorkflowRun["pauseAhead"]>): string;
376
420
  export type WorkflowRunProjection = Omit<WorkflowRun, "nodes"> & {
377
421
  nodes?: never;
378
422
  };
@@ -410,6 +454,7 @@ interface RunFlowOptions {
410
454
  autoApprove?: boolean;
411
455
  imageRigOverrides?: Record<string, unknown>;
412
456
  voices?: VoiceMap;
457
+ voiceTreatment?: VoiceTreatmentBody;
413
458
  wait: boolean;
414
459
  json: boolean;
415
460
  out?: string;
@@ -431,6 +476,11 @@ export declare function rejectTerminalFlag(occurrences: FlagOccurrence[]): void;
431
476
  export declare function parseAutoApproveFlag(args: string[]): boolean;
432
477
  export declare function parseRigOverridesFlag(occurrences: FlagOccurrence[], readFile?: (path: string) => string): Record<string, unknown> | undefined;
433
478
  export declare function parseVoicesFlag(occurrences: FlagOccurrence[], readFile?: (path: string) => string): VoiceMap | undefined;
479
+ export interface VoiceTreatmentBody {
480
+ path: string;
481
+ describe?: Record<string, string>;
482
+ }
483
+ export declare function parseVoiceTreatmentFlag(occurrences: FlagOccurrence[], readFile?: (path: string) => string): VoiceTreatmentBody | undefined;
434
484
  export declare function parseFillFlag(occurrences: FlagOccurrence[]): string | undefined;
435
485
  export declare function formatWorkflowList(workflows: WorkflowListItem[]): string;
436
486
  export declare function formatRecentRuns(runs: WorkflowRunProjection[]): string;
@@ -464,6 +514,7 @@ export declare function botsFlow(opts: {
464
514
  slug?: string;
465
515
  json: boolean;
466
516
  }, deps: WorkflowRunDeps): Promise<FlowResult>;
517
+ export declare function voiceTreatmentLine(provenance: unknown): string | undefined;
467
518
  export declare function runFlow(workflowRef: string, opts: RunFlowOptions, deps: WorkflowRunDeps): Promise<FlowResult>;
468
519
  export declare function statusFlow(opts: {
469
520
  id?: string;
@@ -6,6 +6,7 @@ import { formatApiError } from "../lib/format.js";
6
6
  import { pollUntilDone } from "../lib/poll.js";
7
7
  import { normalizeRunStatus, isTerminalRunStatus, storedWorkflowStatusForms, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
8
8
  import { runVerdict } from "../lib/runVerdict.js";
9
+ import { runDisplayName, runHeadingWord, runTypeWord } from "../lib/runNames.js";
9
10
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
10
11
  import { missingRouteLine } from "../lib/route-support.js";
11
12
  import { getChannel } from "../lib/channel.js";
@@ -20,7 +21,7 @@ Usage:
20
21
  exodus workflow templates [list] [--json]
21
22
  exodus workflow templates export <key> [--out <file>] [--json]
22
23
  exodus workflow schema [--kind <kind>] [--face <face>] [--json]
23
- exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--auto-approve] [--wait] [--out <dir>] [--json]
24
+ exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--voice-treatment <name|json|@file>] [--auto-approve] [--wait] [--out <dir>] [--json]
24
25
  exodus workflow status [--json]
25
26
  exodus workflow status --id <runId> [--out <dir>] [--json]
26
27
  exodus workflow cancel <runId> [--reason "..."] [--json]
@@ -102,6 +103,29 @@ Flags:
102
103
  <run>" at the storyboard review instead. Voice ids
103
104
  belong to the brand's own folder — never put them in a
104
105
  shared workflow or template.
106
+ --voice-treatment <v> (run) Say HOW this run makes its voices. Two kinds of
107
+ answer. A name on its own picks a way of working that
108
+ uses voices you already have:
109
+ --voice-treatment sts-revoice
110
+ --voice-treatment lipsync-retarget --voices @voices.json
111
+ The other kind has the video model speak the lines
112
+ itself, in a voice you WRITE instead of one you pick.
113
+ Those need a description for every speaker in your
114
+ script, so they take a JSON object, or @path to a .json
115
+ file holding one:
116
+ --voice-treatment @voice-treatment.json
117
+ --voice-treatment '{"path":"native-prompt","describe":{"HOST 1":"Dry, tired baritone."}}'
118
+ Written voices and --voices are never used together:
119
+ the video model owns the voice on those runs, so
120
+ ElevenLabs voices would be paid for and never heard,
121
+ and asking for both stops the launch. Anything the run
122
+ could not actually do — a treatment this stack has not
123
+ switched on, a Video step on a model that cannot do it,
124
+ a missing key, a rig that shows every line as on-screen
125
+ text — stops the launch before the run is created, and
126
+ the message says which. Descriptions are chosen HERE,
127
+ at the start: "exodus video voices <run>" shows them at
128
+ the review but cannot change them.
105
129
  --auto-approve (run) Deliberately unattended launch: every Checkpoint
106
130
  box this run stops at is approved automatically, with
107
131
  whatever it was holding left exactly as it is, and the
@@ -194,6 +218,7 @@ Examples:
194
218
  exodus workflow run "Product Shots" --rig-overrides '{"rig_1":{"lines":{"line_1":{"count":3}}}}'
195
219
  exodus workflow run "Product Shots" --rig-overrides @rig.json --wait
196
220
  exodus workflow run "Podcast Ad" --input script=@script.txt --voices @voices.json --wait
221
+ exodus workflow run "Podcast Ad" --input script=@script.txt --voice-treatment @voice-treatment.json --wait
197
222
  exodus workflow run "Launch Flow" --wait --out ./deliverables
198
223
  exodus workflow status # recent runs + their ids
199
224
  exodus workflow status --id wr_123
@@ -268,6 +293,20 @@ Notes:
268
293
  no speaker names to key on at the start, so it is refused with a pointer at
269
294
  the review-time command. Keep voice ids in the brand's own folder, never in a
270
295
  shared workflow or template. (#1846)
296
+ "workflow run --voice-treatment" says HOW a run makes its voices. Some ways of
297
+ working play a voice you picked (that is today's behaviour, and it still takes
298
+ --voices); others have the video model speak the CAST's lines itself, in voices
299
+ you describe in words. A described voice needs one description per speaker,
300
+ because a speaker nobody described gets a voice the model invents afresh on
301
+ every clip, and the drift only shows up once the clips are paid for. Written
302
+ voices and --voices are refused together, since the ElevenLabs voices would
303
+ never be heard. Everything the run could not do — a way of working this stack
304
+ has not switched on, a Video step whose model cannot do it, a missing key, a
305
+ rig that shows every line as on-screen text — stops the launch before the run
306
+ is created. The ways of working beyond the two shipped ones are switched on
307
+ only on the test stack while they are being tried out, so a live run refuses
308
+ them by name. Both the launch receipt and "workflow status" say which way a
309
+ run took, and "exodus video voices <run>" shows each written voice. (#1869)
271
310
  workflow triggers are addressed by 1-based position — a trigger carries no id,
272
311
  so the CLI reads the live list from the export contract and sends a fingerprint
273
312
  of the trigger's fields as the guard; a concurrent edit fails loud rather than
@@ -310,6 +349,29 @@ Notes:
310
349
  ids it wants). The "gate" verbs retired in 2.0 along with the Gate node — the
311
350
  command now prints a pointer at "checkpoint" and exits 1.
312
351
  `.trim();
352
+ export function formatRulesLine(provenance) {
353
+ const format = provenance?.format;
354
+ if (!format || typeof format !== "object")
355
+ return undefined;
356
+ const { rigName, specVersion, nodeId, rulesFrom } = format;
357
+ if (typeof rigName !== "string" || rigName === "")
358
+ return undefined;
359
+ const version = typeof specVersion === "string" && specVersion !== ""
360
+ ? `, version ${specVersion}`
361
+ : "";
362
+ const where = typeof nodeId === "string" && nodeId !== ""
363
+ ? ` (from the "${nodeId}" Rig box)`
364
+ : " (from the Show's rig)";
365
+ const borrowed = rulesFrom && typeof rulesFrom.rigName === "string"
366
+ ? ` The rules come from "${rulesFrom.rigName}".`
367
+ : "";
368
+ return `Format rules: ${rigName}${version}${where}.${borrowed}`;
369
+ }
370
+ export function pauseAheadLine(ahead) {
371
+ return ahead.after === "frames"
372
+ ? "This run will pause for your approval once the frames are ready. The reference, cast and scene pictures are made first (a few cents each). Nothing bigger is spent until you approve."
373
+ : "This run will pause for your approval once the storyboard is written. Nothing is spent on pictures, voices or clips until you approve.";
374
+ }
313
375
  const LIST_PATH = "/api/v2/workflows";
314
376
  const RUN_PATH = "/api/v2/workflows/run";
315
377
  const STATUS_PATH = "/api/v2/workflow";
@@ -354,6 +416,7 @@ const VALUE_FLAGS = new Set([
354
416
  "reject",
355
417
  "rig-overrides",
356
418
  "voices",
419
+ "voice-treatment",
357
420
  "note",
358
421
  ]);
359
422
  function defaultMkdirp(dir) {
@@ -976,6 +1039,57 @@ export function parseVoicesFlag(occurrences, readFile) {
976
1039
  }
977
1040
  return parsed;
978
1041
  }
1042
+ export function parseVoiceTreatmentFlag(occurrences, readFile) {
1043
+ const usage = "--voice-treatment requires a treatment name, JSON, or @path/to/voice-treatment.json";
1044
+ let raw;
1045
+ for (const { flag, value } of occurrences) {
1046
+ if (flag !== "voice-treatment")
1047
+ continue;
1048
+ if (value === undefined || value.startsWith("--"))
1049
+ throw new Error(usage);
1050
+ raw = value;
1051
+ }
1052
+ if (raw === undefined)
1053
+ return undefined;
1054
+ let text = raw.trim();
1055
+ if (!text)
1056
+ throw new Error(usage);
1057
+ let fromFile = false;
1058
+ if (text.startsWith("@@")) {
1059
+ text = text.slice(1);
1060
+ }
1061
+ else if (text.startsWith("@")) {
1062
+ const filePath = text.slice(1);
1063
+ if (!filePath) {
1064
+ throw new Error("--voice-treatment @<file> needs a file path after \"@\"");
1065
+ }
1066
+ if (!readFile) {
1067
+ throw new Error(`--voice-treatment: cannot load @${filePath} here (no file access)`);
1068
+ }
1069
+ try {
1070
+ text = readFile(filePath);
1071
+ }
1072
+ catch (e) {
1073
+ const msg = e instanceof Error ? e.message : String(e);
1074
+ throw new Error(`--voice-treatment: could not read file "${filePath}": ${msg}`);
1075
+ }
1076
+ fromFile = true;
1077
+ }
1078
+ if (!fromFile && !text.startsWith("{"))
1079
+ return { path: text };
1080
+ let parsed;
1081
+ try {
1082
+ parsed = JSON.parse(text);
1083
+ }
1084
+ catch (e) {
1085
+ const msg = e instanceof Error ? e.message : String(e);
1086
+ throw new Error(`--voice-treatment is not valid JSON: ${msg}`);
1087
+ }
1088
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1089
+ throw new Error('--voice-treatment must be a treatment name, or a JSON object like {"path":"native-prompt","describe":{"HOST 1":"..."}}');
1090
+ }
1091
+ return parsed;
1092
+ }
979
1093
  export function parseFillFlag(occurrences) {
980
1094
  let name;
981
1095
  for (const { flag, value } of occurrences) {
@@ -1006,7 +1120,13 @@ const RECENT_RUNS_PAGE = 25;
1006
1120
  export function formatRecentRuns(runs) {
1007
1121
  if (runs.length === 0)
1008
1122
  return "No workflow runs found for the active brand.";
1009
- const rows = table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, runVerdict(r), dateOnly(r.createdAt), r._id]));
1123
+ const rows = table(["run", "type", "status", "created", "id"], runs.map((r) => [
1124
+ runDisplayName(r),
1125
+ runTypeWord(r),
1126
+ runVerdict(r),
1127
+ dateOnly(r.createdAt),
1128
+ r._id,
1129
+ ]));
1010
1130
  const notes = [];
1011
1131
  if (runs.length >= RECENT_RUNS_PAGE) {
1012
1132
  notes.push(`Showing the ${RECENT_RUNS_PAGE} newest runs — there may be older ones this list doesn't reach.`);
@@ -1087,7 +1207,7 @@ export function formatImportSummary(result, mode = {}) {
1087
1207
  export function formatWorkflowRun(run) {
1088
1208
  const lines = [];
1089
1209
  const counts = formatCounts(run.counts);
1090
- lines.push(`Workflow run — ${run.workflowName}`);
1210
+ lines.push(`${runHeadingWord(run)} — ${runDisplayName(run)}`);
1091
1211
  lines.push(`runId: ${run._id}`);
1092
1212
  lines.push(`workflowId: ${run.workflowId}`);
1093
1213
  if (run.triggerRunId)
@@ -1095,6 +1215,12 @@ export function formatWorkflowRun(run) {
1095
1215
  lines.push(`status: ${runVerdict(run)}${counts ? ` (${counts})` : ""}`);
1096
1216
  if (run.isTerminal)
1097
1217
  lines.push("terminal: yes");
1218
+ const rulesLine = formatRulesLine(run.provenance);
1219
+ if (rulesLine)
1220
+ lines.push(rulesLine);
1221
+ const treatmentLine = voiceTreatmentLine(run.provenance);
1222
+ if (treatmentLine)
1223
+ lines.push(treatmentLine);
1098
1224
  if (run.error)
1099
1225
  lines.push(`error: ${run.error}`);
1100
1226
  if (Object.keys(run.inputs ?? {}).length > 0) {
@@ -1104,6 +1230,10 @@ export function formatWorkflowRun(run) {
1104
1230
  if (run.autoApprovals && run.autoApprovals.length > 0) {
1105
1231
  lines.push(`auto-approved: ${run.autoApprovals.length} checkpoint stop${run.autoApprovals.length === 1 ? "" : "s"} (${run.autoApprovals.map((a) => a.nodeId).join(", ")}) — launched with --auto-approve, nobody reviewed these`);
1106
1232
  }
1233
+ if (run.pauseAhead) {
1234
+ lines.push("");
1235
+ lines.push(pauseAheadLine(run.pauseAhead));
1236
+ }
1107
1237
  const parkedNodeId = gateParkedNodeId(run.status, run.pauseReason, run.pausedNodeId);
1108
1238
  if (run.nodes.length > 0) {
1109
1239
  lines.push("");
@@ -1786,6 +1916,23 @@ function launchVoicesLine(attached) {
1786
1916
  }
1787
1917
  return said.length > 0 ? `Voices: ${said.join(", ")}` : undefined;
1788
1918
  }
1919
+ export function voiceTreatmentLine(provenance) {
1920
+ const voice = provenance?.voice;
1921
+ if (!voice || typeof voice !== "object")
1922
+ return undefined;
1923
+ if (voice.source === "unreadable") {
1924
+ return ("Voice treatment: the setting saved when this run started cannot be read, so no " +
1925
+ "clip will be made. Start the run again.");
1926
+ }
1927
+ const path = voice.path;
1928
+ if (typeof path !== "string" || path === "")
1929
+ return undefined;
1930
+ const where = voice.source === "show" ? "from its Show" : "chosen at launch";
1931
+ const described = voice.source === "launch" && Array.isArray(voice.descriptions) && voice.descriptions.length > 0
1932
+ ? `. Written voices: ${voice.descriptions.map((d) => d.speaker).join(", ")}`
1933
+ : "";
1934
+ return `Voice treatment: ${path} (${where})${described}`;
1935
+ }
1789
1936
  export async function runFlow(workflowRef, opts, deps) {
1790
1937
  let workflowId;
1791
1938
  try {
@@ -1829,6 +1976,7 @@ export async function runFlow(workflowRef, opts, deps) {
1829
1976
  ...(opts.autoApprove === true ? { autoApprove: true } : {}),
1830
1977
  ...(opts.imageRigOverrides ? { imageRigOverrides: opts.imageRigOverrides } : {}),
1831
1978
  ...(opts.voices ? { voices: opts.voices } : {}),
1979
+ ...(opts.voiceTreatment ? { voiceTreatment: opts.voiceTreatment } : {}),
1832
1980
  launchedVia: "cli",
1833
1981
  };
1834
1982
  const start = await deps.post(RUN_PATH, body);
@@ -1843,6 +1991,12 @@ export async function runFlow(workflowRef, opts, deps) {
1843
1991
  if (opts.json && !opts.wait)
1844
1992
  return { code: 0, lines: [JSON.stringify(base)] };
1845
1993
  const voicesLine = launchVoicesLine(data?.launchVoices);
1994
+ const provenance = { format: data?.format ?? null, voice: data?.voice ?? null };
1995
+ const rulesLine = formatRulesLine(provenance);
1996
+ const treatmentLine = voiceTreatmentLine(provenance);
1997
+ const advisories = Array.isArray(data?.advisories)
1998
+ ? data.advisories.filter((line) => typeof line === "string")
1999
+ : [];
1846
2000
  const lines = opts.json
1847
2001
  ? []
1848
2002
  : [
@@ -1850,7 +2004,10 @@ export async function runFlow(workflowRef, opts, deps) {
1850
2004
  "Workflow run started.",
1851
2005
  `runId: ${data.runId}`,
1852
2006
  `triggerRunId: ${data.triggerRunId}`,
2007
+ ...(rulesLine ? [rulesLine] : []),
2008
+ ...advisories,
1853
2009
  ...(voicesLine ? [voicesLine] : []),
2010
+ ...(treatmentLine ? [treatmentLine] : []),
1854
2011
  `Poll: exodus workflow status --id ${data.runId}`,
1855
2012
  ];
1856
2013
  if (!opts.wait) {
@@ -2957,7 +3114,7 @@ export async function run(flags, occurrences) {
2957
3114
  const workflowRef = rest[0];
2958
3115
  if (!workflowRef) {
2959
3116
  console.error("Error: workflow run requires <workflowId|name>.");
2960
- console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--auto-approve] [--wait] [--json]");
3117
+ console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--voice-treatment <name|json|@file>] [--auto-approve] [--wait] [--json]");
2961
3118
  process.exit(1);
2962
3119
  }
2963
3120
  let inputs;
@@ -2965,6 +3122,7 @@ export async function run(flags, occurrences) {
2965
3122
  let autoApprove;
2966
3123
  let imageRigOverrides;
2967
3124
  let voices;
3125
+ let voiceTreatment;
2968
3126
  try {
2969
3127
  inputs = parseRawInputFlags(occurrences);
2970
3128
  rejectTerminalFlag(occurrences);
@@ -2972,6 +3130,7 @@ export async function run(flags, occurrences) {
2972
3130
  autoApprove = parseAutoApproveFlag(process.argv.slice(3));
2973
3131
  imageRigOverrides = parseRigOverridesFlag(occurrences, defaultDeps.readFile);
2974
3132
  voices = parseVoicesFlag(occurrences, defaultDeps.readFile);
3133
+ voiceTreatment = parseVoiceTreatmentFlag(occurrences, defaultDeps.readFile);
2975
3134
  }
2976
3135
  catch (e) {
2977
3136
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -2983,6 +3142,7 @@ export async function run(flags, occurrences) {
2983
3142
  autoApprove,
2984
3143
  imageRigOverrides,
2985
3144
  voices,
3145
+ voiceTreatment,
2986
3146
  wait: flags["wait"] === true,
2987
3147
  json,
2988
3148
  out: flagString(flags, "out"),
@@ -0,0 +1,10 @@
1
+ export declare const COPY_FACE_MODULE_KEY = "copy-face";
2
+ export interface RunNameFields {
3
+ workflowName?: string;
4
+ runTitle?: string;
5
+ title?: string;
6
+ moduleKey?: string;
7
+ }
8
+ export declare function runDisplayName(run: RunNameFields): string;
9
+ export declare function runTypeWord(run: RunNameFields): string;
10
+ export declare function runHeadingWord(run: RunNameFields): string;
@@ -0,0 +1,10 @@
1
+ export const COPY_FACE_MODULE_KEY = "copy-face";
2
+ export function runDisplayName(run) {
3
+ return run.title || run.runTitle || run.workflowName || "";
4
+ }
5
+ export function runTypeWord(run) {
6
+ return run.moduleKey === COPY_FACE_MODULE_KEY ? "Copy run" : "Workflow";
7
+ }
8
+ export function runHeadingWord(run) {
9
+ return run.moduleKey === COPY_FACE_MODULE_KEY ? "Copy run" : "Workflow run";
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1900-beta.1",
3
+ "version": "2026.9.2000-beta.0",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",