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

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.
@@ -6,11 +6,27 @@ import { displayRunStatus, formatApiError } from "../lib/format.js";
6
6
  import { missingRouteLine } from "../lib/route-support.js";
7
7
  import { hasBinary } from "../lib/preflight.js";
8
8
  import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
9
+ import { HELPER_LEDGER_BASE, SET_OPTION_LEDGER_BASE } from "../lib/helperLedgerBase.js";
10
+ const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
11
+ const VOICE_MODES = ["native", "voice-first"];
12
+ const STYLE_SLUGS = [
13
+ "pixar-3d",
14
+ "claymation-stop-motion",
15
+ "lego-toy-brick",
16
+ "balloon",
17
+ "video-game-8bit",
18
+ ];
19
+ function joinedOr(items) {
20
+ if (items.length <= 1)
21
+ return items[0] ?? "";
22
+ return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
23
+ }
9
24
  export const helpText = `
10
25
  exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
11
26
 
12
27
  The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
13
- one voice track per scene, one video clip per scene, plus the music bed. It does
28
+ one voice track per scene, one video clip per scene, and a music bed if you
29
+ asked for one with --music. It does
14
30
  not make the finished ad. You pull the pieces to a folder, cut them together
15
31
  with whatever editing tools you like, and upload the cut back.
16
32
 
@@ -41,7 +57,13 @@ The whole loop, in order:
41
57
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
42
58
 
43
59
  7. exodus video status <runId>
44
- Where the run is and how each scene's clip turned out.
60
+ Where the run is and how each scene's clip turned out. If the storyboard
61
+ failed, it also says which parts of it were accepted and why the next one
62
+ was turned down.
63
+
64
+ exodus video status <runId> --rejected-draft
65
+ Prints the storyboard draft that was turned down. A record of what
66
+ happened, thrown away by the system — never something to act on.
45
67
 
46
68
  8. exodus video pull <runId> --out ./ad
47
69
  Writes every piece to that folder plus a manifest.json index.
@@ -62,18 +84,21 @@ The whole loop, in order:
62
84
  Attaches your cut to the run and prints the page to approve it on.
63
85
 
64
86
  11. exodus video approve <runId>
65
- Or click Approve on the page from step 10.
87
+ Or click Approve on the page from step 10. If you redid a clip after
88
+ uploading, this stops and says which scenes changed. Upload a fresh cut,
89
+ or add --approve-stale-cut to deliver the cut you already uploaded.
66
90
 
67
91
  Usage:
68
- exodus video status <runId> [--json]
92
+ exodus video status <runId> [--rejected-draft] [--json]
69
93
  exodus video storyboard <runId> [--json]
70
- exodus video approve <runId> [--json]
94
+ exodus video approve <runId> [--approve-stale-cut] [--json]
71
95
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
72
96
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
73
97
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
74
98
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
75
99
  exodus video pull <runId> --out <dir> [--json]
76
100
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
101
+ exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--wait] [--json]
77
102
 
78
103
  Options:
79
104
  --out <dir> Folder to write the pulled pieces into (pull)
@@ -88,12 +113,30 @@ Options:
88
113
  (retry-frame, retry-clip, revoice)
89
114
  --all Every finished clip that kept the video model's voice
90
115
  (revoice)
116
+ --approve-stale-cut Deliver the cut you uploaded even though a clip changed
117
+ after you uploaded it (approve). Without it, approving
118
+ stops and names the scenes that changed
91
119
  --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
92
120
  character by its ID or by the name your script uses.
93
121
  Repeat it once per character
94
122
  --clear <who> Take a character's voice off again (voices). Repeatable
95
123
  --from <file.json> A file of characters and voice IDs (voices). --set and
96
124
  --clear win over the same name in the file
125
+ --rejected-draft Print the storyboard draft the system turned down
126
+ (status). It was thrown away — a record only
127
+ --script <file> Text file of what the ad says (start)
128
+ --conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
129
+ --style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
130
+ --direction "<note>" Note for the whole ad (start)
131
+ --voice-path <path> How the voices are made (start)
132
+ --video-model <id> Which video model films this ad, instead of the kind of
133
+ ad's own default (start, with --conceit)
134
+ --voice <mode> native: the video model speaks the lines. voice-first:
135
+ the lines are recorded first (start, with --conceit)
136
+ --music Put a music bed under the ad (start). An ad has no music
137
+ unless you ask for it here
138
+ --no-music Leave the music bed off (start), which it already is
139
+ --wait Wait until the storyboard needs a yes (start)
97
140
  --json Machine-readable output
98
141
  --help, -h Print this help
99
142
 
@@ -117,8 +160,12 @@ Examples:
117
160
  `.trim();
118
161
  const SHOWS_PATH = "/api/v2/shows";
119
162
  const RUNS_PATH = "/api/v2/video/runs";
163
+ const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
120
164
  const RUN_PATH = "/api/v2/workflow";
121
165
  const ITEMS_PATH = "/api/v2/workflow/items";
166
+ const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
167
+ const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
168
+ "with the next server update.";
122
169
  const STORYBOARD_PATH = "/api/v2/video/storyboard";
123
170
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
124
171
  const APPROVE_PATH = "/api/v2/workflow/approve";
@@ -133,6 +180,48 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
133
180
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
134
181
  return rest;
135
182
  }
183
+ export function qcWithoutJudgeWording(qc) {
184
+ if (!qc.takes)
185
+ return qc;
186
+ return {
187
+ ...qc,
188
+ takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
189
+ };
190
+ }
191
+ export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
192
+ const TAKE_NEIGHBOUR_SENTENCE = {
193
+ "not-applicable": () => "never compared",
194
+ "not-attached": () => "no neighbours attached",
195
+ "none-accepted": () => "no accepted neighbours yet",
196
+ attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
197
+ };
198
+ export function renderQcTakeHistory(takes) {
199
+ if (!takes || takes.length === 0)
200
+ return [NO_TAKE_HISTORY_LINE];
201
+ const lines = [];
202
+ takes.forEach((take, index) => {
203
+ const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
204
+ const parts = [
205
+ take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
206
+ ...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
207
+ say(take.neighbours),
208
+ ];
209
+ const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
210
+ lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
211
+ for (const { code, wording } of take.judgeWording ?? []) {
212
+ lines.push(` checker said (${code}): ${wording}`);
213
+ }
214
+ });
215
+ return lines;
216
+ }
217
+ function printDisplacedHistory(lines, lastRedo) {
218
+ const history = lastRedo.displacedHistory;
219
+ if (!history)
220
+ return;
221
+ lines.push(` ${history.title}`);
222
+ for (const line of history.lines)
223
+ lines.push(` ${line}`);
224
+ }
136
225
  export function asVideoRun(data) {
137
226
  const run = (data ?? {});
138
227
  return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
@@ -245,6 +334,7 @@ export function classifyRun(run) {
245
334
  ...(pausedNode.kind === "scene-frames"
246
335
  ? { framesNodeId: pausedNode.nodeId }
247
336
  : {}),
337
+ ...(isShowAd(run) ? { showAd: true } : {}),
248
338
  };
249
339
  }
250
340
  if (parkedAtFinalWatch(run))
@@ -254,6 +344,22 @@ export function classifyRun(run) {
254
344
  const active = run.nodes.find((n) => n.status === "running");
255
345
  return { at: "running", stage: active?.kind ?? "starting" };
256
346
  }
347
+ export function hasAttachedCut(items) {
348
+ return items.some((i) => i.itemKind === "final" && i.status === "done");
349
+ }
350
+ export function resolveStop(stop, cutAttached) {
351
+ if (stop.at !== "final-watch")
352
+ return stop;
353
+ return { at: "final-watch", cutAttached };
354
+ }
355
+ export async function resolveStopAtPark(stop, runId, deps) {
356
+ if (stop.at !== "final-watch")
357
+ return stop;
358
+ const res = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
359
+ if (!res.ok)
360
+ return resolveStop(stop, null);
361
+ return resolveStop(stop, hasAttachedCut(res.data.items ?? []));
362
+ }
257
363
  const STAGE_WORDS = {
258
364
  brief: "reading the script",
259
365
  storyboard: "writing the storyboard",
@@ -279,8 +385,11 @@ export function stepName(kind) {
279
385
  return "A step in this run";
280
386
  return STEP_NAMES[kind] ?? `The ${kind} step`;
281
387
  }
388
+ export function isShowAd(run) {
389
+ return run.moduleOwned === true;
390
+ }
282
391
  export function reviewUrl(dashboardUrl, run) {
283
- if (run.moduleOwned === true)
392
+ if (isShowAd(run))
284
393
  return `${dashboardUrl}/video?ad=${run._id}`;
285
394
  if (run.workflowId)
286
395
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
@@ -292,14 +401,31 @@ export function stopLines(stop, runId, runUrl) {
292
401
  "Parked: the storyboard is waiting for your yes.",
293
402
  `Read it: exodus video storyboard ${runId}`,
294
403
  `Approve it: exodus video approve ${runId}`,
295
- `Send back: exodus video flag ${runId} --note "what's wrong"`,
296
404
  ];
405
+ if (stop.showAd) {
406
+ lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
407
+ }
297
408
  if (stop.framesNodeId) {
298
409
  lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
299
410
  }
300
411
  return lines;
301
412
  }
302
413
  if (stop.at === "final-watch") {
414
+ if (stop.cutAttached === null) {
415
+ return [
416
+ "Parked: every piece is made.",
417
+ `Couldn't check whether a cut is already uploaded. See where it stands: exodus video status ${runId}`,
418
+ `Watch it here: ${runUrl}`,
419
+ ];
420
+ }
421
+ if (stop.cutAttached) {
422
+ return [
423
+ "Parked: your cut is uploaded and waiting for your approval.",
424
+ `Approve it: exodus video approve ${runId}`,
425
+ `Watch it here: ${runUrl}`,
426
+ "Changed your mind? You can still redo a clip and upload a new cut.",
427
+ ];
428
+ }
303
429
  return [
304
430
  "Parked: every piece is made and the run is waiting for a cut.",
305
431
  `Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
@@ -310,6 +436,13 @@ export function stopLines(stop, runId, runUrl) {
310
436
  }
311
437
  if (stop.at === "failed") {
312
438
  if (stop.repair) {
439
+ if (!stop.step) {
440
+ return [
441
+ "This run stopped at a step that can't be picked back up from here.",
442
+ "Start a fresh run to get this ad made.",
443
+ `Open the run: ${runUrl}`,
444
+ ];
445
+ }
313
446
  return [
314
447
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
315
448
  `Try that step again: exodus workflow repair ${runId} retry`,
@@ -335,7 +468,40 @@ export function stopLines(stop, runId, runUrl) {
335
468
  }
336
469
  return [`Working: ${stageWord(stop.stage)}.`];
337
470
  }
338
- export const CAST_LEDGER_BASE = 910000;
471
+ export function failedStoryboardNode(run) {
472
+ return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
473
+ }
474
+ export function errorEchoesReasons(error, record) {
475
+ if (!error || record?.kind !== "rejected")
476
+ return false;
477
+ const reasons = record.reasons ?? [];
478
+ return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
479
+ }
480
+ export function planFailureLines(node, runId) {
481
+ const record = node?.planFailure;
482
+ if (!record || !Array.isArray(record.lines) || !node)
483
+ return [];
484
+ const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
485
+ const bodyCount = Math.max(0, record.lines.length - reasons.length);
486
+ const lines = [`What happened to the storyboard step (${node.nodeId}):`];
487
+ record.lines.forEach((line, i) => {
488
+ lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
489
+ });
490
+ if (node?.hasRejectedDraft === true) {
491
+ lines.push(" A rejected draft is on file. The system threw it away, so it is a record of what happened and nothing to act on.", ` Read it: exodus video status ${runId} --rejected-draft`);
492
+ }
493
+ return lines;
494
+ }
495
+ export const MUSIC_HEARD_CODE = "music-heard";
496
+ export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
497
+ export function musicBedState(run) {
498
+ if (run.musicBed === undefined)
499
+ return "unknown";
500
+ return run.musicBed ? "on" : "off";
501
+ }
502
+ function isPullSceneIndex(sceneIndex) {
503
+ return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
504
+ }
339
505
  const FALLBACK_EXT = {
340
506
  image: "png",
341
507
  video: "mp4",
@@ -352,6 +518,10 @@ function extFor(url, family) {
352
518
  export function scenePrefix(sceneIndex) {
353
519
  return `scene-${String(sceneIndex).padStart(2, "0")}`;
354
520
  }
521
+ function clipQcOf(item) {
522
+ const artifact = item?.artifact;
523
+ return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
524
+ }
355
525
  function outputsOfNodeKind(run, kind) {
356
526
  return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
357
527
  }
@@ -381,7 +551,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
381
551
  function reservedCastFrames(items) {
382
552
  const byIndex = new Map();
383
553
  for (const item of items) {
384
- if (item.itemKind === "frame" && item.sceneIndex >= CAST_LEDGER_BASE) {
554
+ if (item.itemKind === "frame" &&
555
+ item.sceneIndex >= CAST_LEDGER_BASE &&
556
+ item.sceneIndex < SET_OPTION_LEDGER_BASE) {
385
557
  byIndex.set(item.sceneIndex, item);
386
558
  }
387
559
  }
@@ -534,6 +706,8 @@ export function planPull(run, items, opts) {
534
706
  for (const frame of artifact.frames ?? []) {
535
707
  if (!frame.imageUrl)
536
708
  continue;
709
+ if (!isPullSceneIndex(frame.sceneIndex))
710
+ continue;
537
711
  keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
538
712
  }
539
713
  }
@@ -551,7 +725,7 @@ export function planPull(run, items, opts) {
551
725
  };
552
726
  continue;
553
727
  }
554
- if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex < 0)
728
+ if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
555
729
  continue;
556
730
  voiceByScene.set(artifact.sceneIndex, {
557
731
  download: {
@@ -592,6 +766,8 @@ export function planPull(run, items, opts) {
592
766
  }
593
767
  if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
594
768
  continue;
769
+ if (!isPullSceneIndex(artifact.sceneIndex))
770
+ continue;
595
771
  const clip = clipFromArtifact(artifact.sceneIndex, artifact);
596
772
  if (!clip)
597
773
  continue;
@@ -601,6 +777,8 @@ export function planPull(run, items, opts) {
601
777
  const frameItemByScene = new Map();
602
778
  const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
603
779
  for (const item of items) {
780
+ if (!isPullSceneIndex(item.sceneIndex))
781
+ continue;
604
782
  if (item.itemKind === "clip")
605
783
  clipItemByScene.set(item.sceneIndex, item);
606
784
  if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
@@ -632,7 +810,7 @@ export function planPull(run, items, opts) {
632
810
  ...frameItemByScene.keys(),
633
811
  ]),
634
812
  ]
635
- .filter((sceneIndex) => sceneIndex >= 0 && sceneIndex < CAST_LEDGER_BASE)
813
+ .filter((sceneIndex) => isPullSceneIndex(sceneIndex))
636
814
  .sort((a, b) => a - b);
637
815
  const scenes = sceneIndexes.map((sceneIndex) => {
638
816
  const clip = clipByScene.get(sceneIndex);
@@ -667,7 +845,7 @@ export function planPull(run, items, opts) {
667
845
  : "this-file",
668
846
  voice: voice?.download.file ?? null,
669
847
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
670
- qc: clip?.qc ?? null,
848
+ qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
671
849
  revoiced: clip ? clip.revoiced : null,
672
850
  speechTrimmed: clip ? clip.speechTrimmed : null,
673
851
  rawStorageId: clip ? clip.rawStorageId : null,
@@ -698,6 +876,10 @@ export function planPull(run, items, opts) {
698
876
  storyboard,
699
877
  reference: reference?.file ?? null,
700
878
  music: music?.file ?? null,
879
+ musicBed: musicBedState(run),
880
+ musicHeardScenes: scenes
881
+ .filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
882
+ .map((scene) => scene.sceneIndex),
701
883
  cast,
702
884
  narration: narrationDownload
703
885
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -707,6 +889,14 @@ export function planPull(run, items, opts) {
707
889
  ...(run.provenance?.format || run.provenance?.voice
708
890
  ? { provenance: run.provenance }
709
891
  : {}),
892
+ ...(run.videoChoice
893
+ ? {
894
+ videoChoice: {
895
+ videoModel: run.videoChoice.videoModel,
896
+ voiceMode: run.videoChoice.voiceMode,
897
+ },
898
+ }
899
+ : {}),
710
900
  },
711
901
  };
712
902
  }
@@ -837,7 +1027,7 @@ export async function startFlow(opts, deps) {
837
1027
  showId: opts.showId,
838
1028
  script,
839
1029
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
840
- ...(opts.music === false ? { music: false } : {}),
1030
+ ...(opts.music === undefined ? {} : { music: opts.music }),
841
1031
  });
842
1032
  if (!res.ok)
843
1033
  return errorResult(res, opts.json);
@@ -866,6 +1056,264 @@ export async function startFlow(opts, deps) {
866
1056
  lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
867
1057
  };
868
1058
  }
1059
+ function nonBlank(value) {
1060
+ if (value === undefined || value.trim().length === 0)
1061
+ return undefined;
1062
+ return value;
1063
+ }
1064
+ function isVoiceMode(value) {
1065
+ return VOICE_MODES.some((mode) => mode === value);
1066
+ }
1067
+ function isConceit(value) {
1068
+ return CONCEIT_KEYS.some((key) => key === value);
1069
+ }
1070
+ export function planMusicChoice(occurrences) {
1071
+ let music;
1072
+ for (const { flag, value } of occurrences) {
1073
+ if (flag === "no-music") {
1074
+ music = false;
1075
+ continue;
1076
+ }
1077
+ if (flag !== "music")
1078
+ continue;
1079
+ if (value === undefined || value.startsWith("--")) {
1080
+ music = true;
1081
+ continue;
1082
+ }
1083
+ const word = value.trim().toLowerCase();
1084
+ const literal = word === "true" ? true : word === "false" ? false : undefined;
1085
+ if (literal === undefined) {
1086
+ return {
1087
+ ok: false,
1088
+ line: `video start --music takes true or false, or no value at all (got "${value}").`,
1089
+ };
1090
+ }
1091
+ music = literal;
1092
+ }
1093
+ return { ok: true, music };
1094
+ }
1095
+ function planValueFlag(occurrences, name) {
1096
+ let value;
1097
+ for (const occurrence of occurrences) {
1098
+ if (occurrence.flag !== name)
1099
+ continue;
1100
+ const given = occurrence.value;
1101
+ if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
1102
+ return { ok: false, line: `video start --${name} needs a value.` };
1103
+ }
1104
+ value = given.trim();
1105
+ }
1106
+ return { ok: true, value };
1107
+ }
1108
+ export function planVideoStart(flags, occurrences) {
1109
+ const showId = nonBlank(flagString(flags, "show"));
1110
+ const scriptFile = nonBlank(flagString(flags, "script"));
1111
+ const conceit = nonBlank(flagString(flags, "conceit"));
1112
+ const style = nonBlank(flagString(flags, "style"));
1113
+ if (showId && (conceit || style)) {
1114
+ return {
1115
+ kind: "usage",
1116
+ line: "Pass either --show <id>, or --conceit and --style. Not both.",
1117
+ };
1118
+ }
1119
+ if (!scriptFile) {
1120
+ return {
1121
+ kind: "usage",
1122
+ line: "video start needs --script <file>, a text file of what the ad says.",
1123
+ };
1124
+ }
1125
+ const musicChoice = planMusicChoice(occurrences);
1126
+ if (!musicChoice.ok)
1127
+ return { kind: "usage", line: musicChoice.line };
1128
+ const videoModelFlag = planValueFlag(occurrences, "video-model");
1129
+ if (!videoModelFlag.ok)
1130
+ return { kind: "usage", line: videoModelFlag.line };
1131
+ const voiceFlag = planValueFlag(occurrences, "voice");
1132
+ if (!voiceFlag.ok)
1133
+ return { kind: "usage", line: voiceFlag.line };
1134
+ const videoModel = videoModelFlag.value;
1135
+ const voiceMode = voiceFlag.value;
1136
+ if (showId && (videoModel || voiceMode)) {
1137
+ return {
1138
+ kind: "usage",
1139
+ line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
1140
+ };
1141
+ }
1142
+ if (voiceMode && !isVoiceMode(voiceMode)) {
1143
+ return {
1144
+ kind: "usage",
1145
+ line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
1146
+ };
1147
+ }
1148
+ if (showId) {
1149
+ return {
1150
+ kind: "show",
1151
+ opts: {
1152
+ showId,
1153
+ scriptFile,
1154
+ voicePath: flagString(flags, "voice-path"),
1155
+ music: musicChoice.music,
1156
+ wait: flags["wait"] === true,
1157
+ json: flags["json"] === true,
1158
+ },
1159
+ };
1160
+ }
1161
+ if (!conceit) {
1162
+ return {
1163
+ kind: "usage",
1164
+ line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
1165
+ };
1166
+ }
1167
+ if (!isConceit(conceit)) {
1168
+ return {
1169
+ kind: "usage",
1170
+ line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
1171
+ };
1172
+ }
1173
+ if (!style) {
1174
+ return {
1175
+ kind: "usage",
1176
+ line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
1177
+ };
1178
+ }
1179
+ const direction = nonBlank(flagString(flags, "direction"));
1180
+ const voicePath = nonBlank(flagString(flags, "voice-path"));
1181
+ const opts = {
1182
+ scriptFile,
1183
+ conceit,
1184
+ style,
1185
+ wait: flags["wait"] === true,
1186
+ json: flags["json"] === true,
1187
+ };
1188
+ if (direction)
1189
+ opts.direction = direction;
1190
+ if (voicePath)
1191
+ opts.voicePath = voicePath;
1192
+ if (videoModel)
1193
+ opts.videoModel = videoModel;
1194
+ if (voiceMode && isVoiceMode(voiceMode))
1195
+ opts.voiceMode = voiceMode;
1196
+ if (musicChoice.music !== undefined)
1197
+ opts.music = musicChoice.music;
1198
+ return { kind: "script", opts };
1199
+ }
1200
+ function isRecord(value) {
1201
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1202
+ }
1203
+ function isResolvedCard(value) {
1204
+ if (!isRecord(value))
1205
+ return false;
1206
+ if (!isRecord(value.conceit) || !isRecord(value.style))
1207
+ return false;
1208
+ if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
1209
+ return false;
1210
+ if (typeof value.family !== "string")
1211
+ return false;
1212
+ if (!("wrapper" in value))
1213
+ return false;
1214
+ if (value.wrapper !== null && typeof value.wrapper !== "string")
1215
+ return false;
1216
+ if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
1217
+ return false;
1218
+ return true;
1219
+ }
1220
+ function startedVideoChoice(value) {
1221
+ if (!isRecord(value))
1222
+ return null;
1223
+ if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
1224
+ return null;
1225
+ return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1226
+ }
1227
+ function scriptStartedLines(runId, url, card, video) {
1228
+ return [
1229
+ `Started ad run ${runId}`,
1230
+ ...(card
1231
+ ? [
1232
+ `Conceit: ${card.conceit.label}`,
1233
+ `Family: ${card.family}`,
1234
+ `Wrapper: ${card.wrapper ?? ""}`,
1235
+ `Style: ${card.style.label}`,
1236
+ ]
1237
+ : ["The server did not include a resolved card."]),
1238
+ ...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
1239
+ `Watch it: ${url}`,
1240
+ ];
1241
+ }
1242
+ function waitJsonWithCard(waited, card, video) {
1243
+ const first = waited.lines[0];
1244
+ if (typeof first !== "string")
1245
+ return waited;
1246
+ let parsed;
1247
+ try {
1248
+ parsed = JSON.parse(first);
1249
+ }
1250
+ catch {
1251
+ return waited;
1252
+ }
1253
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1254
+ return waited;
1255
+ return {
1256
+ code: waited.code,
1257
+ lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1258
+ };
1259
+ }
1260
+ export async function startScriptFlow(opts, deps) {
1261
+ let script;
1262
+ try {
1263
+ script = deps.readFile(opts.scriptFile);
1264
+ }
1265
+ catch (e) {
1266
+ return {
1267
+ code: 1,
1268
+ lines: [
1269
+ `Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
1270
+ ],
1271
+ };
1272
+ }
1273
+ if (script.trim().length === 0) {
1274
+ return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
1275
+ }
1276
+ const res = await deps.post(SCRIPT_RUNS_PATH, {
1277
+ script,
1278
+ conceit: opts.conceit,
1279
+ style: opts.style,
1280
+ ...(opts.direction ? { direction: opts.direction } : {}),
1281
+ ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1282
+ ...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
1283
+ ...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
1284
+ ...(opts.music === true ? { music: true } : {}),
1285
+ });
1286
+ if (!res.ok)
1287
+ return errorResult(res, opts.json);
1288
+ const started = res.data;
1289
+ if (!started.runId) {
1290
+ return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
1291
+ }
1292
+ const runId = started.runId;
1293
+ const url = typeof started.url === "string" ? started.url : "";
1294
+ const card = isResolvedCard(started.card) ? started.card : null;
1295
+ const video = startedVideoChoice(started.videoChoice);
1296
+ if (!opts.wait) {
1297
+ return {
1298
+ code: 0,
1299
+ lines: opts.json
1300
+ ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1301
+ : [
1302
+ ...scriptStartedLines(runId, url, card, video),
1303
+ "",
1304
+ "Wait for the storyboard here instead: exodus video start … --wait",
1305
+ `Or check in whenever: exodus video status ${runId}`,
1306
+ ],
1307
+ };
1308
+ }
1309
+ const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1310
+ if (opts.json)
1311
+ return waitJsonWithCard(waited, card, video);
1312
+ return {
1313
+ code: waited.code,
1314
+ lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
1315
+ };
1316
+ }
869
1317
  const POLL_INTERVAL_MS = 5000;
870
1318
  const MAX_POLLS = 720;
871
1319
  export async function waitFlow(runId, opts, deps) {
@@ -891,15 +1339,16 @@ export async function waitFlow(runId, opts, deps) {
891
1339
  }
892
1340
  continue;
893
1341
  }
1342
+ const resolved = await resolveStopAtPark(stop, runId, deps);
894
1343
  if (opts.json) {
895
1344
  return {
896
1345
  code: stop.at === "failed" ? 1 : 0,
897
- lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
1346
+ lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
898
1347
  };
899
1348
  }
900
1349
  return {
901
1350
  code: stop.at === "failed" ? 1 : 0,
902
- lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
1351
+ lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
903
1352
  };
904
1353
  }
905
1354
  const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
@@ -924,6 +1373,11 @@ function itemWord(item) {
924
1373
  return "flagged";
925
1374
  return ITEM_STATUS_WORD[item.status] ?? item.status;
926
1375
  }
1376
+ 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.";
1377
+ function carryingOn(run) {
1378
+ return (run.status === "queued" &&
1379
+ run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
1380
+ }
927
1381
  export async function statusFlow(runId, json, deps) {
928
1382
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
929
1383
  if (!runRes.ok)
@@ -933,15 +1387,35 @@ export async function statusFlow(runId, json, deps) {
933
1387
  if (!itemsRes.ok)
934
1388
  return errorResult(itemsRes, json);
935
1389
  const items = itemsRes.data.items ?? [];
936
- const stop = classifyRun(run);
937
- const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
1390
+ const hasFinal = hasAttachedCut(items);
1391
+ const stop = resolveStop(classifyRun(run), hasFinal);
1392
+ const failedStoryboard = failedStoryboardNode(run);
1393
+ const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
1394
+ ? { ...stop, error: undefined }
1395
+ : stop, runId, reviewUrl(deps.dashboardUrl, run));
938
1396
  const warnings = run.nodes
939
1397
  .filter((n) => n.warning)
940
1398
  .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
941
1399
  if (json) {
942
1400
  return {
943
1401
  code: 0,
944
- lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
1402
+ lines: [
1403
+ JSON.stringify({
1404
+ runId,
1405
+ status: run.status,
1406
+ stop,
1407
+ items,
1408
+ hasFinal,
1409
+ warnings,
1410
+ guidance,
1411
+ ...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
1412
+ ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1413
+ ...(failedStoryboard?.planFailure
1414
+ ? { storyboardNodeId: failedStoryboard.nodeId }
1415
+ : {}),
1416
+ ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1417
+ }),
1418
+ ],
945
1419
  };
946
1420
  }
947
1421
  const revoicedByScene = new Set();
@@ -972,8 +1446,10 @@ export async function statusFlow(runId, json, deps) {
972
1446
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
973
1447
  const lines = [
974
1448
  headline,
975
- ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
1449
+ ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
1450
+ ...guidance,
976
1451
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1452
+ ...planFailureLines(failedStoryboard, runId),
977
1453
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
978
1454
  ];
979
1455
  if (byScene.size === 0) {
@@ -984,6 +1460,21 @@ export async function statusFlow(runId, json, deps) {
984
1460
  for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
985
1461
  const row = byScene.get(sceneIndex);
986
1462
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1463
+ if (row.clip?.lastRedo) {
1464
+ lines.push(` clip: ${row.clip.lastRedo.label}`);
1465
+ printDisplacedHistory(lines, row.clip.lastRedo);
1466
+ }
1467
+ if (row.clip?.lastRedo || row.clip?.flagged) {
1468
+ for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
1469
+ lines.push(` ${line}`);
1470
+ }
1471
+ }
1472
+ if (row.voiceover?.lastRedo) {
1473
+ lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1474
+ printDisplacedHistory(lines, row.voiceover.lastRedo);
1475
+ }
1476
+ if (row.frame?.lastRedo)
1477
+ lines.push(` picture: ${row.frame.lastRedo.label}`);
987
1478
  for (const finding of row.clip?.findings ?? []) {
988
1479
  lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
989
1480
  const judgeSeverity = finding.judgeSeverity ?? finding.severity;
@@ -1016,6 +1507,45 @@ export async function statusFlow(runId, json, deps) {
1016
1507
  : "Final cut: not uploaded yet.");
1017
1508
  return { code: 0, lines };
1018
1509
  }
1510
+ export async function rejectedDraftFlow(runId, json, deps) {
1511
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1512
+ if (!runRes.ok)
1513
+ return errorResult(runRes, json);
1514
+ const node = failedStoryboardNode(asVideoRun(runRes.data));
1515
+ if (!node) {
1516
+ const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
1517
+ return {
1518
+ code: 0,
1519
+ lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
1520
+ };
1521
+ }
1522
+ const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
1523
+ if (missingRouteLine(res, "exodus video status --rejected-draft")) {
1524
+ return {
1525
+ code: 1,
1526
+ lines: json
1527
+ ? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
1528
+ : [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
1529
+ };
1530
+ }
1531
+ if (!res.ok)
1532
+ return errorResult(res, json);
1533
+ const draft = res.data.draft ?? null;
1534
+ if (json)
1535
+ return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
1536
+ if (!draft)
1537
+ return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
1538
+ return {
1539
+ code: 0,
1540
+ lines: [
1541
+ `REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
1542
+ "The system turned this draft down and threw it away. It is kept only as a record of what happened.",
1543
+ "Nothing in it is part of your ad. There is nothing here to act on or edit.",
1544
+ "",
1545
+ ...draft.split("\n"),
1546
+ ],
1547
+ };
1548
+ }
1019
1549
  export async function storyboardFlow(runId, json, deps) {
1020
1550
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1021
1551
  if (!res.ok)
@@ -1067,7 +1597,14 @@ export async function storyboardFlow(runId, json, deps) {
1067
1597
  }
1068
1598
  }
1069
1599
  }
1070
- lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
1600
+ const runRes = await deps
1601
+ .get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
1602
+ .catch(() => null);
1603
+ const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
1604
+ lines.push("", `approve with: exodus video approve ${runId}`);
1605
+ if (showAd) {
1606
+ lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
1607
+ }
1071
1608
  if (cards.framesNodeId) {
1072
1609
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
1073
1610
  }
@@ -1095,14 +1632,30 @@ function sceneCardLines(scene) {
1095
1632
  lines.push(` picture: ${scene.frame.status}`);
1096
1633
  return lines;
1097
1634
  }
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;
1635
+ export async function approveFlow(runId, opts, deps) {
1636
+ const res = await deps.post(APPROVE_PATH, {
1637
+ runId,
1638
+ ...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
1639
+ });
1640
+ if (!res.ok) {
1641
+ const refusal = errorResult(res, opts.json);
1642
+ const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
1643
+ return staleCut
1644
+ ? {
1645
+ ...refusal,
1646
+ lines: [
1647
+ ...refusal.lines,
1648
+ `To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
1649
+ ],
1650
+ }
1651
+ : refusal;
1652
+ }
1653
+ const data = res.data;
1654
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
1655
+ if (opts.json) {
1656
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
1657
+ }
1658
+ const voices = data?.voices;
1106
1659
  const summary = voices
1107
1660
  ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1108
1661
  : [];
@@ -1110,6 +1663,7 @@ export async function approveFlow(runId, json, deps) {
1110
1663
  code: 0,
1111
1664
  lines: [
1112
1665
  "Approved.",
1666
+ ...warnings,
1113
1667
  ...(summary.length > 0 ? ["", ...summary, ""] : []),
1114
1668
  `See what happens next: exodus video status ${runId}`,
1115
1669
  ],
@@ -1167,8 +1721,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1167
1721
  ],
1168
1722
  };
1169
1723
  }
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.";
1724
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
1725
+ "exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
1726
+ "or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
1172
1727
  export function planClipRedo(run, items, target) {
1173
1728
  const stop = classifyRun(run);
1174
1729
  const found = findClipRow(run, items, target);
@@ -1201,7 +1756,7 @@ export function planClipRedo(run, items, target) {
1201
1756
  };
1202
1757
  }
1203
1758
  function findClipRow(run, items, target) {
1204
- const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1759
+ const uploadedCut = hasAttachedCut(items);
1205
1760
  const rows = items.filter((i) => i.itemKind === "clip" &&
1206
1761
  i.sceneIndex === target.sceneIndex &&
1207
1762
  (target.nodeId === undefined || i.nodeId === target.nodeId));
@@ -1600,6 +2155,13 @@ export function voiceSheetLines(sheet) {
1600
2155
  : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
1601
2156
  return lines;
1602
2157
  }
2158
+ function musicLines(manifest) {
2159
+ const lines = [`music: ${manifest.musicBed}`];
2160
+ if (manifest.musicHeardScenes.length > 0) {
2161
+ lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
2162
+ }
2163
+ return lines;
2164
+ }
1603
2165
  export async function pullFlow(runId, dir, json, deps) {
1604
2166
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1605
2167
  if (!runRes.ok)
@@ -1643,6 +2205,7 @@ export async function pullFlow(runId, dir, json, deps) {
1643
2205
  const lines = [
1644
2206
  `Pulled ${wrote} files into ${dir}`,
1645
2207
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2208
+ ...musicLines(plan.manifest),
1646
2209
  ];
1647
2210
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
1648
2211
  if (flagged.length > 0) {
@@ -1899,7 +2462,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
1899
2462
  const VALUE_FLAGS = new Set([
1900
2463
  "show",
1901
2464
  "script",
2465
+ "conceit",
2466
+ "style",
2467
+ "direction",
1902
2468
  "voice-path",
2469
+ "video-model",
2470
+ "voice",
1903
2471
  "note",
1904
2472
  "out",
1905
2473
  "file",
@@ -1952,20 +2520,12 @@ export async function run(flags, occurrences) {
1952
2520
  if (sub === "shows")
1953
2521
  return printResult(await showsFlow(json, defaultDeps));
1954
2522
  if (sub === "start") {
1955
- const showId = flagString(flags, "show");
1956
- const scriptFile = flagString(flags, "script");
1957
- if (!showId)
1958
- usage("video start needs --show <id>. List them with: exodus video shows");
1959
- if (!scriptFile)
1960
- usage("video start needs --script <file>, a text file of what the ad says.");
1961
- return printResult(await startFlow({
1962
- showId,
1963
- scriptFile,
1964
- voicePath: flagString(flags, "voice-path"),
1965
- music: flags["music"] === false ? false : undefined,
1966
- wait: flags["wait"] === true,
1967
- json,
1968
- }, defaultDeps));
2523
+ const plan = planVideoStart(flags, occurrences);
2524
+ if (plan.kind === "usage")
2525
+ usage(plan.line);
2526
+ if (plan.kind === "show")
2527
+ return printResult(await startFlow(plan.opts, defaultDeps));
2528
+ return printResult(await startScriptFlow(plan.opts, defaultDeps));
1969
2529
  }
1970
2530
  const runId = rest[0];
1971
2531
  const needsRunId = [
@@ -1983,12 +2543,17 @@ export async function run(flags, occurrences) {
1983
2543
  if (needsRunId.includes(sub) && !runId) {
1984
2544
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
1985
2545
  }
1986
- if (sub === "status")
2546
+ if (sub === "status") {
2547
+ if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
2548
+ return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
2549
+ }
1987
2550
  return printResult(await statusFlow(runId, json, defaultDeps));
2551
+ }
1988
2552
  if (sub === "storyboard")
1989
2553
  return printResult(await storyboardFlow(runId, json, defaultDeps));
1990
- if (sub === "approve")
1991
- return printResult(await approveFlow(runId, json, defaultDeps));
2554
+ if (sub === "approve") {
2555
+ return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2556
+ }
1992
2557
  if (sub === "flag") {
1993
2558
  const note = flagString(flags, "note");
1994
2559
  if (!note)