@aicopycoders/exodus 2026.9.1900-beta.0 → 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
@@ -263,6 +274,7 @@ Then, on either route:
263
274
  exodus video status <runId> [--json] the park and each scene's clip/voice/picture
264
275
  exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
265
276
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "<t>"] [--json] redo one clip at the final watch
277
+ exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json] redo only the voice on finished clips; no new video
266
278
  exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
267
279
  Cut: your choice of tool — Exodus does not stitch or mix. One option ships with the skill:
268
280
  node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
@@ -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
 
@@ -100,6 +102,7 @@ exodus video status <runId> where it is, per scene
100
102
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
101
103
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"]
102
104
  redo one clip at the final watch
105
+ exodus video revoice <runId> --all | --scene <n> redo only the voice; no new video
103
106
  … make the cut (your choice of tool)
104
107
  exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
105
108
  exodus video approve <runId> make it the ad
@@ -176,6 +179,7 @@ Verified against the routes, not assumed. Everything here is admin-gated on top.
176
179
  | script-turn edits at the gate | **refused** | dashboard only |
177
180
  | `video retry-frame` | works | works |
178
181
  | `video retry-clip` | works | works |
182
+ | `video revoice` | works | works |
179
183
  | `video pull` | works | works |
180
184
  | `video upload` | only if the video node sets `finalWatch: true` | works |
181
185
  | `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
@@ -254,6 +258,13 @@ CTA:
254
258
  THE SOFTGEL: Tap below and give your eyes ClearBlink.
255
259
  ```
256
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
+
257
268
  ## Reading the storyboard gate
258
269
 
259
270
  `storyboard <runId>` prints one card per scene on both routes: who speaks, the
@@ -277,9 +288,11 @@ whole loop unattended.
277
288
 
278
289
  ## Giving the characters voices
279
290
 
280
- By default a workflow run's characters have no voice chosen, so every clip keeps
281
- the voice the video model invents. `exodus video voices` is where that gets
282
- fixed, and it only works **before** the storyboard is approved — once clips start
291
+ By default a workflow run's characters have no voice of their own. A character
292
+ without one gets the run's default voice (the workflow's narrator voice) when the
293
+ run has one; with no default either, the clip keeps the voice the video model
294
+ invents. `exodus video voices` shows which of the three applies and is where it
295
+ gets fixed, and it only works **before** the storyboard is approved — once clips start
283
296
  it refuses.
284
297
 
285
298
  ```
@@ -353,6 +366,88 @@ written by a bot is refused with a pointer at the review-time command, because
353
366
  nobody knows the speaker names yet. When the storyboard arrives, the voices are
354
367
  already set and `exodus video voices <runId>` shows them.
355
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
+
356
451
  **The JSON receipt** (`--json`), the same shape for reading and for setting:
357
452
 
358
453
  ```
@@ -361,9 +456,15 @@ runId
361
456
  changed[] the characters whose voice actually moved (set only)
362
457
  canChange false once the run has left the storyboard review
363
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"
364
461
  cast[] one per character
365
462
  characterId, name
366
- voice { voiceId, label } or null
463
+ voice { voiceId, label }: the voice this character's clips are
464
+ converted to. null only when they have no voice of their
465
+ own AND the run has no default voice
466
+ voiceFrom "own-pin" or "run-default"; absent when voice is null
467
+ description the WRITTEN voice, when the run was given one
367
468
  availability { state: "available" | "missing" | "not-checked", … }
368
469
  spokenScenes how many scenes this character speaks in
369
470
  spokenSeconds those scenes' planned seconds — what ElevenLabs bills on
@@ -378,8 +479,8 @@ A failure is the same shape every `video` verb uses:
378
479
  `{ "ok": false, "status": 400, "error": "<one plain sentence>" }`, exit 1.
379
480
 
380
481
  **Before you approve, read `notices`.** The ones that cost the user something:
381
- a character who speaks but has no voice (those clips keep their generated
382
- voice), no ElevenLabs key saved on the run (no voice can be applied at all), and
482
+ a character who speaks but has no voice of their own and no run default to fall
483
+ back on (those clips keep their generated voice), no ElevenLabs key saved on the run (no voice can be applied at all), and
383
484
  a voice ElevenLabs no longer has.
384
485
 
385
486
  `estimatedCostUsd` is always null. Exodus cannot price a voice conversion yet;
@@ -445,6 +546,19 @@ scenes[] one per scene, in order
445
546
  speechTrimmed the clip was re-cut to its spoken words
446
547
  (0.5 s before the first, 0.8 s after the
447
548
  last); null when there is no clip
549
+ rawStorageId the stored file the video model made, kept
550
+ on record when the voice pass replaced the
551
+ clip with a new file; null when the clip IS
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
448
562
  failed[] files that did not download: {file, url, error}
449
563
  ```
450
564
 
@@ -493,13 +607,16 @@ own ElevenLabs render.
493
607
  **Which scenes carry their voice where.** A scene whose storyboard entry has a
494
608
  non-empty `voText` is a narrator scene: its clip is rendered silent and the
495
609
  voice arrives as a separate track. A scene with on-camera `dialogue[]` has the
496
- speech inside the clip — under the default voice path the video model's invented
497
- voice is then replaced, in place, with the cast member's pinned ElevenLabs voice
498
- (that is `revoiced: true`). Other voice paths leave the render owning its own
499
- audio, e.g. lip-sync retargeting a VO track onto the video. The path is frozen
500
- on the run — `--voice-path` on `video start` sets it on a Show run, the node
501
- config sets it on a workflow run. Read `wordsFrom` and `revoiced` per scene
502
- 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.
503
620
 
504
621
  **Word timings** (`scene-NN.words.json`). Seconds from the start of the file
505
622
  `wordsFrom` names, always. `wordsFrom: "clip"` is an ElevenLabs Scribe
@@ -610,6 +727,65 @@ and compare the file before and after.
610
727
  the CLI says so when that is the case. Pull again, re-cut and upload again before
611
728
  anyone approves.
612
729
 
730
+ ## Redoing only the voice on finished clips
731
+
732
+ `revoice` puts clips that are already paid for back through the voice pass: the
733
+ spoken words are checked against the script, the clip is trimmed to the speech,
734
+ and the chosen voice is applied. It makes no new video, so the picture cannot
735
+ change and no video credits are spent. ElevenLabs bills the voice work to the
736
+ member's own key.
737
+
738
+ ```
739
+ exodus video revoice <runId> --all
740
+ exodus video revoice <runId> --scene 3
741
+ ```
742
+
743
+ **When to reach for it.** `status` shows talking clips with `voice-unpinned` or
744
+ `voice-not-applied` (usually next to `speech-check-skipped`), and the manifest
745
+ shows `revoiced: false` with no words file. The usual cause is a run that had no ElevenLabs key when its
746
+ clips were made. `voices <runId>` says so: "This run has no ElevenLabs key saved".
747
+
748
+ **It finds the key by itself.** Before queuing anything, the server gives the run
749
+ the ElevenLabs key saved on the run owner's account, if the run has none. When
750
+ there is no saved key either, nothing is queued and the refusal says where to add
751
+ one (Settings → Keys). Approving a storyboard does the same top-up, so an older
752
+ run parked at its storyboard is fixed before any clip is paid for.
753
+
754
+ **`--all` picks the clips that need it.** Every finished clip whose file is still
755
+ the video model's own and whose findings carry `voice-unpinned` or
756
+ `voice-not-applied`, the two codes that mean a voice change should have happened
757
+ and did not. `speech-check-skipped` alone does not qualify: it also lands on
758
+ clips whose sound the video model or a later step owns, where there is no voice
759
+ to change and a pass would bill a transcription for nothing. `--scene <n>` is
760
+ not filtered this way; the server decides. It sends one request per scene, tries
761
+ every one, and reports each. Scenes refused with the same sentence share one
762
+ line (a run with no key prints that sentence once); `--json` keeps one entry per
763
+ scene.
764
+
765
+ **What is refused.** A clip that already went through the voice pass (`revoiced`,
766
+ `speechTrimmed` or a trimmed tail): the pass would trim or convert it twice. Use
767
+ `retry-clip` to make that clip again from scratch. A failed clip, a scene already
768
+ being redone, a step still rendering, and an ad already delivered.
769
+
770
+ **An older server starts nothing.** The voice redo has its own server route and
771
+ its own worker task. A server or worker from before it existed cannot run it, and
772
+ the CLI never falls back to `retry-clip`'s route, which would make new paid video.
773
+ You get one sentence saying the server does not have the voice redo yet, and
774
+ nothing is queued or spent.
775
+
776
+ **A clip is never lost.** The clip stays on its row the whole time. If the voice
777
+ pass cannot finish, the clip, its findings and its cost are left as they were and
778
+ one warning, `revoice-not-finished`, is added. Run it again. If ElevenLabs refuses
779
+ the key, the clip comes back unchanged with `voice-not-applied`.
780
+
781
+ **If the word check now fails.** The voiced clip is still delivered, and the scene
782
+ is flagged with the finding, because the picture is the same one the ad already
783
+ had. Decide from `status`: keep it, or `retry-clip` it.
784
+
785
+ **Afterwards.** Pull again. Each voiced scene now has `revoiced: true`, a words
786
+ file, and `rawStorageId` naming the original file. Re-cut and upload again if a
787
+ cut was already uploaded; the CLI says so when that is the case.
788
+
613
789
  ## Cutting the ad
614
790
 
615
791
  Editing is the part every user does differently, and the team is deliberately
@@ -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
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { dirname, join } from "node:path";
7
7
  import { getVersion } from "../lib/version.js";
8
+ import { parseArgs } from "../lib/args.js";
8
9
  const OTHER_COMMANDS = new Set([
9
10
  "browse",
10
11
  "status",
@@ -113,42 +114,8 @@ async function buildHelp() {
113
114
  ];
114
115
  return sections.filter((s) => s.trim() !== "").join("\n\n");
115
116
  }
116
- function parseArgs(argv) {
117
- const [, , rawCommand = "help", ...rest] = argv;
118
- const command = ["--help", "-h", "-help"].includes(rawCommand) ? "help" : rawCommand;
119
- const flags = {};
120
- let i = 0;
121
- while (i < rest.length) {
122
- const arg = rest[i];
123
- if (arg === "--help" || arg === "-h") {
124
- flags["help"] = true;
125
- i++;
126
- continue;
127
- }
128
- if (arg.startsWith("--")) {
129
- const key = arg.slice(2);
130
- const next = rest[i + 1];
131
- if (key.startsWith("no-")) {
132
- flags[key.slice(3)] = false;
133
- i++;
134
- }
135
- else if (next !== undefined && !next.startsWith("--")) {
136
- flags[key] = next;
137
- i += 2;
138
- }
139
- else {
140
- flags[key] = true;
141
- i++;
142
- }
143
- }
144
- else {
145
- i++;
146
- }
147
- }
148
- return { command, flags };
149
- }
150
117
  async function main() {
151
- const { command, flags } = parseArgs(process.argv);
118
+ const { command, flags, occurrences } = parseArgs(process.argv);
152
119
  if (["--version", "-v", "-V", "version"].includes(command)) {
153
120
  console.log(getVersion());
154
121
  process.exit(0);
@@ -180,7 +147,7 @@ async function main() {
180
147
  console.error(`Command "${command}" does not export a run() function.`);
181
148
  process.exit(1);
182
149
  }
183
- await commandModule.run(flags);
150
+ await commandModule.run(flags, occurrences);
184
151
  }
185
152
  main().catch((err) => {
186
153
  console.error("Fatal error:", err instanceof Error ? err.message : String(err));
@@ -1,4 +1,6 @@
1
1
  import { type ApiResponse } from "../lib/client.js";
2
+ import type { FlagOccurrence } from "../lib/args.js";
3
+ import { type RunProvenance } from "./workflow.js";
2
4
  export declare const helpText: string;
3
5
  export declare const VOICES_PATH = "/api/v2/video/voices";
4
6
  export interface ClipWord {
@@ -43,6 +45,8 @@ export type ArtifactSubset = {
43
45
  final?: boolean;
44
46
  revoiced?: boolean;
45
47
  speechTrimmed?: boolean;
48
+ tailTrimmed?: boolean;
49
+ rawStorageId?: string;
46
50
  } | {
47
51
  type: "audio";
48
52
  sceneIndex?: number;
@@ -97,6 +101,7 @@ export interface VideoRun {
97
101
  pausedNodeId?: string;
98
102
  nodes: VideoRunNode[];
99
103
  castLock?: PullCastLock | null;
104
+ provenance?: RunProvenance;
100
105
  workflowId?: string;
101
106
  moduleOwned?: boolean;
102
107
  }
@@ -202,6 +207,7 @@ export interface ManifestScene {
202
207
  qc: ClipQc | null;
203
208
  revoiced: boolean | null;
204
209
  speechTrimmed: boolean | null;
210
+ rawStorageId: string | null;
205
211
  clipStatus: string;
206
212
  error: string | null;
207
213
  flagged: boolean;
@@ -221,6 +227,7 @@ export interface ManifestCastRef {
221
227
  error: string | null;
222
228
  voiceId: string | null;
223
229
  voiceLabel: string | null;
230
+ voiceDescription: string | null;
224
231
  }
225
232
  export interface VideoManifest {
226
233
  runId: string;
@@ -236,6 +243,7 @@ export interface VideoManifest {
236
243
  } | null;
237
244
  scenes: ManifestScene[];
238
245
  failed: PullFailure[];
246
+ provenance?: RunProvenance;
239
247
  }
240
248
  export declare const CAST_LEDGER_BASE = 910000;
241
249
  export interface PullPlan {
@@ -288,6 +296,11 @@ export interface ClipRedoTarget {
288
296
  }
289
297
  export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
290
298
  export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
299
+ export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
300
+ export type RevoiceTarget = ClipRedoTarget | {
301
+ all: true;
302
+ };
303
+ export declare function revoiceFlow(runId: string, target: RevoiceTarget, json: boolean, deps: VideoDeps): Promise<FlowResult>;
291
304
  export interface CastVoiceRow {
292
305
  characterId: string;
293
306
  name: string;
@@ -295,6 +308,7 @@ export interface CastVoiceRow {
295
308
  voiceId: string;
296
309
  label?: string;
297
310
  } | null;
311
+ voiceFrom?: "own-pin" | "run-default";
298
312
  availability?: {
299
313
  state: "available";
300
314
  providerName: string;
@@ -304,6 +318,7 @@ export interface CastVoiceRow {
304
318
  state: "not-checked";
305
319
  why: string;
306
320
  };
321
+ description?: string;
307
322
  spokenScenes: number;
308
323
  spokenSeconds: number;
309
324
  }
@@ -317,6 +332,9 @@ export interface CastVoiceSheet {
317
332
  label?: string;
318
333
  } | null;
319
334
  treatment: {
335
+ kind?: string;
336
+ path?: string | null;
337
+ usesElevenLabs?: boolean;
320
338
  summary: string;
321
339
  provider: string;
322
340
  model: string;
@@ -334,7 +352,7 @@ export type VoiceMap = Record<string, string | {
334
352
  voiceId: string;
335
353
  label?: string;
336
354
  } | null>;
337
- export declare function parseVoiceFlags(argv: readonly string[], readFile: (path: string) => string): VoiceMap | null;
355
+ export declare function parseVoiceFlags(occurrences: FlagOccurrence[], readFile: (path: string) => string): VoiceMap | null;
338
356
  export declare function voicesFlow(runId: string, voices: VoiceMap | null, json: boolean, deps: VideoDeps): Promise<FlowResult>;
339
357
  export declare function voiceSheetLines(sheet: CastVoiceSheet): string[];
340
358
  export declare function pullFlow(runId: string, dir: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
@@ -343,4 +361,4 @@ export declare const NO_DURATION_MESSAGE: string;
343
361
  export declare function describeFetchFailure(err: unknown): string;
344
362
  export declare function uploadFlow(runId: string, filePath: string, durationFlag: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
345
363
  export declare function parsePositional(args?: string[]): string[];
346
- export declare function run(flags: Record<string, string | boolean>): Promise<void>;
364
+ export declare function run(flags: Record<string, string | boolean>, occurrences: FlagOccurrence[]): Promise<void>;