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

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.
@@ -147,9 +147,15 @@ minutes with "Still running after 60 minutes" and exit 0; that is not done, so
147
147
  check `status` before treating it as finished. Do not poll `status` in a tight
148
148
  loop; a full run is several minutes.
149
149
 
150
- **What costs money.** Starting the run creates the storyboard. Approving the
151
- storyboard is the cost gate: it releases the paid rendering of every picture,
152
- voice and clip. `shows`, `workflow list`, `workflow describe`, `video status`,
150
+ **What costs money.** Starting the run writes the storyboard and then draws the
151
+ reference, cast and scene pictures, and only then pauses. Each picture is a few
152
+ cents and each scene draws a few to choose from: about 25 cents on a small test,
153
+ and it can approach a dollar on a longer ad. Approving releases the expensive
154
+ part: the voices and the clips. So a run that has not paused yet is already
155
+ spending a little, and a run still drawing pictures is healthy. `exodus workflow
156
+ status` and `exodus video status` both print "This run will pause for your
157
+ approval once the frames are ready" on a run that is headed for the gate. Do not
158
+ cancel it because the pause has not arrived. `shows`, `workflow list`, `workflow describe`, `video status`,
153
159
  `storyboard` and `pull` are free reads; run them as often as you like. Never
154
160
  start a second run to "retry" without the user asking; flag the storyboard (Show
155
161
  runs) or tell them what failed instead.
@@ -157,7 +163,7 @@ runs) or tell them what failed instead.
157
163
  **`--auto-approve` does not cover either video gate.** It releases Checkpoint
158
164
  boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
159
165
  and the storyboard gate and the final watch carry no pause reason. An unattended
160
- `workflow run --auto-approve` still stops dead at the cost gate — which is the
166
+ `workflow run --auto-approve` still stops dead at the storyboard gate — which is the
161
167
  safe behaviour, since nothing should approve paid rendering or ship an uncut ad
162
168
  on its own.
163
169
 
@@ -276,8 +282,9 @@ for the user, then ask one question.
276
282
  On a **workflow run** the question is approve or don't — there is no flag. If
277
283
  the storyboard is wrong, say so plainly and let the user decide between
278
284
  approving anyway, cancelling (`exodus workflow cancel <runId>`) and re-running
279
- with a better script, or changing the workflow. Approving is the cost gate, so
280
- do not approve a storyboard you have just told the user is wrong.
285
+ with a better script, or changing the workflow. Approving is what releases the
286
+ voices and the clips — the expensive part — so do not approve a storyboard you
287
+ have just told the user is wrong.
281
288
 
282
289
  On a **Show run** you can also send it back: `video flag <runId> --note "…"`.
283
290
  The note is read by the model that rewrites the storyboard, so make it concrete
@@ -457,7 +464,10 @@ changed[] the characters whose voice actually moved (set only)
457
464
  canChange false once the run has left the storyboard review
458
465
  whyNot why, in one sentence, when canChange is false
459
466
  treatment.path which way of making voices this run froze, or null
460
- treatment.kind "performance-then-conversion" or "render-owns-voice"
467
+ treatment.kind "performance-then-conversion", "voiceover-then-lipsync" or
468
+ "render-owns-voice"
469
+ treatment.pinsHeard true when the chosen ElevenLabs voices are heard on this
470
+ run; false only for "render-owns-voice"
461
471
  cast[] one per character
462
472
  characterId, name
463
473
  voice { voiceId, label }: the voice this character's clips are
@@ -237,7 +237,7 @@ Two limits worth knowing before you treat an empty inbox as proof of anything:
237
237
 
238
238
  **What `inbox` does not list is work that isn't waiting on anybody.** A queued run, a running run, and any automation that ran start-to-finish without an approval step never appear there — not because they're hidden, but because there is nothing for you to decide. Plenty of workflows are built that way on purpose; the shipped **Winner Flywheel** starter is one (brief → splitter → output, no Checkpoint box anywhere), so its run *can never* show up in `inbox`. An empty inbox after a promote means "nothing needs you", not "nothing ran".
239
239
 
240
- **To see runs that aren't waiting on you, run `workflow status` with no `--id`.** That prints the brand's recent runs — newest first, one row each with the workflow name, its status, the **date** it started, and its **run id**. That bare list is how you discover a background run at all, including one that started and finished while you weren't looking; take the id from it and `status --id <runId>` for the full detail.
240
+ **To see runs that aren't waiting on you, run `workflow status` with no `--id`.** That prints the brand's recent runs — newest first, one row each with the run's own name (the name you gave it, else the first line of its brief, else the workflow's name), its type ("Copy run" or "Workflow"), its status, the **date** it started, and its **run id**. That bare list is how you discover a background run at all, including one that started and finished while you weren't looking; take the id from it and `status --id <runId>` for the full detail.
241
241
 
242
242
  Two things about that list you have to know before you answer a question with it:
243
243
 
@@ -1,6 +1,6 @@
1
1
  import { type ApiResponse } from "../lib/client.js";
2
2
  import type { FlagOccurrence } from "../lib/args.js";
3
- import { type RunProvenance } from "./workflow.js";
3
+ import { type RunProvenance, type WorkflowRun } from "./workflow.js";
4
4
  export declare const helpText: string;
5
5
  export declare const VOICES_PATH = "/api/v2/video/voices";
6
6
  export interface ClipWord {
@@ -100,6 +100,7 @@ export interface VideoRun {
100
100
  pauseReason?: BuilderPauseReason;
101
101
  pausedNodeId?: string;
102
102
  nodes: VideoRunNode[];
103
+ pauseAhead?: WorkflowRun["pauseAhead"];
103
104
  castLock?: PullCastLock | null;
104
105
  provenance?: RunProvenance;
105
106
  workflowId?: string;
@@ -115,6 +116,7 @@ export interface NodeItem {
115
116
  attempt?: number;
116
117
  flagged?: boolean;
117
118
  findings?: ClipFinding[];
119
+ staleClaim?: boolean;
118
120
  artifact?: ArtifactSubset;
119
121
  }
120
122
  export interface ShowRow {
@@ -296,6 +298,7 @@ export interface ClipRedoTarget {
296
298
  }
297
299
  export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
298
300
  export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
301
+ export declare const VOICE_PATHS_NEVER_HEARD: Set<string>;
299
302
  export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
300
303
  export type RevoiceTarget = ClipRedoTarget | {
301
304
  all: true;
@@ -5,7 +5,7 @@ import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
5
5
  import { displayRunStatus, formatApiError } from "../lib/format.js";
6
6
  import { missingRouteLine } from "../lib/route-support.js";
7
7
  import { hasBinary } from "../lib/preflight.js";
8
- import { ASSET_UPLOAD_POLICY } from "./workflow.js";
8
+ import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
9
9
  export const helpText = `
10
10
  exodus video — make an ad from a Show, pull every piece, upload your cut
11
11
 
@@ -921,6 +921,8 @@ const ITEM_STATUS_WORD = {
921
921
  function itemWord(item) {
922
922
  if (!item)
923
923
  return "—";
924
+ if (item.staleClaim === true)
925
+ return "stopped";
924
926
  if (item.flagged === true)
925
927
  return "flagged";
926
928
  return ITEM_STATUS_WORD[item.status] ?? item.status;
@@ -974,6 +976,7 @@ export async function statusFlow(runId, json, deps) {
974
976
  const lines = [
975
977
  headline,
976
978
  ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
979
+ ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
977
980
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
978
981
  ];
979
982
  if (byScene.size === 0) {
@@ -1176,6 +1179,7 @@ export function planClipRedo(run, items, target) {
1176
1179
  return { ok: false, reason: found.reason };
1177
1180
  const { row, uploadedCut } = found;
1178
1181
  const redoable = row.status === "failed" ||
1182
+ row.staleClaim === true ||
1179
1183
  (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1180
1184
  if (!redoable) {
1181
1185
  if (row.status === "done") {
@@ -1228,7 +1232,7 @@ function findClipRow(run, items, target) {
1228
1232
  "and one clip can only be redone on a step that finished.",
1229
1233
  };
1230
1234
  }
1231
- if (row.status === "running") {
1235
+ if (row.status === "running" && row.staleClaim !== true) {
1232
1236
  return { reason: `Scene ${target.sceneIndex} is already being redone.` };
1233
1237
  }
1234
1238
  return { row, uploadedCut };
@@ -1294,20 +1298,34 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1294
1298
  }
1295
1299
  function clipIsStillRaw(item) {
1296
1300
  const a = item.artifact;
1297
- return (item.status === "done" &&
1301
+ return ((item.status === "done" || item.staleClaim === true) &&
1298
1302
  a?.type === "video" &&
1299
1303
  a.revoiced !== true &&
1300
1304
  a.speechTrimmed !== true &&
1301
1305
  a.tailTrimmed !== true);
1302
1306
  }
1303
1307
  const VOICE_NOT_CHANGED_CODES = new Set(["voice-unpinned", "voice-not-applied"]);
1308
+ export const VOICE_PATHS_NEVER_HEARD = new Set(["native-prompt", "omni-audio-ids", "gemini-direct"]);
1309
+ async function neverHeardRunLine(runId, deps) {
1310
+ const res = await deps
1311
+ .get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
1312
+ .catch(() => null);
1313
+ if (!res?.ok)
1314
+ return null;
1315
+ const path = res.data?.treatment?.path;
1316
+ if (typeof path !== "string" || !VOICE_PATHS_NEVER_HEARD.has(path))
1317
+ return null;
1318
+ return (`This run's clips are not voiced by ElevenLabs — the "${path}" way settles each voice as ` +
1319
+ "the clip is rendered — so no clip on this run has a voice pass to redo. Nothing was queued " +
1320
+ "and nothing was spent. To make a clip again from scratch, redo the clip instead.");
1321
+ }
1304
1322
  export function planClipRevoice(run, items, target) {
1305
1323
  const found = findClipRow(run, items, target);
1306
1324
  if ("reason" in found)
1307
1325
  return { ok: false, reason: found.reason };
1308
1326
  const { row, uploadedCut } = found;
1309
1327
  const remake = `To make the clip again from scratch: exodus video retry-clip ${run._id} --scene ${target.sceneIndex}`;
1310
- if (row.status !== "done" || row.artifact?.type !== "video") {
1328
+ if ((row.status !== "done" && row.staleClaim !== true) || row.artifact?.type !== "video") {
1311
1329
  return {
1312
1330
  ok: false,
1313
1331
  reason: `Scene ${target.sceneIndex} has no finished clip, so there is no voice to redo. ${remake}`,
@@ -1351,10 +1369,11 @@ export async function revoiceFlow(runId, target, json, deps) {
1351
1369
  .map((i) => ({ sceneIndex: i.sceneIndex, nodeId: i.nodeId }))
1352
1370
  : [target];
1353
1371
  if (targets.length === 0) {
1354
- const nothing = "No clip on this run is waiting for a voice pass, so nothing was started.";
1372
+ const note = (await neverHeardRunLine(runId, deps)) ??
1373
+ "No clip on this run is waiting for a voice pass, so nothing was started.";
1355
1374
  return {
1356
1375
  code: 0,
1357
- lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note: nothing })] : [nothing],
1376
+ lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note })] : [note],
1358
1377
  };
1359
1378
  }
1360
1379
  const scenes = [];
@@ -1551,7 +1570,8 @@ export function voiceSheetLines(sheet) {
1551
1570
  for (const row of sheet.cast) {
1552
1571
  const voice = voiceWords(row);
1553
1572
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1554
- `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1573
+ `speaks in ${row.spokenScenes} scene${row.spokenScenes === 1 ? "" : "s"}, ` +
1574
+ `about ${row.spokenSeconds}s`);
1555
1575
  if (row.description)
1556
1576
  lines.push(` written voice: ${row.description}`);
1557
1577
  }
@@ -1565,8 +1585,9 @@ export function voiceSheetLines(sheet) {
1565
1585
  : "Nothing changed — those voices were already set. Pictures and script were not touched.");
1566
1586
  }
1567
1587
  lines.push(`How voices are applied: ${sheet.treatment.summary}${sheet.treatment.speedChange ? "" : " No speed change."}`);
1568
- const rendersOwnVoice = sheet.treatment.kind === "render-owns-voice";
1569
- if (!rendersOwnVoice || sheet.treatment.usesElevenLabs === undefined) {
1588
+ const playsPinnedVoices = sheet.treatment.kind !== "render-owns-voice";
1589
+ const convertsClips = playsPinnedVoices && sheet.treatment.usage.clips > 0;
1590
+ if (convertsClips || sheet.treatment.usesElevenLabs === undefined) {
1570
1591
  lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
1571
1592
  `About ${sheet.treatment.usage.seconds} seconds of speech across ` +
1572
1593
  `${sheet.treatment.usage.clips} clips will be converted.`);
@@ -386,6 +386,9 @@ export interface WorkflowRun {
386
386
  _id: string;
387
387
  workflowId: string;
388
388
  workflowName: string;
389
+ runTitle?: string;
390
+ title?: string;
391
+ moduleKey?: string;
389
392
  status: WorkflowRunStatus;
390
393
  error?: string;
391
394
  counts?: WorkflowCounts;
@@ -408,7 +411,12 @@ export interface WorkflowRun {
408
411
  approvedAt: number;
409
412
  }[];
410
413
  provenance?: RunProvenance;
414
+ pauseAhead?: {
415
+ nodeId: string;
416
+ after: "frames" | "storyboard";
417
+ };
411
418
  }
419
+ export declare function pauseAheadLine(ahead: NonNullable<WorkflowRun["pauseAhead"]>): string;
412
420
  export type WorkflowRunProjection = Omit<WorkflowRun, "nodes"> & {
413
421
  nodes?: never;
414
422
  };
@@ -6,6 +6,7 @@ import { formatApiError } from "../lib/format.js";
6
6
  import { pollUntilDone } from "../lib/poll.js";
7
7
  import { normalizeRunStatus, isTerminalRunStatus, storedWorkflowStatusForms, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
8
8
  import { runVerdict } from "../lib/runVerdict.js";
9
+ import { runDisplayName, runHeadingWord, runTypeWord } from "../lib/runNames.js";
9
10
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
10
11
  import { missingRouteLine } from "../lib/route-support.js";
11
12
  import { getChannel } from "../lib/channel.js";
@@ -366,6 +367,11 @@ export function formatRulesLine(provenance) {
366
367
  : "";
367
368
  return `Format rules: ${rigName}${version}${where}.${borrowed}`;
368
369
  }
370
+ export function pauseAheadLine(ahead) {
371
+ return ahead.after === "frames"
372
+ ? "This run will pause for your approval once the frames are ready. The reference, cast and scene pictures are made first (a few cents each). Nothing bigger is spent until you approve."
373
+ : "This run will pause for your approval once the storyboard is written. Nothing is spent on pictures, voices or clips until you approve.";
374
+ }
369
375
  const LIST_PATH = "/api/v2/workflows";
370
376
  const RUN_PATH = "/api/v2/workflows/run";
371
377
  const STATUS_PATH = "/api/v2/workflow";
@@ -1114,7 +1120,13 @@ const RECENT_RUNS_PAGE = 25;
1114
1120
  export function formatRecentRuns(runs) {
1115
1121
  if (runs.length === 0)
1116
1122
  return "No workflow runs found for the active brand.";
1117
- const rows = table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, runVerdict(r), dateOnly(r.createdAt), r._id]));
1123
+ const rows = table(["run", "type", "status", "created", "id"], runs.map((r) => [
1124
+ runDisplayName(r),
1125
+ runTypeWord(r),
1126
+ runVerdict(r),
1127
+ dateOnly(r.createdAt),
1128
+ r._id,
1129
+ ]));
1118
1130
  const notes = [];
1119
1131
  if (runs.length >= RECENT_RUNS_PAGE) {
1120
1132
  notes.push(`Showing the ${RECENT_RUNS_PAGE} newest runs — there may be older ones this list doesn't reach.`);
@@ -1195,7 +1207,7 @@ export function formatImportSummary(result, mode = {}) {
1195
1207
  export function formatWorkflowRun(run) {
1196
1208
  const lines = [];
1197
1209
  const counts = formatCounts(run.counts);
1198
- lines.push(`Workflow run — ${run.workflowName}`);
1210
+ lines.push(`${runHeadingWord(run)} — ${runDisplayName(run)}`);
1199
1211
  lines.push(`runId: ${run._id}`);
1200
1212
  lines.push(`workflowId: ${run.workflowId}`);
1201
1213
  if (run.triggerRunId)
@@ -1218,6 +1230,10 @@ export function formatWorkflowRun(run) {
1218
1230
  if (run.autoApprovals && run.autoApprovals.length > 0) {
1219
1231
  lines.push(`auto-approved: ${run.autoApprovals.length} checkpoint stop${run.autoApprovals.length === 1 ? "" : "s"} (${run.autoApprovals.map((a) => a.nodeId).join(", ")}) — launched with --auto-approve, nobody reviewed these`);
1220
1232
  }
1233
+ if (run.pauseAhead) {
1234
+ lines.push("");
1235
+ lines.push(pauseAheadLine(run.pauseAhead));
1236
+ }
1221
1237
  const parkedNodeId = gateParkedNodeId(run.status, run.pauseReason, run.pausedNodeId);
1222
1238
  if (run.nodes.length > 0) {
1223
1239
  lines.push("");
@@ -0,0 +1,10 @@
1
+ export declare const COPY_FACE_MODULE_KEY = "copy-face";
2
+ export interface RunNameFields {
3
+ workflowName?: string;
4
+ runTitle?: string;
5
+ title?: string;
6
+ moduleKey?: string;
7
+ }
8
+ export declare function runDisplayName(run: RunNameFields): string;
9
+ export declare function runTypeWord(run: RunNameFields): string;
10
+ export declare function runHeadingWord(run: RunNameFields): string;
@@ -0,0 +1,10 @@
1
+ export const COPY_FACE_MODULE_KEY = "copy-face";
2
+ export function runDisplayName(run) {
3
+ return run.title || run.runTitle || run.workflowName || "";
4
+ }
5
+ export function runTypeWord(run) {
6
+ return run.moduleKey === COPY_FACE_MODULE_KEY ? "Copy run" : "Workflow";
7
+ }
8
+ export function runHeadingWord(run) {
9
+ return run.moduleKey === COPY_FACE_MODULE_KEY ? "Copy run" : "Workflow run";
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1900-beta.2",
3
+ "version": "2026.9.2000-beta.0",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",