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

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. 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. |
64
+ | **Video** | `exodus-video` | Make a video ad from a script by running a saved video workflow (`npx @aicopycoders/exodus workflow run`). 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. |
@@ -16,7 +16,7 @@ In Claude Code you don't click buttons. You say **"exodus"** plus what you want
16
16
  | exodus-meme | Meme Ad Generator — recommend formats, one batched server-side run |
17
17
  | exodus-workflow | Run/build saved multi-node workflows, resolve inbox gate/repair parks, continue sessions, fire triggers, read banks + promote winners |
18
18
  | exodus-hooks | Read the Scout library of hook cards — composable filters, one-card detail, pattern matches, CSV export (read-only) |
19
- | exodus-video | Make a video ad from a Show — start, storyboard gate, pull every piece + manifest, cut it in whatever editor you use, upload, approve (admin-only) |
19
+ | exodus-video | Make a video ad from a script by running a saved video workflow — storyboard gate, pull every piece + manifest, cut it in whatever editor you use, upload, approve (admin-only) |
20
20
  | exodus-browse | View History and Surface the Right Run |
21
21
  | exodus-drive | Google Drive, Docs, Sheets via the Dashboard's OAuth |
22
22
  | exodus-winners | Mine your own Meta ad account for winners (needs the Meta Ads MCP) and import them into Exodus |
@@ -252,24 +252,20 @@ Returns: a table, --json data, or an exported CSV/JSON file
252
252
 
253
253
  ## exodus-video
254
254
 
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.
255
+ **What it does:** Makes a video ad from a script, in pieces. You run a saved video workflow (`exodus workflow run <name> --input <field>=@script.txt`). 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`. 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.
256
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.
257
+ **Voices.** 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 `--voice-treatment` 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
258
 
259
259
  ```operator-guide
260
- Start (workflow route, no Show):
260
+ Start:
261
261
  exodus workflow list / describe <name> find it and see what it needs
262
262
  exodus workflow run <workflowId|name> --input <field>=@script.txt [--wait] start it; --wait stops at the storyboard gate
263
263
  exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json ... and give each speaker an ElevenLabs voice up front
264
264
  exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json ... or have the video model speak voices you WROTE
265
- Start (Show route):
266
- exodus video shows [--json] Shows and whether each is ready
267
- exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
268
- Then, on either route:
265
+ Then:
269
266
  exodus video storyboard <runId> [--json] the scene cards
270
267
  exodus video voices <runId> [--set <who>=<voiceId>] [--clear <who>] [--from <f>] [--json] who speaks, with whose voice
271
268
  exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
272
- exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back — SHOW RUNS ONLY
273
269
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "<t>"] [--json] redo one still at the pixel gate
274
270
  exodus video status <runId> [--json] the park and each scene's clip/voice/picture
275
271
  exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: exodus-video
3
- description: Make a video ad with Exodus from the terminal. The main path is script-first: run a saved video workflow with `npx @aicopycoders/exodus workflow run`, no Show involved. Starting from a Show with `exodus video start --show` is the other way in. Either way you read and approve the storyboard, pull every finished piece (per-scene clips, the narration track and per-scene voice, keyframes, cast identity stills, word timings, the music bed) into a folder with `exodus video pull`, and upload a finished cut for approval. Exodus makes the pieces; the cut is made outside it, in whatever editor the user wants — this skill hands over the pieces and the facts about them, it does not prescribe an editor. Use it whenever the user has invoked Exodus and wants anything to do with a video ad, an ad run's clips or storyboard, a Show, "pull the pieces", "stitch it together", "make the cut", "upload my cut", or checking on a video run ("exodus, make a video ad from this script", "exodus, is my video run done", "exodus, pull the clips for run X and cut them", "exodus, what's wrong with scene 3", "exodus video status"). Also use it when the user ran an `npx @aicopycoders/exodus video` command or the `exodus` hub skill routed here. Video is admin-only: "video isn't enabled for this key" means either the dashboard user lacks the admin role or the run id is wrong, so check the id and then say so; do not retry. Never claim generic video-editing asks ("edit this mp4", "add captions to my reel") without Exodus context; in shared folders those belong to the user's other tools. Running or authoring workflows generally is `exodus-workflow`; static image ads are `exodus-image`; copy is `exodus-write`.
3
+ description: Make a video ad with Exodus from the terminal. The main path is script-first: run a saved video workflow with `npx @aicopycoders/exodus workflow run`. You read and approve the storyboard, pull every finished piece (per-scene clips, the narration track and per-scene voice, keyframes, cast identity stills, word timings, the music bed) into a folder with `exodus video pull`, and upload a finished cut for approval. Exodus makes the pieces; the cut is made outside it, in whatever editor the user wants — this skill hands over the pieces and the facts about them, it does not prescribe an editor. Use it whenever the user has invoked Exodus and wants anything to do with a video ad, an ad run's clips or storyboard, "pull the pieces", "stitch it together", "make the cut", "upload my cut", or checking on a video run ("exodus, make a video ad from this script", "exodus, is my video run done", "exodus, pull the clips for run X and cut them", "exodus, what's wrong with scene 3", "exodus video status"). Also use it when the user ran an `npx @aicopycoders/exodus video` command or the `exodus` hub skill routed here. Video is admin-only: "video isn't enabled for this key" means either the dashboard user lacks the admin role or the run id is wrong, so check the id and then say so; do not retry. Never claim generic video-editing asks ("edit this mp4", "add captions to my reel") without Exodus context; in shared folders those belong to the user's other tools. Running or authoring workflows generally is `exodus-workflow`; static image ads are `exodus-image`; copy is `exodus-write`.
4
4
  ---
5
5
 
6
6
  # Video: make the pieces, cut the ad, hand it back
@@ -21,29 +21,18 @@ Your job in this skill is to drive that loop, hand over the pieces with the
21
21
  facts about them, and make whatever cut the user asked for. **How to edit is the
22
22
  user's call, not this skill's** — see "Cutting the ad".
23
23
 
24
- ## Two routes in, one set of pieces out
24
+ ## How a run starts
25
25
 
26
- **The workflow route (no Show) is the main path.** The ad is a saved workflow in
27
- the brand, started script-first with `exodus workflow run`. This is where the
28
- real work happens and what to assume unless the user says otherwise.
29
-
30
- **The Show route** starts from a locked Show — its cast, rooms and voices set up
31
- on the dashboard beforehand — with `exodus video start --show`. It buys two
32
- things the workflow route does not have: `video flag` for sending a storyboard
33
- back with a note, and script-turn edits at the gate.
34
-
35
- Everything after the storyboard is the same on both: the same nodes render, the
36
- same `video status` reads the run, the same `video pull` writes the same folder,
37
- the same `video upload` and `approve` finish it. Where a behaviour differs, this
38
- skill says which route it belongs to. **Do not assume the routes are symmetric**
39
- — see "What each route can and cannot do".
26
+ The ad is a saved workflow in the brand, started script-first with `exodus
27
+ workflow run`. From there the same nodes render, `video status` reads the run,
28
+ `video pull` writes the folder, and `video upload` and `approve` finish it.
40
29
 
41
30
  ## Before anything
42
31
 
43
32
  - `npx @aicopycoders/exodus video --help` and `workflow --help` are the
44
33
  authoritative flag lists. The `video` verb is admin-only and hidden from the
45
34
  top-level `--help`; that is expected, not a broken install.
46
- - **Video is admin-only on both routes**, and says so two different ways.
35
+ - **Video is admin-only**, and says so two different ways.
47
36
  - "video isn't enabled for this key", from a `video` verb, has two causes the
48
37
  CLI cannot tell apart: the user is not an admin on this brand, or the run id
49
38
  does not exist. Check the id first; if it is right, they need the admin role.
@@ -58,17 +47,17 @@ skill says which route it belongs to. **Do not assume the routes are symmetric**
58
47
  you must pass `--duration <sec>`. Editing tools have their own requirements on
59
48
  top of that — the bundled script needs a full `ffmpeg` with `libx264` and
60
49
  exits 2 if it is missing.
61
- - The brand matters. A workflow and a Show both belong to one brand, so
62
- `exodus workflow list` and `exodus video shows` show the active brand's only.
63
- Wrong list means wrong brand; `exodus brand use <slug>`.
50
+ - The brand matters. A workflow belongs to one brand, so `exodus workflow list`
51
+ shows the active brand's only. Wrong list means wrong brand; `exodus brand use
52
+ <slug>`.
64
53
 
65
54
  ## The loop
66
55
 
67
56
  Run each line as `npx @aicopycoders/exodus …`; `exodus` below is shorthand.
68
57
 
69
- **Starting on the workflow route (the main path).** The workflow is already
70
- saved in the brand — find it and read what it wants before you run it, because
71
- the script's input field is named by the workflow, not by this skill.
58
+ **Starting a run.** The workflow is already saved in the brand — find it and
59
+ read what it wants before you run it, because the script's input field is named
60
+ by the workflow, not by this skill.
72
61
 
73
62
  ```operator-guide
74
63
  exodus workflow list the brand's workflows
@@ -82,21 +71,13 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatm
82
71
  exodus video status <runId> where it is, per scene
83
72
  ```
84
73
 
85
- **Starting on the Show route.**
86
-
87
- ```operator-guide
88
- exodus video shows which Shows are ready
89
- exodus video start --show <id> --script script.txt --wait start, wait for the storyboard gate
90
- ```
91
-
92
- **From the storyboard on, both routes are the same.**
74
+ **From the storyboard on.**
93
75
 
94
76
  ```operator-guide
95
77
  exodus video storyboard <runId> read the scene cards
96
78
  exodus video voices <runId> who speaks and with whose voice
97
79
  exodus video voices <runId> --set <character>=<voiceId> give a character a voice
98
80
  exodus video approve <runId> keep going
99
- exodus video flag <runId> --note "what's wrong" send it back — SHOW RUNS ONLY
100
81
  exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
101
82
  exodus video status <runId> where it is, per scene
102
83
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
@@ -111,31 +92,32 @@ exodus video approve <runId> make it the ad
111
92
  Every command takes `--json`. Read `--json` when you need to decide something
112
93
  from the output; read the plain form when you are relaying it to the user.
113
94
 
114
- **Read a run's position with `exodus video status`, on either route.** It is the
115
- richest reader — every scene's clip, voice and picture, with the findings. A
116
- `--wait` that landed already printed the same stop block, and `workflow inbox`
117
- lists a parked video run (its row carries `hasShow`), but neither shows the
118
- per-scene detail, and the checkpoint verbs cannot resolve a video park — see
119
- "What each route can and cannot do".
95
+ **Read a run's position with `exodus video status`.** It is the richest reader —
96
+ every scene's clip, voice and picture, with the findings. A `--wait` that landed
97
+ already printed the same stop block, and `workflow inbox` lists a parked video
98
+ run, but neither shows the per-scene detail, and the checkpoint verbs cannot
99
+ resolve a video park — see "What the CLI can and cannot do".
120
100
 
121
101
  **Where a run can stop.** Five stops, each with the exact next command printed
122
102
  under it:
123
103
 
124
- - storyboard gate: needs `approve`, or `flag` on a Show run.
104
+ - storyboard gate: needs `approve`. When the stop block prints a `Redo one
105
+ frame:` line with a node id, one still can be redone first with `retry-frame`;
106
+ a gate parked before the stills are drawn has nothing to redraw.
125
107
  - final watch: every piece is made; needs `pull`, a cut, and `upload`. It is also
126
- the one park where a finished clip can be redone, with `retry-clip`. A
127
- workflow run only reaches this park if its video node sets `finalWatch: true`
128
- — otherwise it skips straight to finished.
108
+ the one park where a finished clip can be redone, with `retry-clip`. A run
109
+ only reaches this park if its video node sets `finalWatch: true` — otherwise
110
+ it skips straight to finished.
129
111
  - paused: a builder checkpoint neither video verb resolves —
130
112
  `exodus workflow checkpoint <runId>` is the verb for that one.
131
113
  - failed: `status` shows how far it got, and a `--wait` that was running exits
132
114
  1 with the error. Tell the user; do not start another run on your own.
133
115
  - finished: pull what it made.
134
116
 
135
- **`--wait` stops at both video parks on either route** (#1818). `workflow run
136
- --wait` and `video start --wait` both end at the storyboard gate and again at
137
- the final watch, exit 0, and close with the same `Parked:` block and next
138
- commands that `exodus video status` prints. Under `--json` the line carries an
117
+ **`--wait` stops at both video parks** (#1818). `workflow run --wait` ends at
118
+ the storyboard gate and again at the final watch, exits 0, and closes with the
119
+ same `Parked:` block and next commands that `exodus video status` prints. Under
120
+ `--json` the line carries an
139
121
  additive `stop` field in the same shape `video status --json` uses, so branch on
140
122
  `stop.at` rather than re-deriving the park. Every other park still prints its
141
123
  notice once and keeps polling, so a resolve from another shell carries the run
@@ -147,44 +129,31 @@ minutes with "Still running after 60 minutes" and exit 0; that is not done, so
147
129
  check `status` before treating it as finished. Do not poll `status` in a tight
148
130
  loop; a full run is several minutes.
149
131
 
150
- **What costs money.** Starting the run creates the storyboard. Approving the
151
- storyboard is the cost gate: it releases the paid rendering of every picture,
152
- voice and clip. `shows`, `workflow list`, `workflow describe`, `video status`,
132
+ **What costs money.** Starting the run writes the storyboard and then draws the
133
+ reference, cast and scene pictures, and only then pauses. Each picture is a few
134
+ cents and each scene draws a few to choose from: about 25 cents on a small test,
135
+ and it can approach a dollar on a longer ad. Approving releases the expensive
136
+ part: the voices and the clips. So a run that has not paused yet is already
137
+ spending a little, and a run still drawing pictures is healthy. `exodus workflow
138
+ status` and `exodus video status` both print "This run will pause for your
139
+ approval once the frames are ready" on a run that is headed for the gate. Do not
140
+ cancel it because the pause has not arrived. `workflow list`, `workflow describe`, `video status`,
153
141
  `storyboard` and `pull` are free reads; run them as often as you like. Never
154
- start a second run to "retry" without the user asking; flag the storyboard (Show
155
- runs) or tell them what failed instead.
142
+ start a second run to "retry" without the user asking; tell them what failed
143
+ instead.
156
144
 
157
145
  **`--auto-approve` does not cover either video gate.** It releases Checkpoint
158
146
  boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
159
147
  and the storyboard gate and the final watch carry no pause reason. An unattended
160
- `workflow run --auto-approve` still stops dead at the cost gate — which is the
148
+ `workflow run --auto-approve` still stops dead at the storyboard gate — which is the
161
149
  safe behaviour, since nothing should approve paid rendering or ship an uncut ad
162
150
  on its own.
163
151
 
164
- ## What each route can and cannot do
165
-
166
- Verified against the routes, not assumed. Everything here is admin-gated on top.
167
-
168
- | Verb | Workflow run (no Show) | Show run |
169
- |---|---|---|
170
- | `video shows` | nothing to list | lists the brand's Shows |
171
- | `video start --show` | not the entry point | starts the run |
172
- | `workflow run` | starts the run | not the entry point |
173
- | `video status` | works | works |
174
- | `video storyboard` | works | works |
175
- | `video voices` (read) | works | works |
176
- | `video voices --set` | works | **refused** — the Show owns its voices |
177
- | `video approve` | works, both gates | works, both gates |
178
- | `video flag --note` | **refused** | works |
179
- | script-turn edits at the gate | **refused** | dashboard only |
180
- | `video retry-frame` | works | works |
181
- | `video retry-clip` | works | works |
182
- | `video revoice` | works | works |
183
- | `video pull` | works | works |
184
- | `video upload` | only if the video node sets `finalWatch: true` | works |
185
- | `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
186
-
187
- Five consequences worth knowing before you promise a user anything:
152
+ ## What the CLI can and cannot do
153
+
154
+ Verified against the CLI and the server, not assumed. Everything here is
155
+ admin-gated on top. Five things worth knowing before you promise a user
156
+ anything:
188
157
 
189
158
  - **A workflow only stops for your cut if its video node asks to.** `finalWatch`
190
159
  defaults to `false` (`convex/lib/workflow/catalog.ts:780`), and the run only
@@ -204,22 +173,20 @@ Five consequences worth knowing before you promise a user anything:
204
173
  `pauseReason === "checkpoint"` (`exodus/commands/workflow.ts:4487-4519`), and
205
174
  both video gates park with no pause reason at all, so those verbs refuse
206
175
  client-side with "Run … is not parked for a checkpoint approval — it is parked
207
- at the cost gate (legacy)." `exodus video approve` posts straight through and
208
- works on either route. `exodus workflow cancel` also works at a video gate; it
209
- has no preflight.
210
-
211
- - **`flag` is Show-only and fails loudly.** `exodus video flag` on a workflow run
212
- answers "Flag-with-note is only available on Video-module ads."
213
- (`convex/videoModule.ts`, guarded on `!run.showId`). Script-turn edits refuse
214
- the same way. There is **no** CLI path that sends a workflow run's storyboard
215
- back with a note, and none that rewrites its scenes, script or prompts — the
216
- whole-envelope edit mutation is wired only to the dashboard's gate screen, not
217
- to any API route. The one exception is voices: `exodus video voices --set`
218
- writes the cast's voice choices in place and nothing else (see "Giving the
219
- characters voices"). For everything else the storyboard is approve-or-cancel
220
- from the terminal: `exodus workflow cancel <runId>` and run again with a better
221
- script, or edit the workflow. Do not offer `flag` to a user on the workflow
222
- route.
176
+ at the cost gate (legacy)." `exodus video approve` posts straight through.
177
+ `exodus workflow cancel` also works at a video gate; it has no preflight.
178
+
179
+ - **Nothing sends the storyboard back with a note.** There is **no** CLI path
180
+ that returns a run's storyboard for a rewrite, and none that rewrites its
181
+ scenes, script or prompts — the whole-envelope edit mutation is wired only to
182
+ the dashboard's gate screen, not to any API route. The one exception is
183
+ voices: `exodus video voices --set` writes the cast's voice choices in place
184
+ and nothing else (see "Giving the characters voices"). A single still can be
185
+ redrawn with `exodus video retry-frame`. For everything else the storyboard is
186
+ approve-or-cancel from the terminal: `exodus workflow cancel <runId>` and run
187
+ again with a better script, or edit the workflow.
188
+ The stop block a parked run prints may still offer a `Send back:` line. A
189
+ workflow ad refuses it, so never run it and never offer it to the user.
223
190
  - **`--wait` lands on both video parks, but only those.** Since #1818 every
224
191
  `--wait` caller — `workflow run`, `triggers fire`, `checkpoint approve|retry`,
225
192
  `repair retry|skip` — ends at a storyboard gate or a final watch, exits 0, and
@@ -228,20 +195,16 @@ Five consequences worth knowing before you promise a user anything:
228
195
  slots park is still not a landing: it prints its notice once and the loop
229
196
  keeps polling, so a resolve from another shell carries the run on. Do not read
230
197
  a park banner mid-poll as the wait having finished.
231
- - **The run's page link differs, and every link the CLI prints follows the run.**
232
- A Show ad opens on the `/video` page. A showless workflow run opens on
233
- `/workflows/<workflowId>/runs/<runId>`, because `/video` answers "That ad isn't
234
- here" for a run with no Show (`getAdDetail` rejects it;
235
- `convex/http.ts:9204-9211`). Since #1851 the manifest's `dashboardUrl`, the
236
- link in every park block and the link `upload` prints are all minted that way,
237
- so they agree on both routes. When all the CLI holds is a run id it mints
238
- `/runs/<runId>`, the canonical forwarder, which lands on the same page.
198
+ - **Every link the CLI prints follows the run.** A run opens on
199
+ `/workflows/<workflowId>/runs/<runId>`. Since #1851 the manifest's
200
+ `dashboardUrl`, the link in every park block and the link `upload` prints are
201
+ all minted that way, so they agree. When all the CLI holds is a run id it
202
+ mints `/runs/<runId>`, the canonical forwarder, which lands on the same page.
239
203
 
240
204
  ## The script file
241
205
 
242
206
  Plain text. A `CAST:` block naming the speakers, one line per turn, then a
243
- `CTA:` block. On a Show run the names must match what the Show locked. The
244
- planner sizes every line at
207
+ `CTA:` block. The planner sizes every line at
245
208
  2.5 words per second (`WORD_LADDER_WPS`), so a turn near 14 words plans a clip
246
209
  near 6 seconds. If a line does get truncated in its clip, the manifest says so
247
210
  (`speech-cutoff`).
@@ -267,24 +230,23 @@ in `manifest.json` under `provenance`.
267
230
 
268
231
  ## Reading the storyboard gate
269
232
 
270
- `storyboard <runId>` prints one card per scene on both routes: who speaks, the
233
+ `storyboard <runId>` prints one card per scene: who speaks, the
271
234
  line, the planned duration, and whether its picture is drawn. Under the cards it
272
235
  also lists the voices — one line per character who speaks or already has a voice
273
236
  — so the user can see who sounds like whom before paying for the clips. Read it
274
237
  for the user, then ask one question.
275
238
 
276
- On a **workflow run** the question is approve or don't — there is no flag. If
277
- the storyboard is wrong, say so plainly and let the user decide between
278
- approving anyway, cancelling (`exodus workflow cancel <runId>`) and re-running
279
- with a better script, or changing the workflow. Approving is the cost gate, so
280
- do not approve a storyboard you have just told the user is wrong.
239
+ The question is approve or don't. If the storyboard is wrong, say so plainly and
240
+ let the user decide between approving anyway, redrawing a single still when
241
+ the stop block offers it (`exodus video retry-frame <runId> --node <nodeId>
242
+ --scene <n>`, with the node id that block prints), cancelling
243
+ (`exodus workflow cancel <runId>`) and re-running with a better script, or
244
+ changing the workflow. Approving is what releases the voices and the clips — the
245
+ expensive part — so do not approve a storyboard you have just told the user is
246
+ wrong.
281
247
 
282
- On a **Show run** you can also send it back: `video flag <runId> --note "…"`.
283
- The note is read by the model that rewrites the storyboard, so make it concrete
284
- ("scene 2 should be a close-up of the softgel, not the eyes").
285
-
286
- Either way, do not approve on the user's behalf unless they told you to run the
287
- whole loop unattended.
248
+ Do not approve on the user's behalf unless they told you to run the whole loop
249
+ unattended.
288
250
 
289
251
  ## Giving the characters voices
290
252
 
@@ -367,7 +329,7 @@ nobody knows the speaker names yet. When the storyboard arrives, the voices are
367
329
  already set and `exodus video voices <runId>` shows them.
368
330
 
369
331
  **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
332
+ HOW a run makes its voices, and it is the only door. Two
371
333
  kinds of answer.
372
334
 
373
335
  A name on its own picks a way of working that plays a voice you already have,
@@ -440,8 +402,7 @@ person made at a keyboard for one run, and nobody made it for the sub-workflow.
440
402
  **Written voices cannot be changed at the review.** They are chosen when the run
441
403
  starts. `exodus video voices <runId>` on a described run names the treatment and
442
404
  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.
405
+ what changes them. Do not set a voice path in node config — it is not a door.
445
406
 
446
407
  **Where it shows up afterwards.** The launch receipt and `exodus workflow status`
447
408
  both print one line naming the treatment and the speakers who got a written
@@ -457,7 +418,10 @@ changed[] the characters whose voice actually moved (set only)
457
418
  canChange false once the run has left the storyboard review
458
419
  whyNot why, in one sentence, when canChange is false
459
420
  treatment.path which way of making voices this run froze, or null
460
- treatment.kind "performance-then-conversion" or "render-owns-voice"
421
+ treatment.kind "performance-then-conversion", "voiceover-then-lipsync" or
422
+ "render-owns-voice"
423
+ treatment.pinsHeard true when the chosen ElevenLabs voices are heard on this
424
+ run; false only for "render-owns-voice"
461
425
  cast[] one per character
462
426
  characterId, name
463
427
  voice { voiceId, label }: the voice this character's clips are
@@ -514,9 +478,7 @@ manifest.json the index below
514
478
 
515
479
  ```
516
480
  runId, pulledAt, dashboardUrl the run and the page that opens it:
517
- /video?ad=<runId> for a Show ad,
518
- /workflows/<workflowId>/runs/<runId> for a
519
- workflow run
481
+ /workflows/<workflowId>/runs/<runId>
520
482
  storyboard, reference, music filenames, or null when not delivered
521
483
  narration { file, timing }, or null on a per-scene run
522
484
  cast[] one per identity still
@@ -554,8 +516,7 @@ provenance which saved rulebook this run followed:
554
516
  {format: {via, nodeId, rigId, rigName,
555
517
  rulesFrom, specVersion}}. Absent when the
556
518
  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
519
+ that chose it; `rulesFrom` names the rig the
559
520
  rules were borrowed from when the picked
560
521
  rig is a copy. Names, ids and a version
561
522
  only — never the rules themselves
@@ -578,12 +539,10 @@ it is a picture-only takeover of the frame while the spoken track underneath
578
539
  keeps running, so it adds no audio and takes no place in the running order. The
579
540
  envelope also carries `overlays[]` (each with a `cueLineId` and a `placement`)
580
541
  and `audio` (`roomTone`, `music`, `sfxCues[]`) — the planner's notes on what
581
- should sit on top. **Route difference:** on a Show run the image and motion
582
- prompts (`framePrompt`, `videoPrompt`, `referencePrompt`) are stripped before
583
- the file reaches you, for everyone including admins — they are style-pack IP
584
- (`convex/workflows.ts:13847`, `isModuleRun = !!run.showId`). A workflow run has
585
- no `showId`, so an admin key gets them in full. Their absence on a Show run is
586
- by design, not a broken pull.
542
+ should sit on top. The image and motion prompts (`framePrompt`, `videoPrompt`,
543
+ `referencePrompt`) reach an admin key in full. A caller who cannot use video on
544
+ this brand gets them stripped before the file arrives — they are style-pack IP
545
+ (`redactVideoIp` in `convex/workflows.ts`).
587
546
 
588
547
  **The narration track** (`narration.mp3` + `narration.json`). Present when the
589
548
  run recorded continuously: every narrated scene's `voText` joined into one
@@ -595,8 +554,7 @@ narration.mp3**. Runs go per-scene instead when any scene has on-camera
595
554
  dialogue, when the voiceover node is configured `take: "per-scene"`, or on an
596
555
  older storyboard carrying no `script[]`; then there is no `narration.mp3` and
597
556
  each scene's voice is its own recording. That choice is made from the storyboard
598
- and the node config (`planNarration`), not from the route — a workflow run and a
599
- Show run reach it the same way.
557
+ and the node config (`planNarration`).
600
558
 
601
559
  **The per-scene voice tracks** (`scene-NN.voice.<ext>`). On a continuous run
602
560
  these are cut out of `narration.mp3` itself — the same audio, sliced at the
@@ -611,9 +569,8 @@ speech inside the clip — under the default voice treatment the video model's
611
569
  invented voice is then replaced, in place, with the cast member's pinned
612
570
  ElevenLabs voice (that is `revoiced: true`). Other treatments leave the render
613
571
  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
572
+ treatment is frozen on the run when it starts, and there is exactly one door
573
+ that sets it: `--voice-treatment` on `workflow run` (see **Choosing how the
617
574
  voices are made**). Node config is never a door — nothing on the canvas sets a
618
575
  voice treatment, and a run that was given none renders the default. Read
619
576
  `wordsFrom` and `revoiced` per scene rather than assuming.
@@ -651,8 +608,8 @@ finding says `wrong-character`.
651
608
  generated by Eleven Music after the last clip lands, at the landed clips' summed
652
609
  length plus a 3-second safety pad. One track per ad, never per-scene. It is
653
610
  never mixed into any clip and never levelled — gain, fade and ducking are the
654
- cut-maker's decisions. `--no-music` on `start` means no artifact at all, and a
655
- music failure degrades to a warning rather than blocking the run.
611
+ cut-maker's decisions. A music failure degrades to a warning rather than
612
+ blocking the run.
656
613
 
657
614
  ### Reading it before you cut
658
615
 
@@ -715,7 +672,7 @@ revoiced and trimmed the same way, with fresh findings and fresh word timings.
715
672
  scene's motion prompt only (`withRedoDirection`,
716
673
  `scout/src/trigger/workflow-scene-retry.ts`); the spoken line still comes from
717
674
  the approved storyboard. Asking for different words in a note will not change
718
- them. If the words are wrong, that is a storyboard flag or a new run.
675
+ them. If the words are wrong, that is a new run.
719
676
 
720
677
  **A redo whose take fails the checks keeps the original clip.** On a finished
721
678
  clip the row stays `done` with the ORIGINAL take and only its findings are
@@ -795,8 +752,7 @@ they want; if they have not said and are not around, say which you used and why.
795
752
  - **The bundled script** — `node .claude/skills/exodus-video/scripts/first-cut.mjs
796
753
  ./ad-<runId>`, path from the workspace root (from a brand subfolder, prefix
797
754
  `../`). A plain assembly with no editing decisions in it, for seeing the whole
798
- ad end to end quickly. Read "What the bundled script does" before you run it:
799
- it changes the audio.
755
+ ad end to end quickly. Read "What the bundled script does" before you run it.
800
756
  - **HyperFrames** — HTML/CSS compositions rendered to video. Suits an ad that
801
757
  needs captions, title cards, overlays or motion graphics laid over the clips.
802
758
  It is a separate skill pack; check that a `hyperframes` skill is installed
@@ -829,17 +785,15 @@ Read these out of `storyboard.json` before you build anything, in any tool:
829
785
 
830
786
  ### What the bundled script does
831
787
 
832
- State this to the user before you run it, because an editor does not expect a
833
- cut tool to alter the performance:
834
-
835
- - **It changes the narration's speed.** When a narrated scene's voice runs
836
- longer than its clip, it speeds that scene's voice up — to at most 1.26×
837
- (`ATEMPO_CAP`, `scripts/first-cut.mjs:39`) — and holds the last frame for the
838
- remainder. The amount differs per scene, so one ad can play scene 1 at normal
839
- speed and scene 3 a quarter faster. **This is the script's behaviour, not the
840
- system's**; nothing in the pipeline does it. It is the defect tracked in
841
- #1814. If the user cares about a single consistent narrator pace, cut
842
- elsewhere, or skip the scenes it stretches.
788
+ State this to the user before you run it, so they know what the cut does with
789
+ the pieces:
790
+
791
+ - **It cuts the picture to the voice, and never the other way round.** A
792
+ narrated scene's segment runs exactly as long as that scene's voice track plus
793
+ a beat of air. A clip that runs longer is trimmed; a clip that runs shorter
794
+ holds its last frame for the difference. The narration is never sped up,
795
+ slowed down or cut short, so every scene plays at the speed it was recorded,
796
+ and the word times are handed on as they were measured.
843
797
  - **It ignores the continuous master.** It reads only `scene-NN.voice` files;
844
798
  it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
845
799
  master down whole and cuts the pictures to it keeps the read intact.
@@ -856,7 +810,8 @@ cut tool to alter the performance:
856
810
  - Beside the MP4 it writes `cut.words.json` (every spoken word of the finished
857
811
  ad with its start and end) and `cut.srt` (the same words grouped into subtitle
858
812
  lines), named from the `--out` basename. Those times are on the finished ad's
859
- timeline and include the speed changes above. Scenes with no word times leave
813
+ timeline, each scene's own word times offset by where that scene starts in the
814
+ cut and otherwise unaltered. Scenes with no word times leave
860
815
  gaps and are named in the report; when no scene delivered any, neither file is
861
816
  written. These sidecars are the script's own output — another tool will not
862
817
  produce them.
@@ -880,8 +835,8 @@ give a real one.
880
835
 
881
836
  **`upload` only works at the final watch.** The run has to be parked there with
882
837
  its video step done, or the server answers "This ad isn't waiting for your final
883
- cut right now." On a workflow run that park exists only when the video node sets
884
- `finalWatch: true`; a Show run always has it. Uploading again replaces the
838
+ cut right now." That park exists only when the video node sets
839
+ `finalWatch: true`. Uploading again replaces the
885
840
  previous cut in place rather than adding a second one. Upload does not move the
886
841
  run; it stays parked until someone approves.
887
842
 
@@ -896,7 +851,7 @@ compares the cut against the script or the storyboard. The approved spoken copy
896
851
  and the approved narration are therefore not the editor's to change — reordering
897
852
  lines, re-recording narration, or cutting words out of the CTA will ship,
898
853
  silently. Cut the pictures to the words, not the words to the pictures. If the
899
- words are wrong, that is a storyboard flag or a new run, not an edit.
854
+ words are wrong, that is a new run, not an edit.
900
855
 
901
856
  ## Reporting to the user
902
857
 
@@ -9,8 +9,8 @@
9
9
  // The audio track comes first. A narrated scene (the storyboard gives it
10
10
  // narration and no on-camera line) plays its voice track and NOTHING the clip
11
11
  // recorded: the voice replaces the clip's audio, and the picture is fitted to
12
- // the voice, trimmed when the clip runs longer, sped up to at most 1.26x and
13
- // then held on its last frame when the voice runs longer. A dialogue scene
12
+ // the voice, trimmed when the clip runs longer and held on its last frame when
13
+ // the voice runs longer. The voice itself is never altered. A dialogue scene
14
14
  // keeps the audio it performed. A scene with no clip but a keyframe becomes a
15
15
  // still for the length of its voice track, else the storyboard's planned
16
16
  // duration. Every segment is loudness-normalized to -16 LUFS before the join.
@@ -35,8 +35,6 @@ const stereo = "aresample=48000,aformat=channel_layouts=stereo";
35
35
  const LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
36
36
  /** Air after the last spoken word of a narrated scene, so the line lands. */
37
37
  export const TAIL_AIR_SEC = 0.8;
38
- /** The most a voice track may be sped up to fit its clip. */
39
- export const ATEMPO_CAP = 1.26;
40
38
  /** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
41
39
  const ROOM_TONE =
42
40
  "anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
@@ -91,27 +89,18 @@ export function scenePlans(storyboard) {
91
89
  }
92
90
 
93
91
  /**
94
- * Fit a picture to its voice track. The segment runs as long as the voice plus
95
- * a beat of air. A clip that runs longer is trimmed; a voice that runs longer
96
- * is sped up to at most ATEMPO_CAP and the clip's last frame is held for
97
- * whatever is still left. A still (no clip) simply runs the voice's length.
92
+ * Fit a picture to its voice track. The segment always runs as long as the
93
+ * voice plus a beat of air, because the voice is never altered. A clip that
94
+ * runs longer is trimmed; a clip that runs shorter holds its last frame for the
95
+ * difference. A still (no clip) simply runs the voice's length.
98
96
  */
99
97
  export function fitNarration({ clipSeconds, voiceSeconds }) {
100
98
  const target = voiceSeconds + TAIL_AIR_SEC;
101
- if (clipSeconds === null) return { seconds: target, atempo: 1, holdSec: 0, trimmedBy: 0 };
99
+ if (clipSeconds === null) return { seconds: target, holdSec: 0, trimmedBy: 0 };
102
100
  if (target <= clipSeconds) {
103
- return { seconds: target, atempo: 1, holdSec: 0, trimmedBy: clipSeconds - target };
101
+ return { seconds: target, holdSec: 0, trimmedBy: clipSeconds - target };
104
102
  }
105
- const room = Math.max(clipSeconds - TAIL_AIR_SEC, 0.1);
106
- const atempo = Math.min(ATEMPO_CAP, voiceSeconds / room);
107
- const fitted = voiceSeconds / atempo + TAIL_AIR_SEC;
108
- if (fitted <= clipSeconds) return { seconds: fitted, atempo, holdSec: 0, trimmedBy: 0 };
109
- return { seconds: fitted, atempo, holdSec: fitted - clipSeconds, trimmedBy: 0 };
110
- }
111
-
112
- function scaleWords(words, atempo) {
113
- if (!words || atempo === 1) return words;
114
- return words.map((w) => ({ ...w, s: w.s / atempo, e: w.e / atempo }));
103
+ return { seconds: target, holdSec: target - clipSeconds, trimmedBy: 0 };
115
104
  }
116
105
 
117
106
  export function cueOffsetOnSpine(spine, counts, cueLineId) {
@@ -186,27 +175,26 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
186
175
  const fit = fitNarration({ clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds });
187
176
  const how = [];
188
177
  if (fit.trimmedBy > 0.05) how.push(`clip trimmed ${clip.seconds.toFixed(1)}s -> ${fit.seconds.toFixed(1)}s`);
189
- if (fit.atempo > 1.001) how.push(`voice sped up ${fit.atempo.toFixed(2)}x`);
190
178
  if (fit.holdSec > 0.05) how.push(`last frame held ${fit.holdSec.toFixed(1)}s`);
191
179
  spine.push({
192
- ...base, audio: "narration", voice, seconds: fit.seconds, atempo: fit.atempo,
193
- holdSec: fit.holdSec, words: scaleWords(readWords(scene.words), fit.atempo),
180
+ ...base, audio: "narration", voice, seconds: fit.seconds,
181
+ holdSec: fit.holdSec, words: readWords(scene.words),
194
182
  note: `${flag}, ${voice} replaces the clip's audio${how.length ? ` (${how.join(", ")})` : ""}`,
195
183
  });
196
184
  } else if (narrated) {
197
185
  spine.push({
198
- ...base, audio: "silence", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
186
+ ...base, audio: "silence", voice: null, seconds: clip.seconds, holdSec: 0,
199
187
  words: null,
200
188
  note: `${flag}, narrated scene without its voice track: the clip's own audio is muted`,
201
189
  });
202
190
  } else if (clip.hasAudio) {
203
191
  spine.push({
204
- ...base, audio: "clip", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
192
+ ...base, audio: "clip", voice: null, seconds: clip.seconds, holdSec: 0,
205
193
  words: readWords(scene.words), note: `${flag}, its own dialogue`,
206
194
  });
207
195
  } else {
208
196
  spine.push({
209
- ...base, audio: "silence", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
197
+ ...base, audio: "silence", voice: null, seconds: clip.seconds, holdSec: 0,
210
198
  words: null, note: `${flag}, silent clip`,
211
199
  });
212
200
  }
@@ -216,10 +204,10 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
216
204
  if (exists(scene.keyframe)) {
217
205
  const fit = voice
218
206
  ? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
219
- : { seconds: plan.durationSec ?? 4, atempo: 1, holdSec: 0 };
207
+ : { seconds: plan.durationSec ?? 4, holdSec: 0 };
220
208
  spine.push({
221
209
  sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null,
222
- seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, atempo: 1, holdSec: 0,
210
+ seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, holdSec: 0,
223
211
  startSec, lineIds: plan.lineIds,
224
212
  // #1689: on a narrated scene the words are the voice track's, and the
225
213
  // voice starts where the segment does, so they need no offset here.
@@ -288,8 +276,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
288
276
  filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
289
277
  if (seg.audio === "narration") {
290
278
  const v = addInput("-i", resolve(seg.voice));
291
- const tempo = seg.atempo > 1.001 ? `atempo=${seg.atempo.toFixed(4)},` : "";
292
- filters.push(`[${v}:a]${stereo},${tempo}${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
279
+ filters.push(`[${v}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
293
280
  } else if (seg.audio === "clip") {
294
281
  filters.push(`[${i}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
295
282
  } else {
@@ -237,7 +237,7 @@ Two limits worth knowing before you treat an empty inbox as proof of anything:
237
237
 
238
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".
239
239
 
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 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.
241
241
 
242
242
  Two things about that list you have to know before you answer a question with it:
243
243
 
@@ -1,6 +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
+ import { type RunProvenance, type WorkflowRun } from "./workflow.js";
4
4
  export declare const helpText: string;
5
5
  export declare const VOICES_PATH = "/api/v2/video/voices";
6
6
  export interface ClipWord {
@@ -100,6 +100,7 @@ export interface VideoRun {
100
100
  pauseReason?: BuilderPauseReason;
101
101
  pausedNodeId?: string;
102
102
  nodes: VideoRunNode[];
103
+ pauseAhead?: WorkflowRun["pauseAhead"];
103
104
  castLock?: PullCastLock | null;
104
105
  provenance?: RunProvenance;
105
106
  workflowId?: string;
@@ -115,6 +116,7 @@ export interface NodeItem {
115
116
  attempt?: number;
116
117
  flagged?: boolean;
117
118
  findings?: ClipFinding[];
119
+ staleClaim?: boolean;
118
120
  artifact?: ArtifactSubset;
119
121
  }
120
122
  export interface ShowRow {
@@ -296,6 +298,7 @@ export interface ClipRedoTarget {
296
298
  }
297
299
  export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
298
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>;
299
302
  export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
300
303
  export type RevoiceTarget = ClipRedoTarget | {
301
304
  all: true;
@@ -5,9 +5,9 @@ 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
- exodus video — make an ad from a Show, pull every piece, upload your cut
10
+ exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
11
11
 
12
12
  The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
13
13
  one voice track per scene, one video clip per scene, plus the music bed. It does
@@ -16,28 +16,34 @@ with whatever editing tools you like, and upload the cut back.
16
16
 
17
17
  The whole loop, in order:
18
18
 
19
- 1. exodus video shows
20
- Which Shows exist and which are ready to make ads.
19
+ 1. exodus workflow list
20
+ Which workflows this brand has saved. A video workflow is one that makes
21
+ ads.
21
22
 
22
- 2. exodus video start --show <id> --script script.txt --wait
23
- Starts an ad and waits. It stops when the storyboard needs your yes.
23
+ 2. exodus workflow describe <workflowId|name>
24
+ What that workflow needs before it can run, and what it gives back.
24
25
 
25
- 3. exodus video storyboard <runId>
26
+ 3. exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
27
+ Starts the ad on your script and waits. It stops when the storyboard
28
+ needs your yes. "describe" names the field your script goes in. You can
29
+ also settle the voices here, at the launch, instead of at step 5 — the
30
+ launch flags all live in: exodus workflow --help
31
+
32
+ 4. exodus video storyboard <runId>
26
33
  The scene cards: what each scene says and the picture it will look like.
27
34
 
28
- 4. exodus video voices <runId>
35
+ 5. exodus video voices <runId>
29
36
  Who is in the ad, which voice each one has, how the voices get applied
30
37
  and who pays. Add --set to give someone a voice, before any clip is made.
31
38
 
32
- 5. exodus video approve <runId> (looks right — keep going)
33
- exodus video flag <runId> --note "…" (something is wrong — send it back)
39
+ 6. exodus video approve <runId> (looks right — keep going)
34
40
  exodus video retry-frame <runId> --node <nodeId> --scene <n>
35
41
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
36
42
 
37
- 6. exodus video status <runId>
43
+ 7. exodus video status <runId>
38
44
  Where the run is and how each scene's clip turned out.
39
45
 
40
- 7. exodus video pull <runId> --out ./ad
46
+ 8. exodus video pull <runId> --out ./ad
41
47
  Writes every piece to that folder plus a manifest.json index.
42
48
 
43
49
  exodus video retry-clip <runId> --scene <n>
@@ -50,21 +56,18 @@ The whole loop, in order:
50
56
  picture stays, no new video is made, and a clip whose voice pass
51
57
  cannot finish is kept as it was. Use --scene <n> for one clip.
52
58
 
53
- 8. Make your cut from those files.
59
+ 9. Make your cut from those files.
54
60
 
55
- 9. exodus video upload <runId> --file cut.mp4
56
- Attaches your cut to the run and prints the page to approve it on.
61
+ 10. exodus video upload <runId> --file cut.mp4
62
+ Attaches your cut to the run and prints the page to approve it on.
57
63
 
58
- 10. exodus video approve <runId>
59
- Or click Approve on the page from step 9.
64
+ 11. exodus video approve <runId>
65
+ Or click Approve on the page from step 10.
60
66
 
61
67
  Usage:
62
- exodus video shows [--json]
63
- exodus video start --show <id> --script <file> [--voice-path <v>] [--no-music] [--wait] [--json]
64
68
  exodus video status <runId> [--json]
65
69
  exodus video storyboard <runId> [--json]
66
70
  exodus video approve <runId> [--json]
67
- exodus video flag <runId> --note "<what is wrong>" [--json]
68
71
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
69
72
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
70
73
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
@@ -73,18 +76,11 @@ Usage:
73
76
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
74
77
 
75
78
  Options:
76
- --show <id> Which Show the ad belongs to (from: exodus video shows)
77
- --script <file> A text file with the words you want the ad to say
78
- --voice-path <v> Override the Show's voice path for this ad
79
- --no-music Skip the music bed
80
- --wait Stay open and report as the run moves; stops at every
81
- point that needs you
82
79
  --out <dir> Folder to write the pulled pieces into (pull)
83
80
  --file <cut.mp4> Your finished cut (upload). MP4, MOV or WebM, up to 200MB
84
81
  --duration <sec> How long your cut is, in seconds. Only needed when the
85
82
  length can't be read off the file itself
86
- --note "<text>" What is wrong with the storyboard (flag), or how to
87
- steer one redo (retry-frame, retry-clip)
83
+ --note "<text>" How to steer one redo (retry-frame, retry-clip)
88
84
  --node <nodeId> Which scene-frames node holds the still (retry-frame).
89
85
  Which video step holds the clip, when a scene has one on
90
86
  more than one step (retry-clip, revoice)
@@ -99,14 +95,15 @@ Options:
99
95
  --from <file.json> A file of characters and voice IDs (voices). --set and
100
96
  --clear win over the same name in the file
101
97
  --json Machine-readable output
102
- --help, -h Show this help
98
+ --help, -h Print this help
103
99
 
104
100
  Video is admin-only. If every command here answers "video isn't enabled for
105
101
  this key", your dashboard user needs the admin role on this brand.
106
102
 
107
103
  Examples:
108
- exodus video shows
109
- exodus video start --show k57abc --script ./script.txt --wait
104
+ exodus workflow list
105
+ exodus workflow describe "Video Ad"
106
+ exodus workflow run "Video Ad" --input script=@./script.txt --wait
110
107
  exodus video storyboard run_123
111
108
  exodus video approve run_123
112
109
  exodus video retry-frame run_123 --node frames-1 --scene 2
@@ -921,6 +918,8 @@ const ITEM_STATUS_WORD = {
921
918
  function itemWord(item) {
922
919
  if (!item)
923
920
  return "—";
921
+ if (item.staleClaim === true)
922
+ return "stopped";
924
923
  if (item.flagged === true)
925
924
  return "flagged";
926
925
  return ITEM_STATUS_WORD[item.status] ?? item.status;
@@ -974,6 +973,7 @@ export async function statusFlow(runId, json, deps) {
974
973
  const lines = [
975
974
  headline,
976
975
  ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
976
+ ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
977
977
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
978
978
  ];
979
979
  if (byScene.size === 0) {
@@ -1176,6 +1176,7 @@ export function planClipRedo(run, items, target) {
1176
1176
  return { ok: false, reason: found.reason };
1177
1177
  const { row, uploadedCut } = found;
1178
1178
  const redoable = row.status === "failed" ||
1179
+ row.staleClaim === true ||
1179
1180
  (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1180
1181
  if (!redoable) {
1181
1182
  if (row.status === "done") {
@@ -1228,7 +1229,7 @@ function findClipRow(run, items, target) {
1228
1229
  "and one clip can only be redone on a step that finished.",
1229
1230
  };
1230
1231
  }
1231
- if (row.status === "running") {
1232
+ if (row.status === "running" && row.staleClaim !== true) {
1232
1233
  return { reason: `Scene ${target.sceneIndex} is already being redone.` };
1233
1234
  }
1234
1235
  return { row, uploadedCut };
@@ -1294,20 +1295,34 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1294
1295
  }
1295
1296
  function clipIsStillRaw(item) {
1296
1297
  const a = item.artifact;
1297
- return (item.status === "done" &&
1298
+ return ((item.status === "done" || item.staleClaim === true) &&
1298
1299
  a?.type === "video" &&
1299
1300
  a.revoiced !== true &&
1300
1301
  a.speechTrimmed !== true &&
1301
1302
  a.tailTrimmed !== true);
1302
1303
  }
1303
1304
  const VOICE_NOT_CHANGED_CODES = new Set(["voice-unpinned", "voice-not-applied"]);
1305
+ export const VOICE_PATHS_NEVER_HEARD = new Set(["native-prompt", "omni-audio-ids", "gemini-direct"]);
1306
+ async function neverHeardRunLine(runId, deps) {
1307
+ const res = await deps
1308
+ .get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
1309
+ .catch(() => null);
1310
+ if (!res?.ok)
1311
+ return null;
1312
+ const path = res.data?.treatment?.path;
1313
+ if (typeof path !== "string" || !VOICE_PATHS_NEVER_HEARD.has(path))
1314
+ return null;
1315
+ return (`This run's clips are not voiced by ElevenLabs — the "${path}" way settles each voice as ` +
1316
+ "the clip is rendered — so no clip on this run has a voice pass to redo. Nothing was queued " +
1317
+ "and nothing was spent. To make a clip again from scratch, redo the clip instead.");
1318
+ }
1304
1319
  export function planClipRevoice(run, items, target) {
1305
1320
  const found = findClipRow(run, items, target);
1306
1321
  if ("reason" in found)
1307
1322
  return { ok: false, reason: found.reason };
1308
1323
  const { row, uploadedCut } = found;
1309
1324
  const remake = `To make the clip again from scratch: exodus video retry-clip ${run._id} --scene ${target.sceneIndex}`;
1310
- if (row.status !== "done" || row.artifact?.type !== "video") {
1325
+ if ((row.status !== "done" && row.staleClaim !== true) || row.artifact?.type !== "video") {
1311
1326
  return {
1312
1327
  ok: false,
1313
1328
  reason: `Scene ${target.sceneIndex} has no finished clip, so there is no voice to redo. ${remake}`,
@@ -1351,10 +1366,11 @@ export async function revoiceFlow(runId, target, json, deps) {
1351
1366
  .map((i) => ({ sceneIndex: i.sceneIndex, nodeId: i.nodeId }))
1352
1367
  : [target];
1353
1368
  if (targets.length === 0) {
1354
- const nothing = "No clip on this run is waiting for a voice pass, so nothing was started.";
1369
+ const note = (await neverHeardRunLine(runId, deps)) ??
1370
+ "No clip on this run is waiting for a voice pass, so nothing was started.";
1355
1371
  return {
1356
1372
  code: 0,
1357
- lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note: nothing })] : [nothing],
1373
+ lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note })] : [note],
1358
1374
  };
1359
1375
  }
1360
1376
  const scenes = [];
@@ -1551,7 +1567,8 @@ export function voiceSheetLines(sheet) {
1551
1567
  for (const row of sheet.cast) {
1552
1568
  const voice = voiceWords(row);
1553
1569
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1554
- `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1570
+ `speaks in ${row.spokenScenes} scene${row.spokenScenes === 1 ? "" : "s"}, ` +
1571
+ `about ${row.spokenSeconds}s`);
1555
1572
  if (row.description)
1556
1573
  lines.push(` written voice: ${row.description}`);
1557
1574
  }
@@ -1565,8 +1582,9 @@ export function voiceSheetLines(sheet) {
1565
1582
  : "Nothing changed — those voices were already set. Pictures and script were not touched.");
1566
1583
  }
1567
1584
  lines.push(`How voices are applied: ${sheet.treatment.summary}${sheet.treatment.speedChange ? "" : " No speed change."}`);
1568
- const rendersOwnVoice = sheet.treatment.kind === "render-owns-voice";
1569
- if (!rendersOwnVoice || sheet.treatment.usesElevenLabs === undefined) {
1585
+ const playsPinnedVoices = sheet.treatment.kind !== "render-owns-voice";
1586
+ const convertsClips = playsPinnedVoices && sheet.treatment.usage.clips > 0;
1587
+ if (convertsClips || sheet.treatment.usesElevenLabs === undefined) {
1570
1588
  lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
1571
1589
  `About ${sheet.treatment.usage.seconds} seconds of speech across ` +
1572
1590
  `${sheet.treatment.usage.clips} clips will be converted.`);
@@ -386,6 +386,9 @@ export interface WorkflowRun {
386
386
  _id: string;
387
387
  workflowId: string;
388
388
  workflowName: string;
389
+ runTitle?: string;
390
+ title?: string;
391
+ moduleKey?: string;
389
392
  status: WorkflowRunStatus;
390
393
  error?: string;
391
394
  counts?: WorkflowCounts;
@@ -408,7 +411,12 @@ export interface WorkflowRun {
408
411
  approvedAt: number;
409
412
  }[];
410
413
  provenance?: RunProvenance;
414
+ pauseAhead?: {
415
+ nodeId: string;
416
+ after: "frames" | "storyboard";
417
+ };
411
418
  }
419
+ export declare function pauseAheadLine(ahead: NonNullable<WorkflowRun["pauseAhead"]>): string;
412
420
  export type WorkflowRunProjection = Omit<WorkflowRun, "nodes"> & {
413
421
  nodes?: never;
414
422
  };
@@ -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";
@@ -366,6 +367,11 @@ export function formatRulesLine(provenance) {
366
367
  : "";
367
368
  return `Format rules: ${rigName}${version}${where}.${borrowed}`;
368
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
+ }
369
375
  const LIST_PATH = "/api/v2/workflows";
370
376
  const RUN_PATH = "/api/v2/workflows/run";
371
377
  const STATUS_PATH = "/api/v2/workflow";
@@ -1114,7 +1120,13 @@ const RECENT_RUNS_PAGE = 25;
1114
1120
  export function formatRecentRuns(runs) {
1115
1121
  if (runs.length === 0)
1116
1122
  return "No workflow runs found for the active brand.";
1117
- 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
+ ]));
1118
1130
  const notes = [];
1119
1131
  if (runs.length >= RECENT_RUNS_PAGE) {
1120
1132
  notes.push(`Showing the ${RECENT_RUNS_PAGE} newest runs — there may be older ones this list doesn't reach.`);
@@ -1195,7 +1207,7 @@ export function formatImportSummary(result, mode = {}) {
1195
1207
  export function formatWorkflowRun(run) {
1196
1208
  const lines = [];
1197
1209
  const counts = formatCounts(run.counts);
1198
- lines.push(`Workflow run — ${run.workflowName}`);
1210
+ lines.push(`${runHeadingWord(run)} — ${runDisplayName(run)}`);
1199
1211
  lines.push(`runId: ${run._id}`);
1200
1212
  lines.push(`workflowId: ${run.workflowId}`);
1201
1213
  if (run.triggerRunId)
@@ -1218,6 +1230,10 @@ export function formatWorkflowRun(run) {
1218
1230
  if (run.autoApprovals && run.autoApprovals.length > 0) {
1219
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`);
1220
1232
  }
1233
+ if (run.pauseAhead) {
1234
+ lines.push("");
1235
+ lines.push(pauseAheadLine(run.pauseAhead));
1236
+ }
1221
1237
  const parkedNodeId = gateParkedNodeId(run.status, run.pauseReason, run.pausedNodeId);
1222
1238
  if (run.nodes.length > 0) {
1223
1239
  lines.push("");
@@ -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.2",
3
+ "version": "2026.9.2000-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",