@aicopycoders/exodus 2026.9.1900-beta.1 → 2026.9.1900-beta.2

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
 
@@ -256,6 +258,13 @@ CTA:
256
258
  THE SOFTGEL: Tap below and give your eyes ClearBlink.
257
259
  ```
258
260
 
261
+ **How many speakers you may have depends on the rig.** Three by default. A rig
262
+ that carries saved format rules sets its own number: an ensemble rig seats more,
263
+ a talking-head rig seats one. Over the number, the launch refuses before
264
+ anything is spent and says both figures ("4 speakers — this format supports 3").
265
+ The rulebook the run followed is on the start receipt, on `workflow status`, and
266
+ in `manifest.json` under `provenance`.
267
+
259
268
  ## Reading the storyboard gate
260
269
 
261
270
  `storyboard <runId>` prints one card per scene on both routes: who speaks, the
@@ -357,6 +366,88 @@ written by a bot is refused with a pointer at the review-time command, because
357
366
  nobody knows the speaker names yet. When the storyboard arrives, the voices are
358
367
  already set and `exodus video voices <runId>` shows them.
359
368
 
369
+ **Choosing how the voices are made.** `--voice-treatment` on `workflow run` says
370
+ HOW a run makes its voices, and it is the only door on the workflow route. Two
371
+ kinds of answer.
372
+
373
+ A name on its own picks a way of working that plays a voice you already have,
374
+ and still pairs with `--voices`:
375
+
376
+ ```
377
+ exodus workflow run "Podcast Ad" --input script=@script.txt \
378
+ --voice-treatment lipsync-retarget --voices @voices.json
379
+ ```
380
+
381
+ The other kind has the video model speak the CAST's lines itself, in voices you
382
+ WRITE. Those take a `voice-treatment.json`, which is the whole request body:
383
+
384
+ ```json
385
+ {
386
+ "path": "native-prompt",
387
+ "describe": {
388
+ "BUDGET": "Dry, tired middle-aged baritone. Slight rasp. Deadpan, unhurried.",
389
+ "PIGGY BANK": "Bright quick alto, a little smug, crisp consonants."
390
+ }
391
+ }
392
+ ```
393
+
394
+ ```
395
+ exodus workflow run "Podcast Ad" --input script=@script.txt \
396
+ --voice-treatment @voice-treatment.json
397
+ ```
398
+
399
+ Every speaker in the script needs a description. One left out gets a voice the
400
+ model invents afresh on every clip, and that drift only shows up once the clips
401
+ are paid for, so the launch refuses instead.
402
+
403
+ **The narration is not part of this.** A describing treatment changes how the
404
+ CAST's lines are voiced. A scene with narration (`voText`) is still recorded by
405
+ ElevenLabs on the member's own key by the Voiceover step, exactly as it always
406
+ was — so a run with `NARRATOR:` turns still needs an ElevenLabs key, and still
407
+ costs ElevenLabs money. `exodus video voices <runId>` says which of the two
408
+ shapes a run is, and only claims nothing is sent to ElevenLabs when nothing is.
409
+
410
+ **What refuses, all before the run is created, so nothing is spent.**
411
+
412
+ - A treatment name that does not exist. The message lists the ones that do.
413
+ - A describing treatment with a speaker undescribed, a speaker the script does
414
+ not have, the same speaker described twice, or an empty description.
415
+ - Written voices plus `--voices`. The video model owns the voice on those runs,
416
+ so the ElevenLabs voices would be paid for and never heard.
417
+ - Descriptions on a treatment that plays voices you picked instead.
418
+ - A workflow whose script is written by a bot. Nobody knows the speaker names
419
+ yet, so there is nothing to describe.
420
+ - A workflow with no Video step, a Video step whose model cannot do what was
421
+ asked, or a Video step with nothing wired into its voiceover port when the
422
+ treatment needs one.
423
+ - No Google AI key on the account, for the treatment that renders through
424
+ Google directly.
425
+ - A rig that shows every line on screen as text. No clip speaks, so there is no
426
+ voice to treat.
427
+
428
+ **Still being tested.** Everything beyond the two shipped ways of working is
429
+ switched on only on the test stack while it is being tried out. On the live
430
+ stack those names refuse by name, and the message says which two to use
431
+ instead. That is a property of the stack, not of the workflow, so the same
432
+ saved workflow behaves differently on each — and a retry asks again rather than
433
+ copying, so a run retried after a treatment was switched off refuses.
434
+
435
+ **A sub-workflow chooses none of this.** When a Call box runs another workflow
436
+ inside this one, the child run carries no voice treatment: it renders the
437
+ shipped default, whatever the parent was started with. A treatment is a choice a
438
+ person made at a keyboard for one run, and nobody made it for the sub-workflow.
439
+
440
+ **Written voices cannot be changed at the review.** They are chosen when the run
441
+ starts. `exodus video voices <runId>` on a described run names the treatment and
442
+ prints each written voice, and says plainly that `--voice-treatment` at launch is
443
+ what changes them. Do not tell anyone to create a Show for this, and do not set a
444
+ voice path in node config — neither is a door.
445
+
446
+ **Where it shows up afterwards.** The launch receipt and `exodus workflow status`
447
+ both print one line naming the treatment and the speakers who got a written
448
+ voice. `manifest.json` from `exodus video pull` carries the same under
449
+ `provenance.voice`, and each cast entry carries its `voiceDescription`.
450
+
360
451
  **The JSON receipt** (`--json`), the same shape for reading and for setting:
361
452
 
362
453
  ```
@@ -365,12 +456,15 @@ runId
365
456
  changed[] the characters whose voice actually moved (set only)
366
457
  canChange false once the run has left the storyboard review
367
458
  whyNot why, in one sentence, when canChange is false
459
+ treatment.path which way of making voices this run froze, or null
460
+ treatment.kind "performance-then-conversion" or "render-owns-voice"
368
461
  cast[] one per character
369
462
  characterId, name
370
463
  voice { voiceId, label }: the voice this character's clips are
371
464
  converted to. null only when they have no voice of their
372
465
  own AND the run has no default voice
373
466
  voiceFrom "own-pin" or "run-default"; absent when voice is null
467
+ description the WRITTEN voice, when the run was given one
374
468
  availability { state: "available" | "missing" | "not-checked", … }
375
469
  spokenScenes how many scenes this character speaks in
376
470
  spokenSeconds those scenes' planned seconds — what ElevenLabs bills on
@@ -456,6 +550,15 @@ scenes[] one per scene, in order
456
550
  on record when the voice pass replaced the
457
551
  clip with a new file; null when the clip IS
458
552
  that file, or there is no clip
553
+ provenance which saved rulebook this run followed:
554
+ {format: {via, nodeId, rigId, rigName,
555
+ rulesFrom, specVersion}}. Absent when the
556
+ run followed none. `nodeId` is the Rig box
557
+ that chose it (absent on a Show ad, which
558
+ has no box); `rulesFrom` names the rig the
559
+ rules were borrowed from when the picked
560
+ rig is a copy. Names, ids and a version
561
+ only — never the rules themselves
459
562
  failed[] files that did not download: {file, url, error}
460
563
  ```
461
564
 
@@ -504,13 +607,16 @@ own ElevenLabs render.
504
607
  **Which scenes carry their voice where.** A scene whose storyboard entry has a
505
608
  non-empty `voText` is a narrator scene: its clip is rendered silent and the
506
609
  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.
610
+ speech inside the clip — under the default voice treatment the video model's
611
+ invented voice is then replaced, in place, with the cast member's pinned
612
+ ElevenLabs voice (that is `revoiced: true`). Other treatments leave the render
613
+ owning its own audio, e.g. lip-sync retargeting a VO track onto the video. The
614
+ treatment is frozen on the run when it starts, and there are exactly two doors
615
+ that set one: `--voice-path` on `video start` for a Show run, and
616
+ `--voice-treatment` on `workflow run` for a workflow run (see **Choosing how the
617
+ voices are made**). Node config is never a door — nothing on the canvas sets a
618
+ voice treatment, and a run that was given none renders the default. Read
619
+ `wordsFrom` and `revoiced` per scene rather than assuming.
514
620
 
515
621
  **Word timings** (`scene-NN.words.json`). Seconds from the start of the file
516
622
  `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
@@ -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 } 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 {
@@ -100,6 +101,7 @@ export interface VideoRun {
100
101
  pausedNodeId?: string;
101
102
  nodes: VideoRunNode[];
102
103
  castLock?: PullCastLock | null;
104
+ provenance?: RunProvenance;
103
105
  workflowId?: string;
104
106
  moduleOwned?: boolean;
105
107
  }
@@ -225,6 +227,7 @@ export interface ManifestCastRef {
225
227
  error: string | null;
226
228
  voiceId: string | null;
227
229
  voiceLabel: string | null;
230
+ voiceDescription: string | null;
228
231
  }
229
232
  export interface VideoManifest {
230
233
  runId: string;
@@ -240,6 +243,7 @@ export interface VideoManifest {
240
243
  } | null;
241
244
  scenes: ManifestScene[];
242
245
  failed: PullFailure[];
246
+ provenance?: RunProvenance;
243
247
  }
244
248
  export declare const CAST_LEDGER_BASE = 910000;
245
249
  export interface PullPlan {
@@ -314,6 +318,7 @@ export interface CastVoiceRow {
314
318
  state: "not-checked";
315
319
  why: string;
316
320
  };
321
+ description?: string;
317
322
  spokenScenes: number;
318
323
  spokenSeconds: number;
319
324
  }
@@ -327,6 +332,9 @@ export interface CastVoiceSheet {
327
332
  label?: string;
328
333
  } | null;
329
334
  treatment: {
335
+ kind?: string;
336
+ path?: string | null;
337
+ usesElevenLabs?: boolean;
330
338
  summary: string;
331
339
  provider: string;
332
340
  model: string;
@@ -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
  }
@@ -1500,7 +1509,7 @@ export async function voicesFlow(runId, voices, json, deps) {
1500
1509
  }
1501
1510
  function availabilityWords(row) {
1502
1511
  if (!row.voice)
1503
- return "no voice yet";
1512
+ return row.description ? "voice written below" : "no voice yet";
1504
1513
  switch (row.availability?.state) {
1505
1514
  case "available":
1506
1515
  return "ElevenLabs has it";
@@ -1534,12 +1543,17 @@ function voiceSummaryLines(cast, notices) {
1534
1543
  }
1535
1544
  export function voiceSheetLines(sheet) {
1536
1545
  const lines = [`Voices for run ${sheet.runId}`];
1546
+ if (typeof sheet.treatment.path === "string" && sheet.treatment.path !== "") {
1547
+ lines.push(` Treatment: ${sheet.treatment.path}`);
1548
+ }
1537
1549
  if (sheet.cast.length === 0)
1538
1550
  lines.push(" Nobody is in this ad yet.");
1539
1551
  for (const row of sheet.cast) {
1540
1552
  const voice = voiceWords(row);
1541
1553
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1542
1554
  `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1555
+ if (row.description)
1556
+ lines.push(` written voice: ${row.description}`);
1543
1557
  }
1544
1558
  if (sheet.narrator) {
1545
1559
  const label = sheet.narrator.label ? `${sheet.narrator.label} ` : "";
@@ -1551,9 +1565,16 @@ export function voiceSheetLines(sheet) {
1551
1565
  : "Nothing changed — those voices were already set. Pictures and script were not touched.");
1552
1566
  }
1553
1567
  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.`);
1568
+ const rendersOwnVoice = sheet.treatment.kind === "render-owns-voice";
1569
+ if (!rendersOwnVoice || sheet.treatment.usesElevenLabs === undefined) {
1570
+ lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
1571
+ `About ${sheet.treatment.usage.seconds} seconds of speech across ` +
1572
+ `${sheet.treatment.usage.clips} clips will be converted.`);
1573
+ }
1574
+ else if (sheet.treatment.usesElevenLabs) {
1575
+ lines.push("Cost: the narration on this run is voiced by ElevenLabs and billed to your own key. " +
1576
+ `${sheet.treatment.costNote} The clips themselves are not converted.`);
1577
+ }
1557
1578
  for (const notice of sheet.notices)
1558
1579
  lines.push(notice);
1559
1580
  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,6 +352,36 @@ 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;
@@ -372,6 +407,7 @@ export interface WorkflowRun {
372
407
  nodeId: string;
373
408
  approvedAt: number;
374
409
  }[];
410
+ provenance?: RunProvenance;
375
411
  }
376
412
  export type WorkflowRunProjection = Omit<WorkflowRun, "nodes"> & {
377
413
  nodes?: never;
@@ -410,6 +446,7 @@ interface RunFlowOptions {
410
446
  autoApprove?: boolean;
411
447
  imageRigOverrides?: Record<string, unknown>;
412
448
  voices?: VoiceMap;
449
+ voiceTreatment?: VoiceTreatmentBody;
413
450
  wait: boolean;
414
451
  json: boolean;
415
452
  out?: string;
@@ -431,6 +468,11 @@ export declare function rejectTerminalFlag(occurrences: FlagOccurrence[]): void;
431
468
  export declare function parseAutoApproveFlag(args: string[]): boolean;
432
469
  export declare function parseRigOverridesFlag(occurrences: FlagOccurrence[], readFile?: (path: string) => string): Record<string, unknown> | undefined;
433
470
  export declare function parseVoicesFlag(occurrences: FlagOccurrence[], readFile?: (path: string) => string): VoiceMap | undefined;
471
+ export interface VoiceTreatmentBody {
472
+ path: string;
473
+ describe?: Record<string, string>;
474
+ }
475
+ export declare function parseVoiceTreatmentFlag(occurrences: FlagOccurrence[], readFile?: (path: string) => string): VoiceTreatmentBody | undefined;
434
476
  export declare function parseFillFlag(occurrences: FlagOccurrence[]): string | undefined;
435
477
  export declare function formatWorkflowList(workflows: WorkflowListItem[]): string;
436
478
  export declare function formatRecentRuns(runs: WorkflowRunProjection[]): string;
@@ -464,6 +506,7 @@ export declare function botsFlow(opts: {
464
506
  slug?: string;
465
507
  json: boolean;
466
508
  }, deps: WorkflowRunDeps): Promise<FlowResult>;
509
+ export declare function voiceTreatmentLine(provenance: unknown): string | undefined;
467
510
  export declare function runFlow(workflowRef: string, opts: RunFlowOptions, deps: WorkflowRunDeps): Promise<FlowResult>;
468
511
  export declare function statusFlow(opts: {
469
512
  id?: string;
@@ -20,7 +20,7 @@ Usage:
20
20
  exodus workflow templates [list] [--json]
21
21
  exodus workflow templates export <key> [--out <file>] [--json]
22
22
  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]
23
+ 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
24
  exodus workflow status [--json]
25
25
  exodus workflow status --id <runId> [--out <dir>] [--json]
26
26
  exodus workflow cancel <runId> [--reason "..."] [--json]
@@ -102,6 +102,29 @@ Flags:
102
102
  <run>" at the storyboard review instead. Voice ids
103
103
  belong to the brand's own folder — never put them in a
104
104
  shared workflow or template.
105
+ --voice-treatment <v> (run) Say HOW this run makes its voices. Two kinds of
106
+ answer. A name on its own picks a way of working that
107
+ uses voices you already have:
108
+ --voice-treatment sts-revoice
109
+ --voice-treatment lipsync-retarget --voices @voices.json
110
+ The other kind has the video model speak the lines
111
+ itself, in a voice you WRITE instead of one you pick.
112
+ Those need a description for every speaker in your
113
+ script, so they take a JSON object, or @path to a .json
114
+ file holding one:
115
+ --voice-treatment @voice-treatment.json
116
+ --voice-treatment '{"path":"native-prompt","describe":{"HOST 1":"Dry, tired baritone."}}'
117
+ Written voices and --voices are never used together:
118
+ the video model owns the voice on those runs, so
119
+ ElevenLabs voices would be paid for and never heard,
120
+ and asking for both stops the launch. Anything the run
121
+ could not actually do — a treatment this stack has not
122
+ switched on, a Video step on a model that cannot do it,
123
+ a missing key, a rig that shows every line as on-screen
124
+ text — stops the launch before the run is created, and
125
+ the message says which. Descriptions are chosen HERE,
126
+ at the start: "exodus video voices <run>" shows them at
127
+ the review but cannot change them.
105
128
  --auto-approve (run) Deliberately unattended launch: every Checkpoint
106
129
  box this run stops at is approved automatically, with
107
130
  whatever it was holding left exactly as it is, and the
@@ -194,6 +217,7 @@ Examples:
194
217
  exodus workflow run "Product Shots" --rig-overrides '{"rig_1":{"lines":{"line_1":{"count":3}}}}'
195
218
  exodus workflow run "Product Shots" --rig-overrides @rig.json --wait
196
219
  exodus workflow run "Podcast Ad" --input script=@script.txt --voices @voices.json --wait
220
+ exodus workflow run "Podcast Ad" --input script=@script.txt --voice-treatment @voice-treatment.json --wait
197
221
  exodus workflow run "Launch Flow" --wait --out ./deliverables
198
222
  exodus workflow status # recent runs + their ids
199
223
  exodus workflow status --id wr_123
@@ -268,6 +292,20 @@ Notes:
268
292
  no speaker names to key on at the start, so it is refused with a pointer at
269
293
  the review-time command. Keep voice ids in the brand's own folder, never in a
270
294
  shared workflow or template. (#1846)
295
+ "workflow run --voice-treatment" says HOW a run makes its voices. Some ways of
296
+ working play a voice you picked (that is today's behaviour, and it still takes
297
+ --voices); others have the video model speak the CAST's lines itself, in voices
298
+ you describe in words. A described voice needs one description per speaker,
299
+ because a speaker nobody described gets a voice the model invents afresh on
300
+ every clip, and the drift only shows up once the clips are paid for. Written
301
+ voices and --voices are refused together, since the ElevenLabs voices would
302
+ never be heard. Everything the run could not do — a way of working this stack
303
+ has not switched on, a Video step whose model cannot do it, a missing key, a
304
+ rig that shows every line as on-screen text — stops the launch before the run
305
+ is created. The ways of working beyond the two shipped ones are switched on
306
+ only on the test stack while they are being tried out, so a live run refuses
307
+ them by name. Both the launch receipt and "workflow status" say which way a
308
+ run took, and "exodus video voices <run>" shows each written voice. (#1869)
271
309
  workflow triggers are addressed by 1-based position — a trigger carries no id,
272
310
  so the CLI reads the live list from the export contract and sends a fingerprint
273
311
  of the trigger's fields as the guard; a concurrent edit fails loud rather than
@@ -310,6 +348,24 @@ Notes:
310
348
  ids it wants). The "gate" verbs retired in 2.0 along with the Gate node — the
311
349
  command now prints a pointer at "checkpoint" and exits 1.
312
350
  `.trim();
351
+ export function formatRulesLine(provenance) {
352
+ const format = provenance?.format;
353
+ if (!format || typeof format !== "object")
354
+ return undefined;
355
+ const { rigName, specVersion, nodeId, rulesFrom } = format;
356
+ if (typeof rigName !== "string" || rigName === "")
357
+ return undefined;
358
+ const version = typeof specVersion === "string" && specVersion !== ""
359
+ ? `, version ${specVersion}`
360
+ : "";
361
+ const where = typeof nodeId === "string" && nodeId !== ""
362
+ ? ` (from the "${nodeId}" Rig box)`
363
+ : " (from the Show's rig)";
364
+ const borrowed = rulesFrom && typeof rulesFrom.rigName === "string"
365
+ ? ` The rules come from "${rulesFrom.rigName}".`
366
+ : "";
367
+ return `Format rules: ${rigName}${version}${where}.${borrowed}`;
368
+ }
313
369
  const LIST_PATH = "/api/v2/workflows";
314
370
  const RUN_PATH = "/api/v2/workflows/run";
315
371
  const STATUS_PATH = "/api/v2/workflow";
@@ -354,6 +410,7 @@ const VALUE_FLAGS = new Set([
354
410
  "reject",
355
411
  "rig-overrides",
356
412
  "voices",
413
+ "voice-treatment",
357
414
  "note",
358
415
  ]);
359
416
  function defaultMkdirp(dir) {
@@ -976,6 +1033,57 @@ export function parseVoicesFlag(occurrences, readFile) {
976
1033
  }
977
1034
  return parsed;
978
1035
  }
1036
+ export function parseVoiceTreatmentFlag(occurrences, readFile) {
1037
+ const usage = "--voice-treatment requires a treatment name, JSON, or @path/to/voice-treatment.json";
1038
+ let raw;
1039
+ for (const { flag, value } of occurrences) {
1040
+ if (flag !== "voice-treatment")
1041
+ continue;
1042
+ if (value === undefined || value.startsWith("--"))
1043
+ throw new Error(usage);
1044
+ raw = value;
1045
+ }
1046
+ if (raw === undefined)
1047
+ return undefined;
1048
+ let text = raw.trim();
1049
+ if (!text)
1050
+ throw new Error(usage);
1051
+ let fromFile = false;
1052
+ if (text.startsWith("@@")) {
1053
+ text = text.slice(1);
1054
+ }
1055
+ else if (text.startsWith("@")) {
1056
+ const filePath = text.slice(1);
1057
+ if (!filePath) {
1058
+ throw new Error("--voice-treatment @<file> needs a file path after \"@\"");
1059
+ }
1060
+ if (!readFile) {
1061
+ throw new Error(`--voice-treatment: cannot load @${filePath} here (no file access)`);
1062
+ }
1063
+ try {
1064
+ text = readFile(filePath);
1065
+ }
1066
+ catch (e) {
1067
+ const msg = e instanceof Error ? e.message : String(e);
1068
+ throw new Error(`--voice-treatment: could not read file "${filePath}": ${msg}`);
1069
+ }
1070
+ fromFile = true;
1071
+ }
1072
+ if (!fromFile && !text.startsWith("{"))
1073
+ return { path: text };
1074
+ let parsed;
1075
+ try {
1076
+ parsed = JSON.parse(text);
1077
+ }
1078
+ catch (e) {
1079
+ const msg = e instanceof Error ? e.message : String(e);
1080
+ throw new Error(`--voice-treatment is not valid JSON: ${msg}`);
1081
+ }
1082
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1083
+ throw new Error('--voice-treatment must be a treatment name, or a JSON object like {"path":"native-prompt","describe":{"HOST 1":"..."}}');
1084
+ }
1085
+ return parsed;
1086
+ }
979
1087
  export function parseFillFlag(occurrences) {
980
1088
  let name;
981
1089
  for (const { flag, value } of occurrences) {
@@ -1095,6 +1203,12 @@ export function formatWorkflowRun(run) {
1095
1203
  lines.push(`status: ${runVerdict(run)}${counts ? ` (${counts})` : ""}`);
1096
1204
  if (run.isTerminal)
1097
1205
  lines.push("terminal: yes");
1206
+ const rulesLine = formatRulesLine(run.provenance);
1207
+ if (rulesLine)
1208
+ lines.push(rulesLine);
1209
+ const treatmentLine = voiceTreatmentLine(run.provenance);
1210
+ if (treatmentLine)
1211
+ lines.push(treatmentLine);
1098
1212
  if (run.error)
1099
1213
  lines.push(`error: ${run.error}`);
1100
1214
  if (Object.keys(run.inputs ?? {}).length > 0) {
@@ -1786,6 +1900,23 @@ function launchVoicesLine(attached) {
1786
1900
  }
1787
1901
  return said.length > 0 ? `Voices: ${said.join(", ")}` : undefined;
1788
1902
  }
1903
+ export function voiceTreatmentLine(provenance) {
1904
+ const voice = provenance?.voice;
1905
+ if (!voice || typeof voice !== "object")
1906
+ return undefined;
1907
+ if (voice.source === "unreadable") {
1908
+ return ("Voice treatment: the setting saved when this run started cannot be read, so no " +
1909
+ "clip will be made. Start the run again.");
1910
+ }
1911
+ const path = voice.path;
1912
+ if (typeof path !== "string" || path === "")
1913
+ return undefined;
1914
+ const where = voice.source === "show" ? "from its Show" : "chosen at launch";
1915
+ const described = voice.source === "launch" && Array.isArray(voice.descriptions) && voice.descriptions.length > 0
1916
+ ? `. Written voices: ${voice.descriptions.map((d) => d.speaker).join(", ")}`
1917
+ : "";
1918
+ return `Voice treatment: ${path} (${where})${described}`;
1919
+ }
1789
1920
  export async function runFlow(workflowRef, opts, deps) {
1790
1921
  let workflowId;
1791
1922
  try {
@@ -1829,6 +1960,7 @@ export async function runFlow(workflowRef, opts, deps) {
1829
1960
  ...(opts.autoApprove === true ? { autoApprove: true } : {}),
1830
1961
  ...(opts.imageRigOverrides ? { imageRigOverrides: opts.imageRigOverrides } : {}),
1831
1962
  ...(opts.voices ? { voices: opts.voices } : {}),
1963
+ ...(opts.voiceTreatment ? { voiceTreatment: opts.voiceTreatment } : {}),
1832
1964
  launchedVia: "cli",
1833
1965
  };
1834
1966
  const start = await deps.post(RUN_PATH, body);
@@ -1843,6 +1975,12 @@ export async function runFlow(workflowRef, opts, deps) {
1843
1975
  if (opts.json && !opts.wait)
1844
1976
  return { code: 0, lines: [JSON.stringify(base)] };
1845
1977
  const voicesLine = launchVoicesLine(data?.launchVoices);
1978
+ const provenance = { format: data?.format ?? null, voice: data?.voice ?? null };
1979
+ const rulesLine = formatRulesLine(provenance);
1980
+ const treatmentLine = voiceTreatmentLine(provenance);
1981
+ const advisories = Array.isArray(data?.advisories)
1982
+ ? data.advisories.filter((line) => typeof line === "string")
1983
+ : [];
1846
1984
  const lines = opts.json
1847
1985
  ? []
1848
1986
  : [
@@ -1850,7 +1988,10 @@ export async function runFlow(workflowRef, opts, deps) {
1850
1988
  "Workflow run started.",
1851
1989
  `runId: ${data.runId}`,
1852
1990
  `triggerRunId: ${data.triggerRunId}`,
1991
+ ...(rulesLine ? [rulesLine] : []),
1992
+ ...advisories,
1853
1993
  ...(voicesLine ? [voicesLine] : []),
1994
+ ...(treatmentLine ? [treatmentLine] : []),
1854
1995
  `Poll: exodus workflow status --id ${data.runId}`,
1855
1996
  ];
1856
1997
  if (!opts.wait) {
@@ -2957,7 +3098,7 @@ export async function run(flags, occurrences) {
2957
3098
  const workflowRef = rest[0];
2958
3099
  if (!workflowRef) {
2959
3100
  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]");
3101
+ 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
3102
  process.exit(1);
2962
3103
  }
2963
3104
  let inputs;
@@ -2965,6 +3106,7 @@ export async function run(flags, occurrences) {
2965
3106
  let autoApprove;
2966
3107
  let imageRigOverrides;
2967
3108
  let voices;
3109
+ let voiceTreatment;
2968
3110
  try {
2969
3111
  inputs = parseRawInputFlags(occurrences);
2970
3112
  rejectTerminalFlag(occurrences);
@@ -2972,6 +3114,7 @@ export async function run(flags, occurrences) {
2972
3114
  autoApprove = parseAutoApproveFlag(process.argv.slice(3));
2973
3115
  imageRigOverrides = parseRigOverridesFlag(occurrences, defaultDeps.readFile);
2974
3116
  voices = parseVoicesFlag(occurrences, defaultDeps.readFile);
3117
+ voiceTreatment = parseVoiceTreatmentFlag(occurrences, defaultDeps.readFile);
2975
3118
  }
2976
3119
  catch (e) {
2977
3120
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -2983,6 +3126,7 @@ export async function run(flags, occurrences) {
2983
3126
  autoApprove,
2984
3127
  imageRigOverrides,
2985
3128
  voices,
3129
+ voiceTreatment,
2986
3130
  wait: flags["wait"] === true,
2987
3131
  json,
2988
3132
  out: flagString(flags, "out"),
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.1900-beta.2",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",