@aicopycoders/exodus 2026.9.2000-beta.1 → 2026.9.2000-beta.3

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.
@@ -104,7 +104,10 @@ under it:
104
104
  - storyboard gate: needs `approve`. When the stop block prints a `Redo one
105
105
  frame:` line with a node id, one still can be redone first with `retry-frame`;
106
106
  a gate parked before the stills are drawn has nothing to redraw.
107
- - final watch: every piece is made; needs `pull`, a cut, and `upload`. It is also
107
+ - final watch: every piece is made; needs `pull`, a cut, and `upload`. Once a
108
+ cut is uploaded the stop block leads with `approve` instead, and `status
109
+ --json` says which state it is in (`stop.cutAttached`, plus the same next-step
110
+ lines under `guidance`). It is also
108
111
  the one park where a finished clip can be redone, with `retry-clip`. A run
109
112
  only reaches this park if its video node sets `finalWatch: true` — otherwise
110
113
  it skips straight to finished.
@@ -677,8 +680,11 @@ them. If the words are wrong, that is a new run.
677
680
  **A redo whose take fails the checks keeps the original clip.** On a finished
678
681
  clip the row stays `done` with the ORIGINAL take and only its findings are
679
682
  refreshed, because a worse take must never replace a delivered one. So never
680
- report that the clip changed. Read `status` again for the new findings, then pull
681
- and compare the file before and after.
683
+ report that the clip changed without reading `status` again: under the scene it
684
+ prints what the redo did, `new take accepted (take N)` or `new take rejected,
685
+ original kept (take N)`, and `status --json` carries the same sentence on the
686
+ row as `lastRedo.label`. A row with no `lastRedo` was redone before outcomes
687
+ were recorded; for those, pull and compare the file before and after.
682
688
 
683
689
  **Re-cut afterwards.** A cut you already uploaded still holds the old clip, and
684
690
  the CLI says so when that is the case. Pull again, re-cut and upload again before
@@ -1,6 +1,5 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { createInterface } from "node:readline/promises";
4
3
  import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
5
4
  import { pollUntilDone } from "../lib/poll.js";
6
5
  import { displayRunStatus, formatGenesisRun, formatError, tickerRunStatus } from "../lib/format.js";
@@ -278,52 +277,13 @@ export async function run(flags) {
278
277
  return submitGenesis(action.body, { noWait, cc });
279
278
  }
280
279
  }
281
- async function listBank(limit) {
282
- const res = await apiGet(`/api/v2/scout/bank?limit=${limit}`);
283
- if (!res.ok) {
284
- console.log(formatError(res));
285
- process.exit(1);
286
- }
287
- const ideas = Array.isArray(res.data.ideas) ? res.data.ideas : [];
288
- if (ideas.length === 0) {
289
- console.log("Bank is empty.");
290
- console.log('Fill it with: exodus genesis scrape (or paste a reel: exodus genesis --reel "<url>")');
291
- return;
292
- }
293
- console.log(`## Idea Bank (${ideas.length} reel${ideas.length === 1 ? "" : "s"})`);
294
- console.log("");
295
- for (const i of ideas) {
296
- const hook = i.hook ?? "(no hook)";
297
- const user = typeof i.sourceUsername === "string" && i.sourceUsername.length > 0
298
- ? ` @${i.sourceUsername.replace(/^@+/, "")}`
299
- : "";
300
- const score = typeof i.relevanceScore === "number" ? ` score=${i.relevanceScore.toFixed(2)}` : "";
301
- const used = typeof i.useCount === "number" && i.useCount > 0 ? " · used" : "";
302
- console.log(` • ${hook}${user}${score}`);
303
- console.log(` id: ${i._id ?? "?"} [${i.status ?? "new"}${used}]`);
304
- if (i.sourceUrl)
305
- console.log(` ${i.sourceUrl}`);
306
- }
307
- console.log("");
308
- console.log("Write from one: exodus genesis --from-bank --idea <id>");
280
+ async function listBank(_limit) {
281
+ console.error(`This command has been retired. View the idea bank in the dashboard: ${getDashboardUrl()}/idea-bank`);
282
+ process.exit(1);
309
283
  }
310
- async function runFromBank(ideaId, opts, rt) {
311
- const res = await apiPost("/api/v2/scout/bank/ideas", { ideaIds: [ideaId] }, { ccCommand: rt.cc });
312
- if (!res.ok) {
313
- console.log(formatError(res));
314
- process.exit(1);
315
- }
316
- const idea = (res.data.ideas ?? [])[0];
317
- if (!idea) {
318
- console.error(`No banked idea found for id "${ideaId}". List the bank: exodus genesis --from-bank`);
319
- process.exit(1);
320
- }
321
- if (!idea.sourceUrl) {
322
- console.error(`Banked idea "${ideaId}" has no source reel URL to write from.`);
323
- process.exit(1);
324
- }
325
- console.log(`Writing from banked reel: ${idea.sourceUrl}`);
326
- return runReel([idea.sourceUrl], opts, rt);
284
+ async function runFromBank(_ideaId, _opts, _rt) {
285
+ console.error(`This command has been retired. Write from banked ideas in the dashboard: ${getDashboardUrl()}/idea-bank`);
286
+ process.exit(1);
327
287
  }
328
288
  async function runReel(urls, opts, rt) {
329
289
  console.log(`Transcribing ${urls.length === 1 ? "the reel" : `${urls.length} reels`} → idea → Genesis…`);
@@ -613,92 +573,11 @@ function requireActiveBrand() {
613
573
  }
614
574
  return slug;
615
575
  }
616
- async function runConnectInstagram(flags, cc) {
617
- const client = requireActiveBrand();
618
- const start = await apiPost("/api/v2/genesis/instagram/connect/start", { client }, { ccCommand: cc });
619
- if (!start.ok || !start.data.liveViewUrl) {
620
- console.log(formatError(start));
621
- process.exit(1);
622
- }
623
- console.log(`\nConnecting an Instagram account to brand "${client}".`);
624
- console.log("\n1. Open this link in your browser:\n");
625
- console.log(` ${start.data.liveViewUrl}\n`);
626
- console.log('2. Go to instagram.com and log into the account for THIS brand.');
627
- console.log("3. Scroll your feed for a bit so the algorithm learns the niche.");
628
- console.log("4. Come back here and press Enter.\n");
629
- const rl = createInterface({ input: process.stdin, output: process.stdout });
630
- try {
631
- await rl.question("Press Enter once you're logged in and done grooming... ");
632
- let username = typeof flags["username"] === "string" ? flags["username"].trim() : "";
633
- while (!username) {
634
- username = (await rl.question("Instagram handle you just logged in as (e.g. mybrand): ")).trim();
635
- }
636
- const finish = await apiPost("/api/v2/genesis/instagram/connect/finish", { client, igUsername: username }, { ccCommand: cc });
637
- if (!finish.ok) {
638
- console.log(formatError(finish));
639
- process.exit(1);
640
- }
641
- console.log(`\n✓ Instagram @${username.replace(/^@+/, "")} connected to "${client}".`);
642
- console.log("Fill the idea bank now: exodus genesis scrape --organic");
643
- }
644
- finally {
645
- rl.close();
646
- }
576
+ async function runConnectInstagram(_flags, _cc) {
577
+ console.error(`This command has been retired. Connect Instagram accounts in the dashboard: ${getDashboardUrl()}/settings`);
578
+ process.exit(1);
647
579
  }
648
- async function runScrape(flags, cc) {
649
- const client = requireActiveBrand();
650
- const noWait = flags["wait"] === false || flags["no-wait"] === true;
651
- if (flags["organic"] === true) {
652
- const smoke = flags["smoke"] === true;
653
- const res = await apiPost("/api/v2/scout/organic", { client, ...(smoke ? { smoke: true } : {}) }, { ccCommand: cc });
654
- if (!res.ok || !res.data.runId) {
655
- console.log(formatError(res));
656
- process.exit(1);
657
- }
658
- console.log(`Organic scrape started for "${client}": ${res.data.runId}${smoke ? " (smoke)" : ""}`);
659
- return pollScrape(res.data.runId, noWait, 1_800_000);
660
- }
661
- const term = typeof flags["term"] === "string" ? flags["term"] : undefined;
662
- const body = { sourceMode: "fresh", client };
663
- if (term)
664
- body.term = term;
665
- const res = await apiPost("/api/v2/scout", body, { ccCommand: cc });
666
- if (!res.ok || !res.data.runId) {
667
- console.log(formatError(res));
668
- process.exit(1);
669
- }
670
- console.log(`Discovery scrape started for "${client}": ${res.data.runId}`);
671
- return pollScrape(res.data.runId, noWait, 1_200_000);
672
- }
673
- async function pollScrape(runId, noWait, timeoutMs) {
674
- if (noWait) {
675
- console.log(`Run ID: ${runId}`);
676
- console.log(`Check status: exodus status --type scout --id ${runId}`);
677
- return;
678
- }
679
- console.log("Polling for completion...");
680
- const result = await pollUntilDone({
681
- path: `/api/v2/scout?runId=${runId}`,
682
- intervalMs: 15_000,
683
- timeoutMs,
684
- onProgress: (data) => {
685
- const status = data["status"];
686
- const captured = data["organicCaptured"];
687
- const qualified = data["organicQualified"];
688
- const counts = captured !== undefined || qualified !== undefined
689
- ? ` captured=${captured ?? 0} qualified=${qualified ?? 0}`
690
- : "";
691
- if (status)
692
- process.stdout.write(`\r status: ${tickerRunStatus(status)}${counts} `);
693
- },
694
- });
695
- console.log();
696
- if (result.timedOut) {
697
- console.log(`Timed out waiting. Check later: exodus status --type scout --id ${runId}`);
698
- }
699
- else {
700
- console.log(`Done. Banked reels are ready to write: exodus genesis --from-bank`);
701
- }
702
- if (!result.ok && !result.timedOut)
703
- process.exit(1);
580
+ async function runScrape(_flags, _cc) {
581
+ console.error(`This command has been retired. Fill the idea bank from the Mining page in the dashboard: ${getDashboardUrl()}/mining`);
582
+ process.exit(1);
704
583
  }
@@ -1,6 +1,5 @@
1
- import { apiGet } from "../lib/client.js";
1
+ import { apiGet, getDashboardUrl } from "../lib/client.js";
2
2
  import { formatGeneration, formatGenesisRun, formatError, displayRunStatus } from "../lib/format.js";
3
- import { hydrateScoutIdeasCount } from "../lib/scout-hydrate.js";
4
3
  import { formatIntelResult as formatIntelResultRich } from "../lib/intel-format.js";
5
4
  export const helpText = `
6
5
  exodus status — Check the status of a pipeline run
@@ -12,19 +11,13 @@ Required:
12
11
  --id <runId> The generation or run ID returned by a prior exodus command
13
12
 
14
13
  Options:
15
- --type <pipeline> Pipeline type: generation (default) | genesis | intel | pulse | scout
14
+ --type <pipeline> Pipeline type: generation (default) | genesis | intel | pulse
16
15
  | creative | template
17
16
  Spark, Viral, Mirror, Remix runs use --type generation (default).
18
- Intel, Pulse, Scout require --type intel|pulse|scout.
17
+ Intel, Pulse require --type intel|pulse.
19
18
  Creative-suite (native/copy-derived/ref-match/meme)
20
19
  use --type creative; Template uses --type template.
21
20
  `.trim();
22
- async function fetchScoutIdeasCount(runId) {
23
- const res = await apiGet(`/api/v2/scout/ideas?runId=${runId}&limit=1`);
24
- if (res.ok && typeof res.data.count === "number")
25
- return res.data.count;
26
- return null;
27
- }
28
21
  export function formatIntelResult(data) {
29
22
  return formatIntelResultRich(data).replace("## Intel Analysis Result", "## Intel Status");
30
23
  }
@@ -157,18 +150,21 @@ export async function run(flags) {
157
150
  process.exit(1);
158
151
  }
159
152
  const type = flags["type"] ?? "generation";
153
+ if (type === "scout") {
154
+ console.error(`--type scout has been retired. Check run status in the dashboard: ${getDashboardUrl()}/runs`);
155
+ process.exit(1);
156
+ }
160
157
  const pathMap = {
161
158
  generation: `/api/v2/generations?id=${id}`,
162
159
  genesis: `/api/v2/genesis?id=${id}`,
163
160
  intel: `/api/v2/intel?id=${id}`,
164
161
  pulse: `/api/v2/pulse?id=${id}`,
165
- scout: `/api/v2/scout?runId=${id}`,
166
162
  creative: `/api/v2/creative?runId=${id}`,
167
163
  template: `/api/v2/template?runId=${id}`,
168
164
  };
169
165
  const path = pathMap[type];
170
166
  if (!path) {
171
- console.error(`Unknown type: "${type}". Valid types: generation, genesis, intel, pulse, scout, creative, template`);
167
+ console.error(`Unknown type: "${type}". Valid types: generation, genesis, intel, pulse, creative, template`);
172
168
  process.exit(1);
173
169
  }
174
170
  const res = await apiGet(path);
@@ -176,9 +172,6 @@ export async function run(flags) {
176
172
  console.log(formatError(res));
177
173
  process.exit(1);
178
174
  }
179
- if (type === "scout") {
180
- await hydrateScoutIdeasCount(id, res.data, fetchScoutIdeasCount);
181
- }
182
175
  switch (type) {
183
176
  case "generation":
184
177
  console.log(formatGeneration(res.data));
@@ -192,9 +185,6 @@ export async function run(flags) {
192
185
  case "pulse":
193
186
  console.log(formatPulseResult(res.data));
194
187
  break;
195
- case "scout":
196
- console.log(formatScoutResult(res.data));
197
- break;
198
188
  case "creative":
199
189
  console.log(formatCreativeSuiteStatus("Creative Suite Status", res.data));
200
190
  break;
@@ -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[];
@@ -117,6 +117,11 @@ export interface NodeItem {
117
117
  flagged?: boolean;
118
118
  findings?: ClipFinding[];
119
119
  staleClaim?: boolean;
120
+ lastRedo?: {
121
+ take: number;
122
+ outcome: string;
123
+ label: string;
124
+ };
120
125
  artifact?: ArtifactSubset;
121
126
  }
122
127
  export interface ShowRow {
@@ -184,11 +189,20 @@ export type RunStop = {
184
189
  at: "finished";
185
190
  status: string;
186
191
  };
192
+ export type ResolvedStop = Exclude<RunStop, {
193
+ at: "final-watch";
194
+ }> | {
195
+ at: "final-watch";
196
+ cutAttached: boolean | null;
197
+ };
187
198
  export declare function classifyRun(run: VideoRun): RunStop;
199
+ export declare function hasAttachedCut(items: NodeItem[]): boolean;
200
+ export declare function resolveStop(stop: RunStop, cutAttached: boolean | null): ResolvedStop;
201
+ export declare function resolveStopAtPark(stop: RunStop, runId: string, deps: Pick<VideoDeps, "get">): Promise<ResolvedStop>;
188
202
  export declare function stageWord(stage: string): string;
189
203
  export declare function stepName(kind: string | undefined): string;
190
204
  export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
191
- export declare function stopLines(stop: RunStop, runId: string, runUrl: string): string[];
205
+ export declare function stopLines(stop: ResolvedStop, runId: string, runUrl: string): string[];
192
206
  export interface PullDownload {
193
207
  file: string;
194
208
  url: string;
@@ -279,7 +293,10 @@ export declare function waitFlow(runId: string, opts: {
279
293
  }, deps: VideoDeps): Promise<FlowResult>;
280
294
  export declare function statusFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
281
295
  export declare function storyboardFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
282
- export declare function approveFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
296
+ export declare function approveFlow(runId: string, opts: {
297
+ json: boolean;
298
+ approveStaleCut: boolean;
299
+ }, deps: VideoDeps): Promise<FlowResult>;
283
300
  export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
284
301
  export declare function retryFrameFlow(runId: string, nodeId: string, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
285
302
  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
@@ -254,6 +259,22 @@ export function classifyRun(run) {
254
259
  const active = run.nodes.find((n) => n.status === "running");
255
260
  return { at: "running", stage: active?.kind ?? "starting" };
256
261
  }
262
+ export function hasAttachedCut(items) {
263
+ return items.some((i) => i.itemKind === "final" && i.status === "done");
264
+ }
265
+ export function resolveStop(stop, cutAttached) {
266
+ if (stop.at !== "final-watch")
267
+ return stop;
268
+ return { at: "final-watch", cutAttached };
269
+ }
270
+ export async function resolveStopAtPark(stop, runId, deps) {
271
+ if (stop.at !== "final-watch")
272
+ return stop;
273
+ const res = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
274
+ if (!res.ok)
275
+ return resolveStop(stop, null);
276
+ return resolveStop(stop, hasAttachedCut(res.data.items ?? []));
277
+ }
257
278
  const STAGE_WORDS = {
258
279
  brief: "reading the script",
259
280
  storyboard: "writing the storyboard",
@@ -300,6 +321,21 @@ export function stopLines(stop, runId, runUrl) {
300
321
  return lines;
301
322
  }
302
323
  if (stop.at === "final-watch") {
324
+ if (stop.cutAttached === null) {
325
+ return [
326
+ "Parked: every piece is made.",
327
+ `Couldn't check whether a cut is already uploaded. See where it stands: exodus video status ${runId}`,
328
+ `Watch it here: ${runUrl}`,
329
+ ];
330
+ }
331
+ if (stop.cutAttached) {
332
+ return [
333
+ "Parked: your cut is uploaded and waiting for your approval.",
334
+ `Approve it: exodus video approve ${runId}`,
335
+ `Watch it here: ${runUrl}`,
336
+ "Changed your mind? You can still redo a clip and upload a new cut.",
337
+ ];
338
+ }
303
339
  return [
304
340
  "Parked: every piece is made and the run is waiting for a cut.",
305
341
  `Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
@@ -891,15 +927,16 @@ export async function waitFlow(runId, opts, deps) {
891
927
  }
892
928
  continue;
893
929
  }
930
+ const resolved = await resolveStopAtPark(stop, runId, deps);
894
931
  if (opts.json) {
895
932
  return {
896
933
  code: stop.at === "failed" ? 1 : 0,
897
- lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
934
+ lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
898
935
  };
899
936
  }
900
937
  return {
901
938
  code: stop.at === "failed" ? 1 : 0,
902
- lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
939
+ lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
903
940
  };
904
941
  }
905
942
  const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
@@ -924,6 +961,11 @@ function itemWord(item) {
924
961
  return "flagged";
925
962
  return ITEM_STATUS_WORD[item.status] ?? item.status;
926
963
  }
964
+ 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.";
965
+ function carryingOn(run) {
966
+ return (run.status === "queued" &&
967
+ run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
968
+ }
927
969
  export async function statusFlow(runId, json, deps) {
928
970
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
929
971
  if (!runRes.ok)
@@ -933,15 +975,18 @@ export async function statusFlow(runId, json, deps) {
933
975
  if (!itemsRes.ok)
934
976
  return errorResult(itemsRes, json);
935
977
  const items = itemsRes.data.items ?? [];
936
- const stop = classifyRun(run);
937
- const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
978
+ const hasFinal = hasAttachedCut(items);
979
+ const stop = resolveStop(classifyRun(run), hasFinal);
980
+ const guidance = stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run));
938
981
  const warnings = run.nodes
939
982
  .filter((n) => n.warning)
940
983
  .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
941
984
  if (json) {
942
985
  return {
943
986
  code: 0,
944
- lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
987
+ lines: [
988
+ JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings, guidance }),
989
+ ],
945
990
  };
946
991
  }
947
992
  const revoicedByScene = new Set();
@@ -972,7 +1017,8 @@ export async function statusFlow(runId, json, deps) {
972
1017
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
973
1018
  const lines = [
974
1019
  headline,
975
- ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
1020
+ ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
1021
+ ...guidance,
976
1022
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
977
1023
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
978
1024
  ];
@@ -984,6 +1030,12 @@ export async function statusFlow(runId, json, deps) {
984
1030
  for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
985
1031
  const row = byScene.get(sceneIndex);
986
1032
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1033
+ if (row.clip?.lastRedo)
1034
+ lines.push(` clip: ${row.clip.lastRedo.label}`);
1035
+ if (row.voiceover?.lastRedo)
1036
+ lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1037
+ if (row.frame?.lastRedo)
1038
+ lines.push(` picture: ${row.frame.lastRedo.label}`);
987
1039
  for (const finding of row.clip?.findings ?? []) {
988
1040
  lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
989
1041
  const judgeSeverity = finding.judgeSeverity ?? finding.severity;
@@ -1095,14 +1147,30 @@ function sceneCardLines(scene) {
1095
1147
  lines.push(` picture: ${scene.frame.status}`);
1096
1148
  return lines;
1097
1149
  }
1098
- export async function approveFlow(runId, json, deps) {
1099
- const res = await deps.post(APPROVE_PATH, { runId });
1100
- if (!res.ok)
1101
- return errorResult(res, json);
1102
- if (json)
1103
- return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
1104
- const voices = res.data
1105
- ?.voices;
1150
+ export async function approveFlow(runId, opts, deps) {
1151
+ const res = await deps.post(APPROVE_PATH, {
1152
+ runId,
1153
+ ...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
1154
+ });
1155
+ if (!res.ok) {
1156
+ const refusal = errorResult(res, opts.json);
1157
+ const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
1158
+ return staleCut
1159
+ ? {
1160
+ ...refusal,
1161
+ lines: [
1162
+ ...refusal.lines,
1163
+ `To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
1164
+ ],
1165
+ }
1166
+ : refusal;
1167
+ }
1168
+ const data = res.data;
1169
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
1170
+ if (opts.json) {
1171
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
1172
+ }
1173
+ const voices = data?.voices;
1106
1174
  const summary = voices
1107
1175
  ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1108
1176
  : [];
@@ -1110,6 +1178,7 @@ export async function approveFlow(runId, json, deps) {
1110
1178
  code: 0,
1111
1179
  lines: [
1112
1180
  "Approved.",
1181
+ ...warnings,
1113
1182
  ...(summary.length > 0 ? ["", ...summary, ""] : []),
1114
1183
  `See what happens next: exodus video status ${runId}`,
1115
1184
  ],
@@ -1167,8 +1236,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1167
1236
  ],
1168
1237
  };
1169
1238
  }
1170
- const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
1171
- "re-cut, and upload again.";
1239
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
1240
+ "exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
1241
+ "or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
1172
1242
  export function planClipRedo(run, items, target) {
1173
1243
  const stop = classifyRun(run);
1174
1244
  const found = findClipRow(run, items, target);
@@ -1201,7 +1271,7 @@ export function planClipRedo(run, items, target) {
1201
1271
  };
1202
1272
  }
1203
1273
  function findClipRow(run, items, target) {
1204
- const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1274
+ const uploadedCut = hasAttachedCut(items);
1205
1275
  const rows = items.filter((i) => i.itemKind === "clip" &&
1206
1276
  i.sceneIndex === target.sceneIndex &&
1207
1277
  (target.nodeId === undefined || i.nodeId === target.nodeId));
@@ -1987,8 +2057,9 @@ export async function run(flags, occurrences) {
1987
2057
  return printResult(await statusFlow(runId, json, defaultDeps));
1988
2058
  if (sub === "storyboard")
1989
2059
  return printResult(await storyboardFlow(runId, json, defaultDeps));
1990
- if (sub === "approve")
1991
- return printResult(await approveFlow(runId, json, defaultDeps));
2060
+ if (sub === "approve") {
2061
+ return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2062
+ }
1992
2063
  if (sub === "flag") {
1993
2064
  const note = flagString(flags, "note");
1994
2065
  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, reviewUrl, stopLines, } from "./video.js";
13
+ import { asVideoRun, classifyRun, resolveStopAtPark, reviewUrl, 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
@@ -2085,9 +2095,10 @@ async function waitForRun(runId, opts, deps) {
2085
2095
  const saved = opts.out !== undefined && terminalRun
2086
2096
  ? await saveDeliveries(terminalRun, opts.out, deps)
2087
2097
  : undefined;
2088
- const videoStop = !pollResult.timedOut && isRecord(pollResult.data)
2098
+ const parkStop = !pollResult.timedOut && isRecord(pollResult.data)
2089
2099
  ? videoParkStop(pollResult.data)
2090
2100
  : undefined;
2101
+ const videoStop = parkStop ? await resolveStopAtPark(parkStop, runId, deps) : undefined;
2091
2102
  if (opts.json) {
2092
2103
  return {
2093
2104
  code: pollResult.ok ? 0 : 1,
@@ -2449,6 +2460,7 @@ export async function triggersFireFlow(workflowRef, opts, deps) {
2449
2460
  ...(opts.imageRigOverrides
2450
2461
  ? { imageRigOverrides: opts.imageRigOverrides }
2451
2462
  : {}),
2463
+ ...(opts.voices ? { voices: opts.voices } : {}),
2452
2464
  });
2453
2465
  if (!res.ok)
2454
2466
  return triggerErrorResult(res, verb, opts.json);
@@ -3312,8 +3324,10 @@ export async function run(flags, occurrences) {
3312
3324
  }
3313
3325
  }
3314
3326
  let fireOverrides;
3327
+ let fireVoices;
3315
3328
  try {
3316
3329
  fireOverrides = parseRigOverridesFlag(occurrences, defaultDeps.readFile);
3330
+ fireVoices = parseVoicesFlag(occurrences, defaultDeps.readFile);
3317
3331
  }
3318
3332
  catch (e) {
3319
3333
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -3323,6 +3337,7 @@ export async function run(flags, occurrences) {
3323
3337
  n,
3324
3338
  text: flagString(flags, "text"),
3325
3339
  imageRigOverrides: fireOverrides,
3340
+ voices: fireVoices,
3326
3341
  wait: flags["wait"] === true,
3327
3342
  json,
3328
3343
  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.1",
3
+ "version": "2026.9.2000-beta.3",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",