@aicopycoders/exodus 2026.9.1700-beta.0 → 2026.9.1700-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 Show: start a run from a script, read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, voices, keyframes, word timings, music bed) plus a manifest, cut them with the bundled first-cut script, 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. 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, first cut, upload, approve (admin-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) |
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 |
@@ -245,21 +245,25 @@ Returns: a table, --json data, or an exported CSV/JSON file
245
245
 
246
246
  ## exodus-video
247
247
 
248
- **What it does:** Makes a video ad from a locked Show, in pieces. The run writes the storyboard, draws a picture per scene, records a voice per scene, renders a clip per scene and composes a music bed, then parks for a cut. The CLI pulls the pieces to a folder with a `manifest.json`, the bundled script makes a plain cut, and the upload attaches it for approval. Admin-only.
248
+ **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
249
 
250
250
  ```operator-guide
251
- Commands:
251
+ Start (workflow route, no Show):
252
+ exodus workflow list / describe <name> find it and see what it needs
253
+ exodus workflow run <workflowId|name> --input <field>=@script.txt [--wait] start it; --wait stops at the storyboard gate
254
+ Start (Show route):
252
255
  exodus video shows [--json] Shows and whether each is ready
253
- exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait parks at the storyboard gate
256
+ exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
257
+ Then, on either route:
254
258
  exodus video storyboard <runId> [--json] the scene cards
255
259
  exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
256
- exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back
260
+ exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back — SHOW RUNS ONLY
257
261
  exodus video status <runId> [--json] the park and each scene's clip/voice/picture
258
262
  exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
259
263
  exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
260
- Cut:
264
+ Cut: your choice of tool — Exodus does not stitch or mix. One option ships with the skill:
261
265
  node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
262
- Returns: park lines with the exact next command; pull writes files + manifest.json
266
+ Returns: park lines with the exact next command (--wait ends on either video park, exit 0); pull writes files + manifest.json
263
267
  ```
264
268
 
265
269
  ---
@@ -1,52 +1,100 @@
1
1
  ---
2
2
  name: exodus-video
3
- description: Make a video ad with Exodus from the terminal. Start an ad from a Show, read and approve its storyboard, pull every finished piece (per-scene clips, voice tracks, keyframes, word timings, the music bed) to a folder, cut them into one video, and upload the cut back for approval, all through the `npx @aicopycoders/exodus video` command family. Use it whenever the user has invoked Exodus and wants anything to do with a video ad, a Show, 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, start 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. 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`, 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`.
4
4
  ---
5
5
 
6
6
  # Video: make the pieces, cut the ad, hand it back
7
7
 
8
- Exodus makes the PIECES of a video ad, not the finished ad. From a locked Show
9
- (its cast, rooms and voices, set up on the dashboard) and a script, the run
10
- writes a storyboard, draws one picture per scene, records one voice track per
11
- scene, renders one video clip per scene, and composes a music bed. Then it
12
- parks and waits for a cut. You pull the pieces, cut them into one video with
13
- ffmpeg, and upload the cut. Approving the cut is what makes it the ad.
8
+ Exodus makes the PIECES of a video ad, not the finished ad. From a script, a run
9
+ writes a storyboard, draws one picture per scene, records the voice, renders one
10
+ video clip per scene, and generates a music bed. Then it parks and waits for a
11
+ cut.
14
12
 
15
- Your job in this skill is to drive that loop and to make a sound first cut.
16
- Editing craft (captions, pacing, b-roll, sound design) is not here yet; a plain,
17
- correct assembly is the bar.
13
+ There is no server-side stitch and no server-side mix. Nothing joins the clips,
14
+ nothing sets levels, nothing burns in captions
15
+ (`scout/src/workflow/video/music-bed.ts` states the ruling: "the cut, and the
16
+ mix, happen outside from the pieces the CLI pulls"). You pull the pieces, cut
17
+ them wherever you like, and upload the result. Approving the upload is what
18
+ makes it the ad.
19
+
20
+ Your job in this skill is to drive that loop, hand over the pieces with the
21
+ facts about them, and make whatever cut the user asked for. **How to edit is the
22
+ user's call, not this skill's** — see "Cutting the ad".
23
+
24
+ ## Two routes in, one set of pieces out
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".
18
40
 
19
41
  ## Before anything
20
42
 
21
- - `npx @aicopycoders/exodus video --help` is the authoritative flag list. The
22
- `video` verb is admin-only and hidden from the top-level `--help`; that is
23
- expected, not a broken install.
24
- - "video isn't enabled for this key" has two causes the CLI cannot tell apart:
25
- the dashboard user is not an admin on this brand, or the run id does not
26
- exist. Check the id first; if it is right, the user needs the admin role.
27
- No flag or retry fixes either.
43
+ - `npx @aicopycoders/exodus video --help` and `workflow --help` are the
44
+ authoritative flag lists. The `video` verb is admin-only and hidden from the
45
+ top-level `--help`; that is expected, not a broken install.
46
+ - **Video is admin-only on both routes**, and says so two different ways.
47
+ - "video isn't enabled for this key", from a `video` verb, has two causes the
48
+ CLI cannot tell apart: the user is not an admin on this brand, or the run id
49
+ does not exist. Check the id first; if it is right, they need the admin role.
50
+ - "Video workflows aren't available on your account. Ask an admin if you need
51
+ video.", from `workflow run`, is unambiguous — `startRun` refuses any graph
52
+ containing a video node for a non-admin (`convex/workflows.ts:6471`).
53
+
54
+ No flag or retry fixes either one.
28
55
  - Other failures: `npx @aicopycoders/exodus doctor` first, then follow what it prints.
29
- - Cutting needs a full `ffmpeg` build with `ffprobe` and the `libx264`
30
- encoder on the PATH (`brew install ffmpeg` / `apt install ffmpeg`; the
31
- script checks and says so). Check before you pull, so the user is not left
32
- with a folder they cannot use.
33
- - The brand matters: a Show belongs to a brand, so `exodus video shows` lists
34
- the active brand's Shows. Wrong list means wrong brand; `exodus brand use <slug>`.
56
+ - `ffprobe` on the PATH lets `upload` read the cut's length by itself. Without
57
+ it, `upload` falls back to parsing an MP4's own header, and for anything else
58
+ you must pass `--duration <sec>`. Editing tools have their own requirements on
59
+ top of that — the bundled script needs a full `ffmpeg` with `libx264` and
60
+ 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>`.
35
64
 
36
65
  ## The loop
37
66
 
38
- Run each line as `npx @aicopycoders/exodus video …`; `exodus` below is shorthand.
67
+ Run each line as `npx @aicopycoders/exodus …`; `exodus` below is shorthand.
68
+
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.
72
+
73
+ ```operator-guide
74
+ exodus workflow list the brand's workflows
75
+ exodus workflow describe <workflowId|name> what inputs it needs
76
+ exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
77
+ start, stop at the storyboard gate
78
+ exodus video status <runId> where it is, per scene
79
+ ```
80
+
81
+ **Starting on the Show route.**
39
82
 
40
83
  ```operator-guide
41
84
  exodus video shows which Shows are ready
42
85
  exodus video start --show <id> --script script.txt --wait start, wait for the storyboard gate
86
+ ```
87
+
88
+ **From the storyboard on, both routes are the same.**
89
+
90
+ ```operator-guide
43
91
  exodus video storyboard <runId> read the scene cards
44
92
  exodus video approve <runId> keep going
45
- exodus video flag <runId> --note "what's wrong" or send it back
93
+ exodus video flag <runId> --note "what's wrong" send it back — SHOW RUNS ONLY
46
94
  exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
47
95
  exodus video status <runId> where it is, per scene
48
96
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
49
- node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId> a plain cut (path from the workspace root)
97
+ … make the cut (your choice of tool)
50
98
  exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
51
99
  exodus video approve <runId> make it the ad
52
100
  ```
@@ -54,35 +102,129 @@ exodus video approve <runId> make it the ad
54
102
  Every command takes `--json`. Read `--json` when you need to decide something
55
103
  from the output; read the plain form when you are relaying it to the user.
56
104
 
57
- **Where a run can stop.** `start --wait` is silent while it polls, then prints
58
- the stage lines and the stop in one burst. Five stops, each with the exact next
59
- command printed under it:
60
-
61
- - storyboard gate: needs `approve` or `flag`.
62
- - final watch: every piece is made; needs `pull`, a cut, and `upload`.
63
- - paused: a builder checkpoint the CLI cannot resolve; open the run's page.
64
- - failed: the run's error is printed and the command exits 1; `status` shows
65
- how far it got. Tell the user; do not start another run on your own.
105
+ **Read a run's position with `exodus video status`, on either route.** It is the
106
+ richest reader — every scene's clip, voice and picture, with the findings. A
107
+ `--wait` that landed already printed the same stop block, and `workflow inbox`
108
+ lists a parked video run (its row carries `hasShow`), but neither shows the
109
+ per-scene detail, and the checkpoint verbs cannot resolve a video park — see
110
+ "What each route can and cannot do".
111
+
112
+ **Where a run can stop.** Five stops, each with the exact next command printed
113
+ under it:
114
+
115
+ - storyboard gate: needs `approve`, or `flag` on a Show run.
116
+ - final watch: every piece is made; needs `pull`, a cut, and `upload`. A
117
+ workflow run only reaches this park if its video node sets `finalWatch: true`
118
+ — otherwise it skips straight to finished.
119
+ - paused: a builder checkpoint neither video verb resolves —
120
+ `exodus workflow checkpoint <runId>` is the verb for that one.
121
+ - failed: `status` shows how far it got, and a `--wait` that was running exits
122
+ 1 with the error. Tell the user; do not start another run on your own.
66
123
  - finished: pull what it made.
67
124
 
68
- `status` prints the same stop lines any time, so an agent that lost the
69
- `--wait` terminal picks up from `status` alone. `--wait` gives up after 60
70
- minutes with "Still running after 60 minutes" and exit 0; that is not done,
71
- so check `status` before treating it as finished. Do not poll `status` in a
72
- tight loop; a full run is several minutes.
125
+ **`--wait` stops at both video parks on either route** (#1818). `workflow run
126
+ --wait` and `video start --wait` both end at the storyboard gate and again at
127
+ the final watch, exit 0, and close with the same `Parked:` block and next
128
+ commands that `exodus video status` prints. Under `--json` the line carries an
129
+ additive `stop` field in the same shape `video status --json` uses, so branch on
130
+ `stop.at` rather than re-deriving the park. Every other park still prints its
131
+ notice once and keeps polling, so a resolve from another shell carries the run
132
+ on — a Checkpoint park is `exodus workflow checkpoint <runId>`, not a video verb.
73
133
 
74
- **What costs money.** `start` creates the run and the storyboard. Approving
75
- the storyboard is the cost gate: it releases the paid rendering of every
76
- picture, voice and clip. `shows`, `status`, `storyboard` and `pull` are free
77
- reads; run them as often as you like. Never start a second run to "retry"
78
- without the user asking; flag the storyboard or tell them what failed instead.
134
+ `exodus video status` prints these stop lines any time, so an agent that lost a
135
+ `--wait` terminal picks up from `status` alone. `--wait` gives up after 60
136
+ minutes with "Still running after 60 minutes" and exit 0; that is not done, so
137
+ check `status` before treating it as finished. Do not poll `status` in a tight
138
+ loop; a full run is several minutes.
139
+
140
+ **What costs money.** Starting the run creates the storyboard. Approving the
141
+ storyboard is the cost gate: it releases the paid rendering of every picture,
142
+ voice and clip. `shows`, `workflow list`, `workflow describe`, `video status`,
143
+ `storyboard` and `pull` are free reads; run them as often as you like. Never
144
+ start a second run to "retry" without the user asking; flag the storyboard (Show
145
+ runs) or tell them what failed instead.
146
+
147
+ **`--auto-approve` does not cover either video gate.** It releases Checkpoint
148
+ boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
149
+ and the storyboard gate and the final watch carry no pause reason. An unattended
150
+ `workflow run --auto-approve` still stops dead at the cost gate — which is the
151
+ safe behaviour, since nothing should approve paid rendering or ship an uncut ad
152
+ on its own.
153
+
154
+ ## What each route can and cannot do
155
+
156
+ Verified against the routes, not assumed. Everything here is admin-gated on top.
157
+
158
+ | Verb | Workflow run (no Show) | Show run |
159
+ |---|---|---|
160
+ | `video shows` | nothing to list | lists the brand's Shows |
161
+ | `video start --show` | not the entry point | starts the run |
162
+ | `workflow run` | starts the run | not the entry point |
163
+ | `video status` | works | works |
164
+ | `video storyboard` | works | works |
165
+ | `video approve` | works, both gates | works, both gates |
166
+ | `video flag --note` | **refused** | works |
167
+ | script-turn edits at the gate | **refused** | dashboard only |
168
+ | `video retry-frame` | works | works |
169
+ | `video pull` | works | works |
170
+ | `video upload` | only if the video node sets `finalWatch: true` | works |
171
+ | `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
172
+
173
+ Five consequences worth knowing before you promise a user anything:
174
+
175
+ - **A workflow only stops for your cut if its video node asks to.** `finalWatch`
176
+ defaults to `false` (`convex/lib/workflow/catalog.ts:780`), and the run only
177
+ parks for a final watch when it is `true`. Without it the run renders every
178
+ piece and runs straight to the end, and `video upload` answers "This ad isn't
179
+ waiting for your final cut right now." You can still `pull` the pieces and cut
180
+ them, but there is nothing to attach the cut to. **Check this before you
181
+ promise a user an upload**, with `exodus workflow export <workflowId|name>`:
182
+ the export carries every node's `config` verbatim, so look for the `video`
183
+ node and a `finalWatch: true` under it. The key is optional, so *absent* means
184
+ off just as much as `false` does. `exodus workflow describe` does NOT answer
185
+ this — it returns only name, inputs, prerequisites and outputs, no node
186
+ config. If `finalWatch` is off the workflow needs editing (`exodus-workflow`);
187
+ no flag on `workflow run` turns it on.
188
+ - **`exodus video approve` is the verb at a video gate, not the checkpoint
189
+ cluster.** `workflow checkpoint approve|edit|retry` preflight on
190
+ `pauseReason === "checkpoint"` (`exodus/commands/workflow.ts:4487-4519`), and
191
+ both video gates park with no pause reason at all, so those verbs refuse
192
+ client-side with "Run … is not parked for a checkpoint approval — it is parked
193
+ at the cost gate (legacy)." `exodus video approve` posts straight through and
194
+ works on either route. `exodus workflow cancel` also works at a video gate; it
195
+ has no preflight.
196
+
197
+ - **`flag` is Show-only and fails loudly.** `exodus video flag` on a workflow run
198
+ answers "Flag-with-note is only available on Video-module ads."
199
+ (`convex/videoModule.ts:2208`, guarded on `!run.showId`). Script-turn edits
200
+ refuse the same way (`:2334`). There is **no** CLI path that sends a workflow
201
+ run's storyboard back with a note — the direct-edit mutation exists but is
202
+ wired only to the dashboard's gate screen, not to any API route. From the
203
+ terminal the storyboard is approve-or-cancel: `exodus workflow cancel <runId>`
204
+ and run again with a better script, or edit the workflow. Do not offer `flag`
205
+ to a user on the workflow route.
206
+ - **`--wait` lands on both video parks, but only those.** Since #1818 every
207
+ `--wait` caller — `workflow run`, `triggers fire`, `checkpoint approve|retry`,
208
+ `repair retry|skip` — ends at a storyboard gate or a final watch, exits 0, and
209
+ prints the same `Parked:` block `video status` does
210
+ (`videoParkStop`, `exodus/commands/workflow.ts`). A Checkpoint, repair or
211
+ slots park is still not a landing: it prints its notice once and the loop
212
+ keeps polling, so a resolve from another shell carries the run on. Do not read
213
+ a park banner mid-poll as the wait having finished.
214
+ - **The run's page link differs.** A Show run's `dashboardUrl` opens
215
+ `/video?show=…&ad=…`; a showless run gets `/runs/<runId>`, because the `/video`
216
+ page cannot render one (`convex/http.ts:9204-9211`). The manifest's
217
+ `dashboardUrl` is always `/video?ad=<runId>`, which is the Show-route shape —
218
+ on a workflow run, hand the user the link `upload` prints, not the manifest's.
79
219
 
80
220
  ## The script file
81
221
 
82
- Plain text. A `CAST:` block that names the speakers exactly as the Show locked
83
- them, one line per turn, then a `CTA:` block. Keep each turn near 14 words so
84
- a clip stays near 6 seconds. If a line does get truncated in its clip, the
85
- manifest says so (`speech-cutoff`).
222
+ Plain text. A `CAST:` block naming the speakers, one line per turn, then a
223
+ `CTA:` block. On a Show run the names must match what the Show locked. The
224
+ planner sizes every line at
225
+ 2.5 words per second (`WORD_LADDER_WPS`), so a turn near 14 words plans a clip
226
+ near 6 seconds. If a line does get truncated in its clip, the manifest says so
227
+ (`speech-cutoff`).
86
228
 
87
229
  ```
88
230
  CAST:
@@ -98,161 +240,293 @@ THE SOFTGEL: Tap below and give your eyes ClearBlink.
98
240
 
99
241
  ## Reading the storyboard gate
100
242
 
101
- `storyboard <runId>` prints one card per scene: who speaks, the line, the
102
- planned duration, and whether its picture is drawn. Read it for the user, then
103
- ask one question: approve, or flag with a note. A flag note is read by the
104
- model that rewrites the storyboard, so make it concrete ("scene 2 should be a
105
- close-up of the softgel, not the eyes"). Do not approve on the user's behalf
106
- unless they told you to run the whole loop unattended.
243
+ `storyboard <runId>` prints one card per scene on both routes: who speaks, the
244
+ line, the planned duration, and whether its picture is drawn. Read it for the
245
+ user, then ask one question.
246
+
247
+ On a **workflow run** the question is approve or don't — there is no flag. If
248
+ the storyboard is wrong, say so plainly and let the user decide between
249
+ approving anyway, cancelling (`exodus workflow cancel <runId>`) and re-running
250
+ with a better script, or changing the workflow. Approving is the cost gate, so
251
+ do not approve a storyboard you have just told the user is wrong.
252
+
253
+ On a **Show run** you can also send it back: `video flag <runId> --note "…"`.
254
+ The note is read by the model that rewrites the storyboard, so make it concrete
255
+ ("scene 2 should be a close-up of the softgel, not the eyes").
107
256
 
108
- ## What `pull` writes, and what the manifest means
257
+ Either way, do not approve on the user's behalf unless they told you to run the
258
+ whole loop unattended.
259
+
260
+ ## The handover: what `pull` gives you
109
261
 
110
262
  `pull` writes a flat folder and a `manifest.json` that indexes it. The manifest
111
- is the data you decide from; the filenames are its values. Scene numbers are
112
- the run's own (they start at 1; scene 0 in `status` is the reference still).
263
+ is the data you decide from; the filenames are its values. Scene numbers are the
264
+ run's own. Every `pull` re-downloads and overwrites every file, so running it
265
+ again is always safe.
113
266
 
114
267
  ```
115
- storyboard.json scene text, order, planned durations, cast looks
116
- reference.<ext> the reference still the pictures were drawn from
117
- scene-NN.keyframe.<ext> the picture for scene NN
268
+ storyboard.json the whole storyboard envelope (see below)
269
+ reference.<ext> the reference still the scene pictures were drawn from
270
+ cast-<characterId>.<ext> one identity still per locked cast member
271
+ cast-ref-NN.<ext> an identity still the cast lock does not claim
272
+ scene-NN.keyframe.<ext> the picture scene NN's clip was animated from
273
+ scene-NN.<ext> the clip for scene NN, usually .mp4
118
274
  scene-NN.voice.<ext> the voice track for scene NN, when one was delivered
119
- (a Show whose voices live inside the clips delivers none;
120
- on a continuous-narrator run this is a cut of narration.mp3)
121
- narration.mp3 one continuous narrator take for the whole script (#1802),
122
- when the run used continuous narration
123
- narration.json takeHash, voice/model/speed, master-timeline words, scene
124
- cut ranges into narration.mp3
125
- scene-NN.<ext> the clip for scene NN, usually .mp4, voice included
126
- scene-NN.words.json [{w, s, e}] word timings for the speech in that scene, from
127
- the clip's own dialogue or, on a narrated scene, from the
128
- voice track (seconds from the start of that file)
129
- music.<ext> the music bed, composed to the clips' total length
130
- manifest.json the index below (includes narration { file, timing } when
131
- a continuous master landed)
275
+ scene-NN.words.json [{w, s, e}] word timings for that scene's speech
276
+ narration.mp3 one continuous narrator take of the whole script
277
+ narration.json that take's settings, master-timeline words, and the
278
+ per-scene ranges it was cut into
279
+ music.<ext> the music bed
280
+ manifest.json the index below
132
281
  ```
133
282
 
134
283
  `manifest.json`:
135
284
 
136
285
  ```
137
- runId, pulledAt, dashboardUrl the run and its page
286
+ runId, pulledAt, dashboardUrl the run and its page (/video?ad=<runId>)
138
287
  storyboard, reference, music filenames, or null when not delivered
288
+ narration { file, timing }, or null on a per-scene run
289
+ cast[] one per identity still
290
+ characterId, name from the run's cast lock; null for an
291
+ unclaimed ledger still
292
+ file, status, error
139
293
  scenes[] one per scene, in order
140
294
  sceneIndex, durationSec the run's number and the clip's real length
141
295
  clip, voice, keyframe, words filenames, or null
142
- wordsFrom clip | voice, which file the word times are
143
- timed against; null when none came
296
+ wordsFrom clip | voice — which file the word times are
297
+ measured against; null when none came
144
298
  clipStatus done | failed | running | pending | missing
145
299
  error why the clip failed, when it did
146
300
  flagged, findings[] QC verdict: {check, code, severity: fail|warn, detail}
301
+ keyframeFindings[] the same, from the picture check; null when
302
+ nobody checked the picture
147
303
  qc {verdict, attempts} from the renderer
304
+ revoiced the cast's pinned voice replaced the clip's
305
+ generated voice; null when there is no clip
306
+ speechTrimmed the clip was re-cut to its spoken words
307
+ (0.5 s before the first, 0.8 s after the
308
+ last); null when there is no clip
148
309
  failed[] files that did not download: {file, url, error}
149
310
  ```
150
311
 
151
- How to read it before cutting:
152
-
153
- - `clipStatus: "done"` and a `clip` filename: the scene is cuttable.
154
- - `wordsFrom: "voice"` (or narration in the storyboard with no on-camera line):
155
- a narrated scene. Its clip is picture only; the voice track is the sound.
156
- Never cut a narrated scene with the clip's own audio.
157
- - Continuous master present (`manifest.narration`): listen to `narration.mp3`
158
- and the joins between adjacent scene voice files before approving the cut.
159
- Voice-ID match alone does not prove continuity (#1802).
312
+ ### What each piece is
313
+
314
+ **The clips** (`scene-NN.<ext>`). The finished take, already trimmed and
315
+ revoiced where the run did that — not a raw render you have to repair.
316
+ `durationSec` is its real measured length. Whether the clip carries sound
317
+ depends on the scene; see "Which scenes carry their voice where" below.
318
+
319
+ **The storyboard** (`storyboard.json`). The intended cut, in the planner's own
320
+ terms. `script[]` is the ad's spoken words in order — `{lineId, speakerId, text}`,
321
+ with `speakerId: null` meaning narrator VO. Each scene carries `sceneIndex`,
322
+ `durationSec`, `dialogue[]`, `voText`, `notes`, and `lineIds` (the script lines
323
+ that scene renders). A scene with `kind: "cutaway"` carries `cueLineId` instead:
324
+ it is a picture-only takeover of the frame while the spoken track underneath
325
+ keeps running, so it adds no audio and takes no place in the running order. The
326
+ envelope also carries `overlays[]` (each with a `cueLineId` and a `placement`)
327
+ and `audio` (`roomTone`, `music`, `sfxCues[]`) — the planner's notes on what
328
+ should sit on top. **Route difference:** on a Show run the image and motion
329
+ prompts (`framePrompt`, `videoPrompt`, `referencePrompt`) are stripped before
330
+ the file reaches you, for everyone including admins — they are style-pack IP
331
+ (`convex/workflows.ts:13847`, `isModuleRun = !!run.showId`). A workflow run has
332
+ no `showId`, so an admin key gets them in full. Their absence on a Show run is
333
+ by design, not a broken pull.
334
+
335
+ **The narration track** (`narration.mp3` + `narration.json`). Present when the
336
+ run recorded continuously: every narrated scene's `voText` joined into one
337
+ script and read by ElevenLabs in a single pass at speed 1.15 — one take, one
338
+ pace, start to finish. `narration.json` carries `takeHash`, `voiceId`,
339
+ `modelId`, `speed`, `alignment: "elevenlabs-timestamps"`, the master's word
340
+ timings, and `cuts[]`: for each scene, `startSec` and `durationSec` **into
341
+ narration.mp3**. Runs go per-scene instead when any scene has on-camera
342
+ dialogue, when the voiceover node is configured `take: "per-scene"`, or on an
343
+ older storyboard carrying no `script[]`; then there is no `narration.mp3` and
344
+ each scene's voice is its own recording. That choice is made from the storyboard
345
+ and the node config (`planNarration`), not from the route — a workflow run and a
346
+ Show run reach it the same way.
347
+
348
+ **The per-scene voice tracks** (`scene-NN.voice.<ext>`). On a continuous run
349
+ these are cut out of `narration.mp3` itself — the same audio, sliced at the
350
+ ranges in `narration.json` with a 0.2 s pad and a boundary at the midpoint of
351
+ each gap. They are not separate performances. On a per-scene run each one is its
352
+ own ElevenLabs render.
353
+
354
+ **Which scenes carry their voice where.** A scene whose storyboard entry has a
355
+ non-empty `voText` is a narrator scene: its clip is rendered silent and the
356
+ voice arrives as a separate track. A scene with on-camera `dialogue[]` has the
357
+ speech inside the clip — under the default voice path the video model's invented
358
+ voice is then replaced, in place, with the cast member's pinned ElevenLabs voice
359
+ (that is `revoiced: true`). Other voice paths leave the render owning its own
360
+ audio, e.g. lip-sync retargeting a VO track onto the video. The path is frozen
361
+ on the run — `--voice-path` on `video start` sets it on a Show run, the node
362
+ config sets it on a workflow run. Read `wordsFrom` and `revoiced` per scene
363
+ rather than assuming.
364
+
365
+ **Word timings** (`scene-NN.words.json`). Seconds from the start of the file
366
+ `wordsFrom` names, always. `wordsFrom: "clip"` is an ElevenLabs Scribe
367
+ (`scribe_v1`) transcription of that clip's own audio, taken blind — the model
368
+ never sees the script — and re-based if the clip was speech-trimmed.
369
+ `wordsFrom: "voice"` is the ElevenLabs alignment of the continuous master,
370
+ re-based to the start of that scene's cut; on a per-scene run it is instead a
371
+ blind Scribe pass over that scene's own voice track. Nothing is aligned to the
372
+ finished ad — that timeline does not exist until you make it.
373
+
374
+ **Cast identity stills** (`cast-*.<ext>`). A full-body identity still per cast
375
+ member, on a plain neutral background in the run's style, minted once and
376
+ frozen onto the run as its cast lock. Every scene picture was anchored to these,
377
+ which is why the same character holds across scenes. `cast-<characterId>` is a
378
+ locked member; `cast-ref-NN` is a still from the same minting the lock does not
379
+ claim. Useful as reference when you cut, and as the thing to point at when a QC
380
+ finding says `wrong-character`.
381
+
382
+ **The music bed** (`music.<ext>`). One continuous track for the whole ad,
383
+ generated by Eleven Music after the last clip lands, at the landed clips' summed
384
+ length plus a 3-second safety pad. One track per ad, never per-scene. It is
385
+ never mixed into any clip and never levelled — gain, fade and ducking are the
386
+ cut-maker's decisions. `--no-music` on `start` means no artifact at all, and a
387
+ music failure degrades to a warning rather than blocking the run.
388
+
389
+ ### Reading it before you cut
390
+
391
+ - `clipStatus: "done"` with a `clip` filename: the scene is cuttable.
392
+ - `wordsFrom: "voice"`, or a scene whose storyboard entry has `voText` and no
393
+ on-camera line: the clip is picture only and the voice track is the sound.
394
+ Do not use that clip's own audio.
395
+ - `manifest.narration` present: listen to `narration.mp3` end to end, then the
396
+ joins between adjacent `scene-NN.voice` files, before you trust the cut.
397
+ Matching voice IDs and a one-speaker diarization result do not prove
398
+ continuity (#1802).
160
399
  - `flagged: true` is a warning, not a block. The clip was delivered anyway; the
161
400
  findings say what the QC model saw (`wrong-character`, `eyeline-off`,
162
401
  `set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
163
402
  flagged and why, and let them decide whether to keep, trim, or drop each one.
164
403
  A `speech-cutoff` on the CTA scene is the one to worry about; the ad's last
165
- words are missing.
166
- - `clipStatus: "failed"` or `"missing"` with a `keyframe`: `durationSec` is
167
- null (it comes from the clip), but the scene can still hold its place as a
168
- still picture for the length of its voice track, or the storyboard's planned
169
- duration when there is no voice. The first-cut script does this by itself.
404
+ words are missing. `status` shows the checker's own wording behind each
405
+ finding; the manifest carries only the verdict.
406
+ - `clipStatus: "failed"` or `"missing"` with a `keyframe`: `durationSec` is null
407
+ (it comes from the clip), but the scene can still hold its place as a still —
408
+ for the length of its voice track, or the storyboard's planned duration when
409
+ there is no voice.
170
410
  - `clipStatus: "running"` or `"pending"`: pull again later. `status` says when
171
411
  the run parks at the final watch, which means nothing is still rendering.
172
- - `failed[]` non-empty: run the same `pull` again. It fetches every file again
173
- and overwrites, so nothing is lost; the new manifest says what still failed.
174
-
175
- ## Making the first cut
176
-
177
- Use the bundled script. It reads the manifest, puts every A-roll scene in order
178
- as the spine, lays each cutaway over the spine at the moment its line is spoken,
179
- normalizes everything to 1080x1920 at 24 fps, and writes one MP4 with faststart
180
- for upload. The audio track comes first: a narrated scene (narration in the
181
- storyboard, no on-camera line) plays its voice track and nothing the clip
182
- recorded, and the picture is fitted to the voice (a longer clip is trimmed, a
183
- longer voice is sped up to at most 1.26x and then the last frame holds). A
184
- dialogue scene keeps the line it performed. Every segment is loudness-normalized
185
- to -16 LUFS, a continuous room-tone bed runs under the whole ad so the joins do
186
- not read as dead air, and the music bed sits underneath at a low level. The path
187
- is from the workspace root; from a brand subfolder, prefix `../`:
188
-
189
- ```
190
- node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId>
191
- node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId> --skip 2,5 --no-music --out ./v2.mp4
192
- ```
193
-
194
- It prints what went in, what was left out and why, and the upload command.
195
- Reach for raw ffmpeg only when the user wants something the script does not do
196
- (a trim, a reorder, a different aspect). When you do, keep the script's output
197
- contract: H.264 + AAC in an MP4, an even-sized frame, `-movflags +faststart`.
198
-
199
- ### The word times that come out with the cut
200
-
201
- Nothing burns captions into the video. Captions belong in post, in whatever tool
202
- the editor uses, so the script hands those tools the timings instead. Beside the
203
- MP4 it writes two files named from the same basename. `cut.words.json` is every
204
- spoken word of the finished ad with its start and end in seconds. `cut.srt` is
205
- the same words grouped into short subtitle lines. `--out v2.mp4` names them
206
- `v2.words.json` and `v2.srt`.
207
-
208
- The times are on the finished ad's timeline, so a word's time is where you hear
209
- it in the MP4. Cutaways contribute nothing, because their audio never plays. A
210
- narrated scene counts even when it is only a picture and a voice track: its word
211
- times come from the voice track, and `manifest.json` says `wordsFrom: "clip"` or
212
- `"voice"` per scene so you can tell which file a scene's times were read off. A
213
- scene that came with no word times leaves a gap, and the report names it. When no
214
- scene delivered word times the script writes neither file and says so. Tell the
215
- user the two files exist and that they are what the editor imports for captions.
216
-
217
- ### Where a cutaway lands
218
-
219
- The storyboard marks some scenes `kind: "cutaway"` and gives each one a
220
- `cueLineId`, the script line the cutaway belongs to. A cutaway is a picture-only
221
- takeover. It replaces the picture for its own length while the spoken track
222
- underneath keeps running, so it never takes a place in the running order and
223
- never adds audio. That is why the cut is as long as the A-roll scenes alone. A
224
- cutaway is cut short where the next cutaway starts or where the ad ends, and
225
- the report says so.
226
-
227
- The script places each cutaway at the real time its cued line is spoken. It
228
- finds the A-roll scene whose `lineIds` hold the cue line, counts the words of
229
- the lines spoken before it in that scene, and reads that word's start time out
230
- of `scene-NN.words.json`. When the scene has no word times, it falls back to a
231
- proportion, the share of the scene's words that come before the cue line scaled
232
- to the scene's real length. The proportion is an estimate and can land a beat
233
- early or late, so the report names which of the two methods placed each cutaway.
234
-
235
- A cutaway whose cue line is spoken by no scene in the cut cannot be placed, so
236
- the script leaves it out and says so. Skipping the A-roll scene that speaks the
237
- cue line does that, and so does a storyboard that never claims the line.
238
-
239
- Before uploading, look at what you made: `ffprobe -v error -show_entries
240
- format=duration -of default=nw=1:nk=1 cut.mp4` should be close to the sum of
241
- the scene durations you kept. A cut that is a few seconds long when five clips
242
- went in means a filter dropped inputs; do not upload it.
243
-
244
- ## Uploading and approving
245
-
246
- `upload <runId> --file cut.mp4` accepts MP4, MOV or WebM up to 200 MB. It reads
247
- the length off the file (MP4 headers, or ffprobe for the rest); when it cannot,
248
- it says so and you pass `--duration <sec>`.
249
- It prints the run's page, where the user can watch the cut, and the approve
250
- command. Approve only when the user has seen the cut or told you to run
251
- unattended; `approve` marks the cut as the ad and moves the run to delivery.
412
+ - `failed[]` non-empty: run the same `pull` again.
413
+
414
+ ## Cutting the ad
415
+
416
+ Editing is the part every user does differently, and the team is deliberately
417
+ still finding out what works. Nothing below is the house way. Ask the user which
418
+ they want; if they have not said and are not around, say which you used and why.
419
+
420
+ - **The bundled script** — `node .claude/skills/exodus-video/scripts/first-cut.mjs
421
+ ./ad-<runId>`, path from the workspace root (from a brand subfolder, prefix
422
+ `../`). A plain assembly with no editing decisions in it, for seeing the whole
423
+ ad end to end quickly. Read "What the bundled script does" before you run it:
424
+ it changes the audio.
425
+ - **HyperFrames** — HTML/CSS compositions rendered to video. Suits an ad that
426
+ needs captions, title cards, overlays or motion graphics laid over the clips.
427
+ It is a separate skill pack; check that a `hyperframes` skill is installed
428
+ before offering it.
429
+ - **An NLE over MCP** — DaVinci Resolve publishes an MCP server, so a timeline
430
+ can be built in a real editor and the user can carry on by hand afterwards.
431
+ Suits a user who wants to keep editing after you stop. Check what MCP servers
432
+ are actually connected first; do not assume one is there.
433
+ - **ffmpeg by hand** — full control with nothing extra to install. Suits a trim,
434
+ a reorder, an aspect change, or anything the others make awkward.
435
+
436
+ Whatever you use, the only thing the product requires of the output is that
437
+ `upload` accepts it (see below).
438
+
439
+ ### What the storyboard asks the cut to do
440
+
441
+ Read these out of `storyboard.json` before you build anything, in any tool:
442
+
443
+ - **Running order.** A-roll scenes (no `kind`, or `kind: "aroll"`) in
444
+ `sceneIndex` order are the spine. Their summed length is the ad's length.
445
+ - **Cutaways.** `kind: "cutaway"` + `cueLineId`: lay the picture over the spine
446
+ at the moment that script line is spoken, taking no audio and no place in the
447
+ running order. To find the moment, locate the A-roll scene whose `lineIds`
448
+ contain the cue line, then read the start time of the first word of that line
449
+ out of that scene's `words.json`.
450
+ - **Overlays and SFX.** `overlays[]` and `audio.sfxCues[]` are cued the same
451
+ way, by `cueLineId`. Nothing renders them for you.
452
+ - **Captions.** Nothing is burned into any clip. Captions belong in post; the
453
+ word timings are what an editor imports to build them.
454
+
455
+ ### What the bundled script does
456
+
457
+ State this to the user before you run it, because an editor does not expect a
458
+ cut tool to alter the performance:
459
+
460
+ - **It changes the narration's speed.** When a narrated scene's voice runs
461
+ longer than its clip, it speeds that scene's voice up — to at most 1.26×
462
+ (`ATEMPO_CAP`, `scripts/first-cut.mjs:39`) — and holds the last frame for the
463
+ remainder. The amount differs per scene, so one ad can play scene 1 at normal
464
+ speed and scene 3 a quarter faster. **This is the script's behaviour, not the
465
+ system's**; nothing in the pipeline does it. It is the defect tracked in
466
+ #1814. If the user cares about a single consistent narrator pace, cut
467
+ elsewhere, or skip the scenes it stretches.
468
+ - **It ignores the continuous master.** It reads only `scene-NN.voice` files;
469
+ it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
470
+ master down whole and cuts the pictures to it keeps the read intact.
471
+ - Otherwise: A-roll in manifest order as the spine, each cutaway laid over the
472
+ spine at its cued word (falling back to a proportional estimate when the scene
473
+ has no word times, and saying which method it used), a narrated scene's voice
474
+ replacing whatever the clip recorded, a dialogue scene keeping its performed
475
+ line, a clip-less scene becoming a still, everything normalized to 1080×1920
476
+ at 24 fps and −16 LUFS per segment, a generated room-tone bed under the whole
477
+ ad, and the music bed underneath at low level. Output is H.264 + AAC in an MP4
478
+ with `-movflags +faststart`.
479
+ - Flags: `--out <file>`, `--skip 2,5`, `--no-music`. It prints what went in,
480
+ what was left out and why, and the upload command.
481
+ - Beside the MP4 it writes `cut.words.json` (every spoken word of the finished
482
+ ad with its start and end) and `cut.srt` (the same words grouped into subtitle
483
+ lines), named from the `--out` basename. Those times are on the finished ad's
484
+ timeline and include the speed changes above. Scenes with no word times leave
485
+ gaps and are named in the report; when no scene delivered any, neither file is
486
+ written. These sidecars are the script's own output — another tool will not
487
+ produce them.
488
+
489
+ Before uploading anything, from any tool, check what you made:
490
+ `ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 cut.mp4`
491
+ should be close to the sum of the scene durations you kept. A cut that is a few
492
+ seconds long when five clips went in means a filter dropped inputs; do not
493
+ upload it.
494
+
495
+ ## What the product actually requires
496
+
497
+ These are the system's rules. They hold no matter which tool made the cut.
498
+
499
+ **`upload` accepts** `.mp4`, `.m4v`, `.mov` or `.webm`, up to 200 MB. The server
500
+ re-derives the type from the stored bytes and rejects anything that is not video.
501
+ The length comes from `--duration` if you pass it, else `ffprobe`, else the MP4
502
+ header; when none of those works the CLI says so and asks for `--duration`. The
503
+ length you give is recorded as stated and never measured against the file, so
504
+ give a real one.
505
+
506
+ **`upload` only works at the final watch.** The run has to be parked there with
507
+ its video step done, or the server answers "This ad isn't waiting for your final
508
+ cut right now." On a workflow run that park exists only when the video node sets
509
+ `finalWatch: true`; a Show run always has it. Uploading again replaces the
510
+ previous cut in place rather than adding a second one. Upload does not move the
511
+ run; it stays parked until someone approves.
512
+
513
+ **Approving is what makes it the ad, and it is one-way.** `approve` at the final
514
+ watch stamps the run delivered and resumes it. After that the server refuses any
515
+ further upload ("This ad is already delivered — nothing to change"). So approve
516
+ only when the user has seen the cut or told you to run unattended.
517
+
518
+ **Nothing checks that the cut matches the approved ad.** The server verifies
519
+ that the file is a video and that the run is at the right gate. It never
520
+ compares the cut against the script or the storyboard. The approved spoken copy
521
+ and the approved narration are therefore not the editor's to change — reordering
522
+ lines, re-recording narration, or cutting words out of the CTA will ship,
523
+ silently. Cut the pictures to the words, not the words to the pictures. If the
524
+ words are wrong, that is a storyboard flag or a new run, not an edit.
252
525
 
253
526
  ## Reporting to the user
254
527
 
255
528
  Lead with where the run is and what it needs from them. Then, when you pulled
256
529
  or cut: how many scenes, which are flagged and why in one line each, what you
257
- left out, the cut's length, and the exact command or link for the next step.
258
- Never paste the manifest; summarize it.
530
+ left out, which tool you cut with and anything it changed, the cut's length, and
531
+ the exact command or link for the next step. Never paste the manifest;
532
+ summarize it.
@@ -110,7 +110,7 @@ The end-to-end narrative, every step naming its verb. You rarely walk all of it
110
110
  3. **Import** — `workflow import <file>` to create, or `import <file> --update <id>` to edit in place.
111
111
  4. **Describe** — `workflow describe` to confirm inputs, prerequisites (✓/✗ primers), and outputs before spending a run.
112
112
  5. **Run** — `workflow run … --wait` (streams progress, prints outputs) or omit `--wait` and poll `workflow status --id <runId>`. Inputs come from `--input key=value` flags, from `--fill <name>` (a saved fill — a named, reusable set of launch answers saved from the app's Run dialog; flags win per key when you pass both), or a mix. Launches that don't satisfy the workflow's entry contract are rejected at the door with the gap named — `describe` shows each input's type, required/optional, and accepted values. Add `--auto-approve` only when the run is meant to go start-to-finish with nobody watching: it approves every Checkpoint the run hits, unchanged, instead of stopping for a verdict. It's a per-launch choice, not a setting on the workflow, and the run keeps a record of each stop it waved through — so confirm with the user before you pass it.
113
- 6. **Notice the park** — a run can stop and wait on you. There are no webhooks in v1; you notice a park one of three ways: `run --wait` prints a pause notice naming the verb to use (the command keeps waiting — resolve from another shell, or Ctrl-C and pick it up via `inbox`), `workflow inbox` lists it, or `workflow status --id <runId>` prints `status: Awaiting approval`.
113
+ 6. **Notice the park** — a run can stop and wait on you. There are no webhooks in v1; you notice a park one of three ways: `run --wait` prints a pause notice naming the verb to use (the command keeps waiting — resolve from another shell, or Ctrl-C and pick it up via `inbox`), `workflow inbox` lists it, or `workflow status --id <runId>` prints `status: Awaiting approval`. One exception: a **video** workflow's storyboard gate and final watch END the wait (exit 0) and print the same `Parked: …` block `exodus video status` prints, with the next command under it — resolve with the `exodus video` verbs it names (see the `exodus-video` skill).
114
114
  7. **Resolve** — `workflow checkpoint <runId> …` (the run reached a Checkpoint box), `workflow repair <runId> …` (a stalled collector), or `workflow answer <runId> …` (a nested workflow's slots).
115
115
  8. **Harvest** — read deliverables with `workflow status --id <runId> --json`; read banked deposits with `bank show <key>`; keep thinking with a bot via `session chat`.
116
116
  9. **Promote** — `bank promote <key>` lands the winner **and** emits the `winner-promoted` event, so any workflow with an **enabled** matching trigger starts a background run. To find out whether one actually started, run `workflow status` with **no** `--id` — the recent-run list. Don't send the user to `inbox` for this: `inbox` only holds runs waiting on a person, and a flywheel run with no approval step will never appear there (§5).
@@ -192,7 +192,7 @@ A run doesn't always finish on its own — it can **park** and wait for a human
192
192
  - **repair** — a require-all collector stalled on a dead input and needs a decision.
193
193
  - **slots** — a nested sub-workflow is waiting on inputs (slot answers).
194
194
 
195
- **No webhooks in v1 — nothing parks silently, but nothing pings you either.** You find parks three ways: `run --wait` prints a pause notice naming the verb to use (a park is not terminal — the command keeps polling so it can resume after you resolve; resolve from another shell, or Ctrl-C and pick it up via `inbox`); `workflow inbox` lists every parked run badged by kind (`checkpoint`/`repair`/`slots`; the badge `gate` shows up only on a pre-2.0 run parked at the retired Gate node — those can't be resumed, cancel and run the workflow again) and how it started (`bg`, `trig:<event>`); or `workflow status --id <runId>` prints `status: Awaiting approval`.
195
+ **No webhooks in v1 — nothing parks silently, but nothing pings you either.** You find parks three ways: `run --wait` prints a pause notice naming the verb to use (a builder park is not terminal — the command keeps polling so it can resume after you resolve; resolve from another shell, or Ctrl-C and pick it up via `inbox`. A **video** workflow's storyboard gate and final watch are the exception: they end the wait with exit 0 and the `Parked: …` block `exodus video status` prints, next command included); `workflow inbox` lists every parked run badged by kind (`checkpoint`/`repair`/`slots`; the badge `gate` shows up only on a pre-2.0 run parked at the retired Gate node — those can't be resumed, cancel and run the workflow again) and how it started (`bg`, `trig:<event>`); or `workflow status --id <runId>` prints `status: Awaiting approval`.
196
196
 
197
197
  ### `inbox` vs the run list — two different questions
198
198
 
@@ -9,6 +9,7 @@ import { runVerdict } from "../lib/runVerdict.js";
9
9
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
10
10
  import { missingRouteLine } from "../lib/route-support.js";
11
11
  import { getChannel } from "../lib/channel.js";
12
+ import { asVideoRun, classifyRun, stopLines } from "./video.js";
12
13
  export const helpText = `
13
14
  exodus workflow — List, describe, run, inspect, import, and export saved workflows
14
15
 
@@ -90,6 +91,13 @@ Flags:
90
91
  person's verdict. This is a choice you make for ONE
91
92
  launch — it is never a setting on the workflow itself.
92
93
  --wait Poll until the workflow run reaches a terminal status.
94
+ A video workflow's storyboard gate and final watch END
95
+ the wait too (exit 0): the run is parked on a decision
96
+ only you can make, so the command prints the same
97
+ "Parked: …" block and next commands "exodus video
98
+ status" prints. A Checkpoint / repair / slots park
99
+ prints its pause notice once and keeps polling so a
100
+ resolve from another shell carries the run on.
93
101
  (checkpoint retry) Re-runs the step feeding the
94
102
  Checkpoint box and waits until its fresh output is
95
103
  ready and the run parks again — that IS its finish
@@ -1796,30 +1804,36 @@ export async function runFlow(workflowRef, opts, deps) {
1796
1804
  return { code: waited.code, lines: [...lines, ...waited.lines] };
1797
1805
  }
1798
1806
  const WAIT_TERMINAL_STATUSES = TERMINAL_RUN_STATUSES.flatMap((s) => storedWorkflowStatusForms(s));
1807
+ function isParkedSnapshot(raw) {
1808
+ const status = raw["status"];
1809
+ return typeof status === "string" && normalizeRunStatus(status) === "awaiting-approval";
1810
+ }
1811
+ function videoParkStop(raw) {
1812
+ if (!isParkedSnapshot(raw))
1813
+ return undefined;
1814
+ const stop = classifyRun(asVideoRun(raw));
1815
+ return stop.at === "storyboard-gate" || stop.at === "final-watch" ? stop : undefined;
1816
+ }
1799
1817
  async function waitForRun(runId, opts, deps) {
1800
1818
  const seen = new Map();
1801
1819
  let pausedNotified = false;
1802
1820
  const landOnPark = opts.landOnPark;
1821
+ const isLandingPark = (raw) => (landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason) ||
1822
+ videoParkStop(raw) !== undefined;
1803
1823
  const pollResult = await deps.poll({
1804
1824
  path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
1805
1825
  intervalMs: 3_000,
1806
1826
  timeoutMs: 60 * 60 * 1000,
1807
- terminalStatuses: landOnPark
1808
- ? [...WAIT_TERMINAL_STATUSES, ...storedWorkflowStatusForms("awaiting-approval")]
1809
- : WAIT_TERMINAL_STATUSES,
1810
- ...(landOnPark
1811
- ? {
1812
- isDone: (raw) => !(typeof raw["status"] === "string" &&
1813
- normalizeRunStatus(raw["status"]) === "awaiting-approval") || raw["pauseReason"] === landOnPark.pauseReason,
1814
- }
1815
- : {}),
1827
+ terminalStatuses: [
1828
+ ...WAIT_TERMINAL_STATUSES,
1829
+ ...storedWorkflowStatusForms("awaiting-approval"),
1830
+ ],
1831
+ isDone: (raw) => !isParkedSnapshot(raw) || isLandingPark(raw),
1816
1832
  onProgress: (raw) => {
1817
1833
  if (opts.json || !opts.onProgressLine)
1818
1834
  return;
1819
- const rawStatus = raw["status"];
1820
- const parked = typeof rawStatus === "string" &&
1821
- normalizeRunStatus(rawStatus) === "awaiting-approval";
1822
- const isLanding = landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason;
1835
+ const parked = isParkedSnapshot(raw);
1836
+ const isLanding = isLandingPark(raw);
1823
1837
  if (parked && !pausedNotified && !isLanding) {
1824
1838
  pausedNotified = true;
1825
1839
  const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
@@ -1848,6 +1862,9 @@ async function waitForRun(runId, opts, deps) {
1848
1862
  const saved = opts.out !== undefined && terminalRun
1849
1863
  ? await saveDeliveries(terminalRun, opts.out, deps)
1850
1864
  : undefined;
1865
+ const videoStop = !pollResult.timedOut && isRecord(pollResult.data)
1866
+ ? videoParkStop(pollResult.data)
1867
+ : undefined;
1851
1868
  if (opts.json) {
1852
1869
  return {
1853
1870
  code: pollResult.ok ? 0 : 1,
@@ -1856,6 +1873,7 @@ async function waitForRun(runId, opts, deps) {
1856
1873
  ...opts.jsonBase,
1857
1874
  result: pollResult.data,
1858
1875
  timedOut: pollResult.timedOut,
1876
+ ...(videoStop ? { stop: videoStop } : {}),
1859
1877
  ...(saved ? { saved: saved.paths } : {}),
1860
1878
  }),
1861
1879
  ],
@@ -1884,6 +1902,9 @@ async function waitForRun(runId, opts, deps) {
1884
1902
  const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
1885
1903
  lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
1886
1904
  }
1905
+ if (videoStop) {
1906
+ lines.push("", ...stopLines(videoStop, runId, deps.dashboardUrl ?? getDashboardUrl()));
1907
+ }
1887
1908
  return { code: pollResult.ok ? 0 : 1, lines };
1888
1909
  }
1889
1910
  export async function statusFlow(opts, deps) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1700-beta.0",
3
+ "version": "2026.9.1700-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",