@aicopycoders/exodus 2026.9.2000-beta.2 → 2026.9.2000-beta.4

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.
@@ -152,7 +152,8 @@ run options:
152
152
  --mode auto|manual|hybrid Generation mode (default: auto)
153
153
  --render-mode images|prompts prompts = skip render, return LLM prompts only (default: images)
154
154
  --aspect 1:1|9:16 Image aspect ratio (default: 1:1)
155
- --model gpt-image-2|nano-banana-pro Kie.ai model (default: gpt-image-2)
155
+ --model gpt-image-2.5|gpt-image-2.5-sunburst|gpt-image-2|nano-banana-pro
156
+ Kie.ai model (default: gpt-image-2.5)
156
157
  --realism off|realistic Realism enforcement (default: off)
157
158
  --quantities "slug:N,slug:N" manual-mode per-type counts (e.g. "testimonial:3,hero:2")
158
159
  --pairings '<JSON>' hybrid-mode host/modifier pairs: '[{"host":"testimonial","modifier":"hero"}]'
@@ -244,6 +244,7 @@ internals into chat.
244
244
  - The rig's child image runs are still `creativeSuiteRuns` with engine `meme`,
245
245
  so finished memes appear in the creative-suite library alongside other engines
246
246
  and `exodus browse --agent meme` still covers them.
247
- - `run` accepts only the two models the rig renders (`gpt-image-2`,
248
- `nano-banana-pro`, or their Kie slugs); anything else is a 400 listing the
249
- accepted values. It used to accept any value and silently ignore it.
247
+ - `run` accepts only the models the rig renders (`gpt-image-2.5`, the house
248
+ default, plus `gpt-image-2.5-sunburst`, `gpt-image-2` and `nano-banana-pro`,
249
+ or their Kie slugs); anything else is a 400 listing the accepted values. It
250
+ used to accept any value and silently ignore it.
@@ -17,7 +17,8 @@ run options:
17
17
  --mode auto|manual|hybrid Generation mode (default: auto)
18
18
  --render-mode images|prompts prompts = skip render, return LLM prompts only (default: images)
19
19
  --aspect 1:1|9:16 Image aspect ratio (default: 1:1)
20
- --model gpt-image-2|nano-banana-pro Kie.ai model (default: gpt-image-2)
20
+ --model gpt-image-2.5|gpt-image-2.5-sunburst|gpt-image-2|nano-banana-pro
21
+ Kie.ai model (default: gpt-image-2.5)
21
22
  --realism off|realistic Realism enforcement (default: off)
22
23
  --quantities "slug:N,slug:N" manual-mode per-type counts (e.g. "testimonial:3,hero:2")
23
24
  --pairings '<JSON>' hybrid-mode host/modifier pairs: '[{"host":"testimonial","modifier":"hero"}]'
@@ -184,7 +184,7 @@ async function runTemplate(text, flags, who) {
184
184
  renderMode: "images",
185
185
  inputAds: text,
186
186
  aspectRatio: aspect,
187
- model: "gpt-image-2",
187
+ model: "gpt-image-2.5",
188
188
  realismMode: realisticFlag ? "realistic" : "off",
189
189
  ...(steering ? { steering } : {}),
190
190
  ...(requestedImageCount !== undefined ? { requestedImageCount } : {}),
@@ -17,7 +17,8 @@ Run options:
17
17
  --mode auto|manual Generation mode (default: auto)
18
18
  --render-mode images|prompts Skip render and return prompts only (default: images)
19
19
  --aspect 1:1|9:16 Image aspect ratio (default: 1:1)
20
- --model gpt-image-2|nano-banana-pro Kie.ai model (default: gpt-image-2)
20
+ --model gpt-image-2.5|gpt-image-2.5-sunburst|gpt-image-2|nano-banana-pro
21
+ Kie.ai model (default: gpt-image-2.5)
21
22
  --realism off|realistic Realism enforcement (default: off)
22
23
  --quantities <slug:N,slug:N> Manual-mode per-type counts (e.g. "testimonial:3,hero:2")
23
24
  --requested-count N Auto-mode total render target (optional)
@@ -103,9 +104,15 @@ async function runTemplate(flags) {
103
104
  process.exit(1);
104
105
  return;
105
106
  }
106
- const model = typeof flags["model"] === "string" ? flags["model"] : "gpt-image-2";
107
- if (model !== "gpt-image-2" && model !== "nano-banana-pro") {
108
- console.error(`Error: --model must be gpt-image-2 | nano-banana-pro (got "${model}").`);
107
+ const TEMPLATE_MODELS = [
108
+ "gpt-image-2.5",
109
+ "gpt-image-2.5-sunburst",
110
+ "gpt-image-2",
111
+ "nano-banana-pro",
112
+ ];
113
+ const model = typeof flags["model"] === "string" ? flags["model"] : "gpt-image-2.5";
114
+ if (!TEMPLATE_MODELS.includes(model)) {
115
+ console.error(`Error: --model must be one of ${TEMPLATE_MODELS.join(" | ")} (got "${model}").`);
109
116
  process.exit(1);
110
117
  return;
111
118
  }
@@ -72,7 +72,7 @@ export type ArtifactSubset = {
72
72
  export interface VideoRunNode {
73
73
  nodeId: string;
74
74
  kind: string;
75
- status: "idle" | "running" | "done" | "failed" | "skipped";
75
+ status: "idle" | "running" | "done" | "failed" | "skipped" | "out-of-scope";
76
76
  error?: string;
77
77
  warning?: string;
78
78
  outputs?: ArtifactSubset[];
@@ -173,6 +173,7 @@ export type RunStop = {
173
173
  at: "storyboard-gate";
174
174
  nodeId?: string;
175
175
  framesNodeId?: string;
176
+ showAd?: true;
176
177
  } | {
177
178
  at: "final-watch";
178
179
  } | {
@@ -201,6 +202,7 @@ export declare function resolveStop(stop: RunStop, cutAttached: boolean | null):
201
202
  export declare function resolveStopAtPark(stop: RunStop, runId: string, deps: Pick<VideoDeps, "get">): Promise<ResolvedStop>;
202
203
  export declare function stageWord(stage: string): string;
203
204
  export declare function stepName(kind: string | undefined): string;
205
+ export declare function isShowAd(run: Pick<VideoRun, "moduleOwned">): boolean;
204
206
  export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
205
207
  export declare function stopLines(stop: ResolvedStop, runId: string, runUrl: string): string[];
206
208
  export interface PullDownload {
@@ -293,7 +295,10 @@ export declare function waitFlow(runId: string, opts: {
293
295
  }, deps: VideoDeps): Promise<FlowResult>;
294
296
  export declare function statusFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
295
297
  export declare function storyboardFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
296
- export declare function approveFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
298
+ export declare function approveFlow(runId: string, opts: {
299
+ json: boolean;
300
+ approveStaleCut: boolean;
301
+ }, deps: VideoDeps): Promise<FlowResult>;
297
302
  export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
298
303
  export declare function retryFrameFlow(runId: string, nodeId: string, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
299
304
  export type ClipRedoPlan = {
@@ -62,12 +62,14 @@ The whole loop, in order:
62
62
  Attaches your cut to the run and prints the page to approve it on.
63
63
 
64
64
  11. exodus video approve <runId>
65
- Or click Approve on the page from step 10.
65
+ Or click Approve on the page from step 10. If you redid a clip after
66
+ uploading, this stops and says which scenes changed. Upload a fresh cut,
67
+ or add --approve-stale-cut to deliver the cut you already uploaded.
66
68
 
67
69
  Usage:
68
70
  exodus video status <runId> [--json]
69
71
  exodus video storyboard <runId> [--json]
70
- exodus video approve <runId> [--json]
72
+ exodus video approve <runId> [--approve-stale-cut] [--json]
71
73
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
72
74
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
73
75
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
@@ -88,6 +90,9 @@ Options:
88
90
  (retry-frame, retry-clip, revoice)
89
91
  --all Every finished clip that kept the video model's voice
90
92
  (revoice)
93
+ --approve-stale-cut Deliver the cut you uploaded even though a clip changed
94
+ after you uploaded it (approve). Without it, approving
95
+ stops and names the scenes that changed
91
96
  --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
92
97
  character by its ID or by the name your script uses.
93
98
  Repeat it once per character
@@ -245,6 +250,7 @@ export function classifyRun(run) {
245
250
  ...(pausedNode.kind === "scene-frames"
246
251
  ? { framesNodeId: pausedNode.nodeId }
247
252
  : {}),
253
+ ...(isShowAd(run) ? { showAd: true } : {}),
248
254
  };
249
255
  }
250
256
  if (parkedAtFinalWatch(run))
@@ -295,8 +301,11 @@ export function stepName(kind) {
295
301
  return "A step in this run";
296
302
  return STEP_NAMES[kind] ?? `The ${kind} step`;
297
303
  }
304
+ export function isShowAd(run) {
305
+ return run.moduleOwned === true;
306
+ }
298
307
  export function reviewUrl(dashboardUrl, run) {
299
- if (run.moduleOwned === true)
308
+ if (isShowAd(run))
300
309
  return `${dashboardUrl}/video?ad=${run._id}`;
301
310
  if (run.workflowId)
302
311
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
@@ -308,8 +317,10 @@ export function stopLines(stop, runId, runUrl) {
308
317
  "Parked: the storyboard is waiting for your yes.",
309
318
  `Read it: exodus video storyboard ${runId}`,
310
319
  `Approve it: exodus video approve ${runId}`,
311
- `Send back: exodus video flag ${runId} --note "what's wrong"`,
312
320
  ];
321
+ if (stop.showAd) {
322
+ lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
323
+ }
313
324
  if (stop.framesNodeId) {
314
325
  lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
315
326
  }
@@ -956,6 +967,11 @@ function itemWord(item) {
956
967
  return "flagged";
957
968
  return ITEM_STATUS_WORD[item.status] ?? item.status;
958
969
  }
970
+ const CARRYING_ON_LINE = "Waiting for a worker to carry on. Everything finished so far is kept — the run picks up from there by itself.";
971
+ function carryingOn(run) {
972
+ return (run.status === "queued" &&
973
+ run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
974
+ }
959
975
  export async function statusFlow(runId, json, deps) {
960
976
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
961
977
  if (!runRes.ok)
@@ -1007,6 +1023,7 @@ export async function statusFlow(runId, json, deps) {
1007
1023
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
1008
1024
  const lines = [
1009
1025
  headline,
1026
+ ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
1010
1027
  ...guidance,
1011
1028
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1012
1029
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
@@ -1108,7 +1125,14 @@ export async function storyboardFlow(runId, json, deps) {
1108
1125
  }
1109
1126
  }
1110
1127
  }
1111
- lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
1128
+ const runRes = await deps
1129
+ .get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
1130
+ .catch(() => null);
1131
+ const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
1132
+ lines.push("", `approve with: exodus video approve ${runId}`);
1133
+ if (showAd) {
1134
+ lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
1135
+ }
1112
1136
  if (cards.framesNodeId) {
1113
1137
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
1114
1138
  }
@@ -1136,14 +1160,30 @@ function sceneCardLines(scene) {
1136
1160
  lines.push(` picture: ${scene.frame.status}`);
1137
1161
  return lines;
1138
1162
  }
1139
- export async function approveFlow(runId, json, deps) {
1140
- const res = await deps.post(APPROVE_PATH, { runId });
1141
- if (!res.ok)
1142
- return errorResult(res, json);
1143
- if (json)
1144
- return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
1145
- const voices = res.data
1146
- ?.voices;
1163
+ export async function approveFlow(runId, opts, deps) {
1164
+ const res = await deps.post(APPROVE_PATH, {
1165
+ runId,
1166
+ ...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
1167
+ });
1168
+ if (!res.ok) {
1169
+ const refusal = errorResult(res, opts.json);
1170
+ const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
1171
+ return staleCut
1172
+ ? {
1173
+ ...refusal,
1174
+ lines: [
1175
+ ...refusal.lines,
1176
+ `To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
1177
+ ],
1178
+ }
1179
+ : refusal;
1180
+ }
1181
+ const data = res.data;
1182
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
1183
+ if (opts.json) {
1184
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
1185
+ }
1186
+ const voices = data?.voices;
1147
1187
  const summary = voices
1148
1188
  ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1149
1189
  : [];
@@ -1151,6 +1191,7 @@ export async function approveFlow(runId, json, deps) {
1151
1191
  code: 0,
1152
1192
  lines: [
1153
1193
  "Approved.",
1194
+ ...warnings,
1154
1195
  ...(summary.length > 0 ? ["", ...summary, ""] : []),
1155
1196
  `See what happens next: exodus video status ${runId}`,
1156
1197
  ],
@@ -1208,8 +1249,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1208
1249
  ],
1209
1250
  };
1210
1251
  }
1211
- const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
1212
- "re-cut, and upload again.";
1252
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
1253
+ "exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
1254
+ "or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
1213
1255
  export function planClipRedo(run, items, target) {
1214
1256
  const stop = classifyRun(run);
1215
1257
  const found = findClipRow(run, items, target);
@@ -2028,8 +2070,9 @@ export async function run(flags, occurrences) {
2028
2070
  return printResult(await statusFlow(runId, json, defaultDeps));
2029
2071
  if (sub === "storyboard")
2030
2072
  return printResult(await storyboardFlow(runId, json, defaultDeps));
2031
- if (sub === "approve")
2032
- return printResult(await approveFlow(runId, json, defaultDeps));
2073
+ if (sub === "approve") {
2074
+ return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2075
+ }
2033
2076
  if (sub === "flag") {
2034
2077
  const note = flagString(flags, "note");
2035
2078
  if (!note)
@@ -34,14 +34,60 @@ export interface WorkflowEdge {
34
34
  targetHandle: string;
35
35
  }
36
36
  export type WorkflowTriggerEvent = "winner-promoted";
37
+ export interface ImageRigLineOverride {
38
+ count?: number;
39
+ aspects?: string[];
40
+ adType?: string;
41
+ style?: string;
42
+ memeFormat?: string;
43
+ steering?: string;
44
+ copyPin?: string;
45
+ }
46
+ export interface ImageRigPlanOverride {
47
+ lines?: unknown[];
48
+ steering?: string;
49
+ defaultAspects?: string[];
50
+ defaultStyle?: string;
51
+ model?: string;
52
+ confirmLargeRun?: boolean;
53
+ }
54
+ export interface ImageRigNodeOverride {
55
+ plan?: ImageRigPlanOverride;
56
+ lines?: Record<string, ImageRigLineOverride>;
57
+ confirmLargeRun?: boolean;
58
+ model?: string;
59
+ }
60
+ export type ImageRigOverrides = Record<string, ImageRigNodeOverride>;
61
+ export interface VoiceRequest {
62
+ who: string;
63
+ choice: {
64
+ kind: "pin";
65
+ voiceId: string;
66
+ label?: string;
67
+ } | {
68
+ kind: "clear";
69
+ };
70
+ }
71
+ export type TriggerVoiceMap = Record<string, string | {
72
+ voiceId: string;
73
+ label?: string;
74
+ } | null>;
37
75
  export type WorkflowTrigger = {
38
76
  type: "event";
39
77
  event: WorkflowTriggerEvent;
40
78
  enabled: boolean;
79
+ imageRigOverrides?: ImageRigOverrides;
80
+ voices?: VoiceRequest[];
41
81
  } | {
42
82
  type: "cron";
43
83
  cron: string;
44
84
  enabled: boolean;
85
+ imageRigOverrides?: ImageRigOverrides;
86
+ voices?: VoiceRequest[];
87
+ };
88
+ type WithoutVoices<T> = T extends unknown ? Omit<T, "voices"> : never;
89
+ export type WorkflowContractTrigger = WithoutVoices<WorkflowTrigger> & {
90
+ voices?: TriggerVoiceMap;
45
91
  };
46
92
  export interface WorkflowContractJson {
47
93
  contract: "exodus-workflow";
@@ -51,7 +97,7 @@ export interface WorkflowContractJson {
51
97
  name: string;
52
98
  description?: string;
53
99
  slots?: WorkflowSlot[];
54
- triggers?: WorkflowTrigger[];
100
+ triggers?: WorkflowContractTrigger[];
55
101
  nodes: WorkflowNode[];
56
102
  edges: WorkflowEdge[];
57
103
  }
@@ -530,12 +576,13 @@ export declare function parseVersionFlag(flags: Record<string, string | boolean>
530
576
  export declare function versionsFlow(workflowRef: string, opts: {
531
577
  json: boolean;
532
578
  }, deps: WorkflowRunDeps, channel?: Channel): Promise<FlowResult>;
533
- export declare function triggerExpect(t: WorkflowTrigger): {
579
+ type AnyTrigger = WorkflowTrigger | WorkflowContractTrigger;
580
+ export declare function triggerExpect(t: AnyTrigger): {
534
581
  type: string;
535
582
  event?: string;
536
583
  cron?: string;
537
584
  };
538
- export declare function formatTriggers(triggers: WorkflowTrigger[]): string;
585
+ export declare function formatTriggers(triggers: AnyTrigger[]): string;
539
586
  export declare function triggersListFlow(workflowRef: string, opts: {
540
587
  json: boolean;
541
588
  }, deps: WorkflowRunDeps): Promise<FlowResult>;
@@ -546,6 +593,7 @@ export declare function triggersFireFlow(workflowRef: string, opts: {
546
593
  n?: number;
547
594
  text?: string;
548
595
  imageRigOverrides?: Record<string, unknown>;
596
+ voices?: VoiceMap;
549
597
  wait: boolean;
550
598
  json: boolean;
551
599
  onProgressLine?: (line: string) => void;
@@ -10,7 +10,7 @@ import { runDisplayName, runHeadingWord, runTypeWord } from "../lib/runNames.js"
10
10
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
11
11
  import { missingRouteLine } from "../lib/route-support.js";
12
12
  import { getChannel } from "../lib/channel.js";
13
- import { asVideoRun, classifyRun, resolveStopAtPark, reviewUrl, stopLines, } from "./video.js";
13
+ import { asVideoRun, classifyRun, resolveStopAtPark, reviewUrl, scenePrefix, stopLines, } from "./video.js";
14
14
  export const helpText = `
15
15
  exodus workflow — List, describe, run, inspect, import, and export saved workflows
16
16
 
@@ -32,7 +32,7 @@ Usage:
32
32
  exodus workflow triggers <workflowId|name> [--json]
33
33
  exodus workflow triggers <workflowId|name> enable <n> [--json]
34
34
  exodus workflow triggers <workflowId|name> disable <n> [--json]
35
- exodus workflow triggers <workflowId|name> fire [<n>] [--text "..."] [--rig-overrides <json|@file>] [--wait] [--json]
35
+ exodus workflow triggers <workflowId|name> fire [<n>] [--text "..."] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--wait] [--json]
36
36
  exodus workflow inbox [--json]
37
37
  exodus workflow checkpoint <runId> [show] [--json]
38
38
  exodus workflow checkpoint <runId> edit <n> [--text "..." | --file <path> | (stdin)] [--json]
@@ -84,9 +84,9 @@ Flags:
84
84
  before anything runs, and the error names it. On
85
85
  "triggers fire" it REPLACES the schedule's own
86
86
  overrides for that one test fire.
87
- --voices <json> (run) Give the ad's characters their voices at the
88
- moment the run starts, instead of at the storyboard
89
- review. Keyed by the names the SCRIPT uses for its
87
+ --voices <json> (run, triggers fire) Give the ad's characters their
88
+ voices at the moment the run starts, instead of at the
89
+ storyboard review. Keyed by the names the SCRIPT uses for its
90
90
  speakers ("HOST 1") — the planner's character ids
91
91
  (C1, C2) do not exist yet when a run starts. Takes a
92
92
  JSON object, or @path to a .json file holding one:
@@ -103,6 +103,14 @@ Flags:
103
103
  <run>" at the storyboard review instead. Voice ids
104
104
  belong to the brand's own folder — never put them in a
105
105
  shared workflow or template.
106
+ A schedule can carry the same map in its YAML
107
+ ("voices:" beside the schedule), so a weekly ad arrives
108
+ already voiced. On "triggers fire" this flag REPLACES
109
+ the schedule's own voices for that one test fire. A
110
+ schedule whose voice has been deleted, or whose
111
+ ElevenLabs key is missing or refused, does not start at
112
+ all: it leaves a failed run saying which speaker and
113
+ what to fix, and spends nothing.
106
114
  --voice-treatment <v> (run) Say HOW this run makes its voices. Two kinds of
107
115
  answer. A name on its own picks a way of working that
108
116
  uses voices you already have:
@@ -279,7 +287,9 @@ Notes:
279
287
  panel in the app); anything you name that isn't there stops the launch and the
280
288
  message says which key was wrong. A trigger can carry the same payload in its
281
289
  YAML ("imageRigOverrides:" beside its schedule), so a Monday schedule and a
282
- Friday one can fire the same workflow at different sizes.
290
+ Friday one can fire the same workflow at different sizes. A trigger can carry
291
+ "voices:" the same way — the same map "workflow run --voices" takes — so a run
292
+ that starts on a schedule arrives at the storyboard already voiced.
283
293
  "workflow run --voices" hands a video run its voices at the start, so a brand
284
294
  that always uses the same hosts stops choosing them by hand on every run. The
285
295
  file is keyed by the names the script uses for its speakers, because that is
@@ -1372,6 +1382,43 @@ function planArtifactFile(artifact) {
1372
1382
  url,
1373
1383
  };
1374
1384
  }
1385
+ function artifactFileSuffixes(artifacts) {
1386
+ const sceneBearing = artifacts.some((a) => typeof a.sceneIndex === "number" || a.final === true);
1387
+ if (!sceneBearing) {
1388
+ return artifacts.map((_, i) => ({
1389
+ suffix: i === 0 ? "" : `-${i + 1}`,
1390
+ renamed: false,
1391
+ }));
1392
+ }
1393
+ const taken = new Set();
1394
+ let fullCount = 0;
1395
+ return artifacts.map((a, i) => {
1396
+ let suffix;
1397
+ if (typeof a.sceneIndex === "number") {
1398
+ suffix = `-${scenePrefix(a.sceneIndex)}`;
1399
+ }
1400
+ else if (a.final === true) {
1401
+ suffix = "-final";
1402
+ }
1403
+ else if (a.type === "audio") {
1404
+ fullCount += 1;
1405
+ suffix = fullCount === 1 ? "-full" : `-full-${fullCount}`;
1406
+ }
1407
+ else {
1408
+ suffix = i === 0 ? "" : `-${i + 1}`;
1409
+ }
1410
+ if (taken.has(suffix)) {
1411
+ let fallback = `${suffix}-${i + 1}`;
1412
+ while (taken.has(fallback))
1413
+ fallback = `${fallback}-${i + 1}`;
1414
+ suffix = fallback;
1415
+ taken.add(suffix);
1416
+ return { suffix, renamed: true };
1417
+ }
1418
+ taken.add(suffix);
1419
+ return { suffix, renamed: false };
1420
+ });
1421
+ }
1375
1422
  export async function saveDeliveries(run, dir, deps) {
1376
1423
  const paths = [];
1377
1424
  if (!run.isTerminal) {
@@ -1406,9 +1453,10 @@ export async function saveDeliveries(run, dir, deps) {
1406
1453
  lines.push(` skipped ${delivery.label} (${delivery.key}) — ${why}`);
1407
1454
  continue;
1408
1455
  }
1456
+ const suffixes = artifactFileSuffixes(delivery.artifacts);
1409
1457
  for (let i = 0; i < delivery.artifacts.length; i++) {
1410
1458
  const plan = planArtifactFile(delivery.artifacts[i]);
1411
- const suffix = i === 0 ? "" : `-${i + 1}`;
1459
+ const { suffix, renamed } = suffixes[i];
1412
1460
  if (plan.kind === "none") {
1413
1461
  lines.push(` skipped ${delivery.label} (${delivery.key})${suffix} — ${plan.reason}`);
1414
1462
  continue;
@@ -1419,7 +1467,7 @@ export async function saveDeliveries(run, dir, deps) {
1419
1467
  deps.writeFile(file, plan.body);
1420
1468
  else
1421
1469
  await downloadToFile(plan.url, file);
1422
- lines.push(` wrote ${file}`);
1470
+ lines.push(` wrote ${file}${renamed ? " — renamed to avoid a collision" : ""}`);
1423
1471
  paths.push(file);
1424
1472
  }
1425
1473
  catch (e) {
@@ -2450,6 +2498,7 @@ export async function triggersFireFlow(workflowRef, opts, deps) {
2450
2498
  ...(opts.imageRigOverrides
2451
2499
  ? { imageRigOverrides: opts.imageRigOverrides }
2452
2500
  : {}),
2501
+ ...(opts.voices ? { voices: opts.voices } : {}),
2453
2502
  });
2454
2503
  if (!res.ok)
2455
2504
  return triggerErrorResult(res, verb, opts.json);
@@ -3313,8 +3362,10 @@ export async function run(flags, occurrences) {
3313
3362
  }
3314
3363
  }
3315
3364
  let fireOverrides;
3365
+ let fireVoices;
3316
3366
  try {
3317
3367
  fireOverrides = parseRigOverridesFlag(occurrences, defaultDeps.readFile);
3368
+ fireVoices = parseVoicesFlag(occurrences, defaultDeps.readFile);
3318
3369
  }
3319
3370
  catch (e) {
3320
3371
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -3324,6 +3375,7 @@ export async function run(flags, occurrences) {
3324
3375
  n,
3325
3376
  text: flagString(flags, "text"),
3326
3377
  imageRigOverrides: fireOverrides,
3378
+ voices: fireVoices,
3327
3379
  wait: flags["wait"] === true,
3328
3380
  json,
3329
3381
  onProgressLine: (line) => console.log(line),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.2",
3
+ "version": "2026.9.2000-beta.4",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",