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

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.
@@ -115,6 +115,11 @@ under it:
115
115
  `exodus workflow checkpoint <runId>` is the verb for that one.
116
116
  - failed: `status` shows how far it got, and a `--wait` that was running exits
117
117
  1 with the error. Tell the user; do not start another run on your own.
118
+ When the storyboard is the step that failed, `status` adds a "What happened
119
+ to the storyboard" block: which parts of the plan were accepted, which part
120
+ was turned down, and why (#1902). Relay it as written. On an admin key it may
121
+ also offer `status <runId> --rejected-draft`. That prints the draft the system
122
+ threw away. It is a record only: never build on it, edit it, or re-run it.
118
123
  - finished: pull what it made.
119
124
 
120
125
  **`--wait` stops at both video parks** (#1818). `workflow run --wait` ends at
@@ -8,10 +8,31 @@ export interface ClipWord {
8
8
  s: number;
9
9
  e: number;
10
10
  }
11
+ export type ClipQcTakeNeighbours = {
12
+ state: "not-applicable";
13
+ } | {
14
+ state: "not-attached";
15
+ } | {
16
+ state: "none-accepted";
17
+ } | {
18
+ state: "attached";
19
+ scenes: number[];
20
+ };
21
+ export interface ClipQcTake {
22
+ kind: "take" | "soft-retry";
23
+ failCodes: string[];
24
+ warnCodes: string[];
25
+ neighbours: ClipQcTakeNeighbours;
26
+ judgeWording?: {
27
+ code: string;
28
+ wording: string;
29
+ }[];
30
+ }
11
31
  export interface ClipQc {
12
32
  verdict: "pass" | "fail";
13
33
  attempts: number;
14
34
  neighbours?: number[];
35
+ takes?: ClipQcTake[];
15
36
  }
16
37
  export interface ClipFinding {
17
38
  check: string;
@@ -21,6 +42,9 @@ export interface ClipFinding {
21
42
  judgeDetail?: string;
22
43
  judgeSeverity?: "fail" | "warn";
23
44
  }
45
+ export declare function qcWithoutJudgeWording(qc: ClipQc): ClipQc;
46
+ export declare const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
47
+ export declare function renderQcTakeHistory(takes: ClipQcTake[] | undefined): string[];
24
48
  export type ArtifactSubset = {
25
49
  type: "storyboard";
26
50
  storyboard?: unknown;
@@ -69,12 +93,23 @@ export type ArtifactSubset = {
69
93
  } | {
70
94
  type: "text" | "primer" | "session" | "document";
71
95
  };
96
+ export interface PlanFailureRecord {
97
+ kind: string;
98
+ partsTotal?: number;
99
+ acceptedParts?: number;
100
+ acceptedScenes?: number;
101
+ failedPart?: number;
102
+ reasons?: string[];
103
+ lines: string[];
104
+ }
72
105
  export interface VideoRunNode {
73
106
  nodeId: string;
74
107
  kind: string;
75
108
  status: "idle" | "running" | "done" | "failed" | "skipped" | "out-of-scope";
76
109
  error?: string;
77
110
  warning?: string;
111
+ planFailure?: PlanFailureRecord;
112
+ hasRejectedDraft?: boolean;
78
113
  outputs?: ArtifactSubset[];
79
114
  }
80
115
  export type BuilderPauseReason = "taste" | "repair" | "slots" | "call" | "checkpoint";
@@ -205,6 +240,9 @@ export declare function stepName(kind: string | undefined): string;
205
240
  export declare function isShowAd(run: Pick<VideoRun, "moduleOwned">): boolean;
206
241
  export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
207
242
  export declare function stopLines(stop: ResolvedStop, runId: string, runUrl: string): string[];
243
+ export declare function failedStoryboardNode(run: VideoRun): VideoRunNode | undefined;
244
+ export declare function errorEchoesReasons(error: string | undefined, record: PlanFailureRecord | undefined): boolean;
245
+ export declare function planFailureLines(node: VideoRunNode | undefined, runId: string): string[];
208
246
  export interface PullDownload {
209
247
  file: string;
210
248
  url: string;
@@ -263,7 +301,7 @@ export interface VideoManifest {
263
301
  failed: PullFailure[];
264
302
  provenance?: RunProvenance;
265
303
  }
266
- export declare const CAST_LEDGER_BASE = 910000;
304
+ export declare const CAST_LEDGER_BASE: number;
267
305
  export interface PullPlan {
268
306
  downloads: PullDownload[];
269
307
  texts: PullTextFile[];
@@ -294,6 +332,7 @@ export declare function waitFlow(runId: string, opts: {
294
332
  maxPolls?: number;
295
333
  }, deps: VideoDeps): Promise<FlowResult>;
296
334
  export declare function statusFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
335
+ export declare function rejectedDraftFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
297
336
  export declare function storyboardFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
298
337
  export declare function approveFlow(runId: string, opts: {
299
338
  json: boolean;
@@ -6,6 +6,7 @@ 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 } from "../lib/helperLedgerBase.js";
9
10
  export const helpText = `
10
11
  exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
11
12
 
@@ -41,7 +42,13 @@ The whole loop, in order:
41
42
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
42
43
 
43
44
  7. exodus video status <runId>
44
- Where the run is and how each scene's clip turned out.
45
+ Where the run is and how each scene's clip turned out. If the storyboard
46
+ failed, it also says which parts of it were accepted and why the next one
47
+ was turned down.
48
+
49
+ exodus video status <runId> --rejected-draft
50
+ Prints the storyboard draft that was turned down. A record of what
51
+ happened, thrown away by the system — never something to act on.
45
52
 
46
53
  8. exodus video pull <runId> --out ./ad
47
54
  Writes every piece to that folder plus a manifest.json index.
@@ -67,7 +74,7 @@ The whole loop, in order:
67
74
  or add --approve-stale-cut to deliver the cut you already uploaded.
68
75
 
69
76
  Usage:
70
- exodus video status <runId> [--json]
77
+ exodus video status <runId> [--rejected-draft] [--json]
71
78
  exodus video storyboard <runId> [--json]
72
79
  exodus video approve <runId> [--approve-stale-cut] [--json]
73
80
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
@@ -99,6 +106,8 @@ Options:
99
106
  --clear <who> Take a character's voice off again (voices). Repeatable
100
107
  --from <file.json> A file of characters and voice IDs (voices). --set and
101
108
  --clear win over the same name in the file
109
+ --rejected-draft Print the storyboard draft the system turned down
110
+ (status). It was thrown away — a record only
102
111
  --json Machine-readable output
103
112
  --help, -h Print this help
104
113
 
@@ -124,6 +133,9 @@ const SHOWS_PATH = "/api/v2/shows";
124
133
  const RUNS_PATH = "/api/v2/video/runs";
125
134
  const RUN_PATH = "/api/v2/workflow";
126
135
  const ITEMS_PATH = "/api/v2/workflow/items";
136
+ const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
137
+ const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
138
+ "with the next server update.";
127
139
  const STORYBOARD_PATH = "/api/v2/video/storyboard";
128
140
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
129
141
  const APPROVE_PATH = "/api/v2/workflow/approve";
@@ -138,6 +150,40 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
138
150
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
139
151
  return rest;
140
152
  }
153
+ export function qcWithoutJudgeWording(qc) {
154
+ if (!qc.takes)
155
+ return qc;
156
+ return {
157
+ ...qc,
158
+ takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
159
+ };
160
+ }
161
+ export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
162
+ const TAKE_NEIGHBOUR_SENTENCE = {
163
+ "not-applicable": () => "never compared",
164
+ "not-attached": () => "no neighbours attached",
165
+ "none-accepted": () => "no accepted neighbours yet",
166
+ attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
167
+ };
168
+ export function renderQcTakeHistory(takes) {
169
+ if (!takes || takes.length === 0)
170
+ return [NO_TAKE_HISTORY_LINE];
171
+ const lines = [];
172
+ takes.forEach((take, index) => {
173
+ const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
174
+ const parts = [
175
+ take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
176
+ ...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
177
+ say(take.neighbours),
178
+ ];
179
+ const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
180
+ lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
181
+ for (const { code, wording } of take.judgeWording ?? []) {
182
+ lines.push(` checker said (${code}): ${wording}`);
183
+ }
184
+ });
185
+ return lines;
186
+ }
141
187
  export function asVideoRun(data) {
142
188
  const run = (data ?? {});
143
189
  return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
@@ -352,6 +398,13 @@ export function stopLines(stop, runId, runUrl) {
352
398
  }
353
399
  if (stop.at === "failed") {
354
400
  if (stop.repair) {
401
+ if (!stop.step) {
402
+ return [
403
+ "This run stopped at a step that can't be picked back up from here.",
404
+ "Start a fresh run to get this ad made.",
405
+ `Open the run: ${runUrl}`,
406
+ ];
407
+ }
355
408
  return [
356
409
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
357
410
  `Try that step again: exodus workflow repair ${runId} retry`,
@@ -377,7 +430,34 @@ export function stopLines(stop, runId, runUrl) {
377
430
  }
378
431
  return [`Working: ${stageWord(stop.stage)}.`];
379
432
  }
380
- export const CAST_LEDGER_BASE = 910000;
433
+ export function failedStoryboardNode(run) {
434
+ return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
435
+ }
436
+ export function errorEchoesReasons(error, record) {
437
+ if (!error || record?.kind !== "rejected")
438
+ return false;
439
+ const reasons = record.reasons ?? [];
440
+ return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
441
+ }
442
+ export function planFailureLines(node, runId) {
443
+ const record = node?.planFailure;
444
+ if (!record || !Array.isArray(record.lines) || !node)
445
+ return [];
446
+ const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
447
+ const bodyCount = Math.max(0, record.lines.length - reasons.length);
448
+ const lines = [`What happened to the storyboard step (${node.nodeId}):`];
449
+ record.lines.forEach((line, i) => {
450
+ lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
451
+ });
452
+ if (node?.hasRejectedDraft === true) {
453
+ 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`);
454
+ }
455
+ return lines;
456
+ }
457
+ export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
458
+ function isPullSceneIndex(sceneIndex) {
459
+ return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
460
+ }
381
461
  const FALLBACK_EXT = {
382
462
  image: "png",
383
463
  video: "mp4",
@@ -394,6 +474,10 @@ function extFor(url, family) {
394
474
  export function scenePrefix(sceneIndex) {
395
475
  return `scene-${String(sceneIndex).padStart(2, "0")}`;
396
476
  }
477
+ function clipQcOf(item) {
478
+ const artifact = item?.artifact;
479
+ return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
480
+ }
397
481
  function outputsOfNodeKind(run, kind) {
398
482
  return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
399
483
  }
@@ -576,6 +660,8 @@ export function planPull(run, items, opts) {
576
660
  for (const frame of artifact.frames ?? []) {
577
661
  if (!frame.imageUrl)
578
662
  continue;
663
+ if (!isPullSceneIndex(frame.sceneIndex))
664
+ continue;
579
665
  keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
580
666
  }
581
667
  }
@@ -593,7 +679,7 @@ export function planPull(run, items, opts) {
593
679
  };
594
680
  continue;
595
681
  }
596
- if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex < 0)
682
+ if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
597
683
  continue;
598
684
  voiceByScene.set(artifact.sceneIndex, {
599
685
  download: {
@@ -634,6 +720,8 @@ export function planPull(run, items, opts) {
634
720
  }
635
721
  if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
636
722
  continue;
723
+ if (!isPullSceneIndex(artifact.sceneIndex))
724
+ continue;
637
725
  const clip = clipFromArtifact(artifact.sceneIndex, artifact);
638
726
  if (!clip)
639
727
  continue;
@@ -643,6 +731,8 @@ export function planPull(run, items, opts) {
643
731
  const frameItemByScene = new Map();
644
732
  const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
645
733
  for (const item of items) {
734
+ if (!isPullSceneIndex(item.sceneIndex))
735
+ continue;
646
736
  if (item.itemKind === "clip")
647
737
  clipItemByScene.set(item.sceneIndex, item);
648
738
  if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
@@ -674,7 +764,7 @@ export function planPull(run, items, opts) {
674
764
  ...frameItemByScene.keys(),
675
765
  ]),
676
766
  ]
677
- .filter((sceneIndex) => sceneIndex >= 0 && sceneIndex < CAST_LEDGER_BASE)
767
+ .filter((sceneIndex) => isPullSceneIndex(sceneIndex))
678
768
  .sort((a, b) => a - b);
679
769
  const scenes = sceneIndexes.map((sceneIndex) => {
680
770
  const clip = clipByScene.get(sceneIndex);
@@ -709,7 +799,7 @@ export function planPull(run, items, opts) {
709
799
  : "this-file",
710
800
  voice: voice?.download.file ?? null,
711
801
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
712
- qc: clip?.qc ?? null,
802
+ qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
713
803
  revoiced: clip ? clip.revoiced : null,
714
804
  speechTrimmed: clip ? clip.speechTrimmed : null,
715
805
  rawStorageId: clip ? clip.rawStorageId : null,
@@ -983,7 +1073,10 @@ export async function statusFlow(runId, json, deps) {
983
1073
  const items = itemsRes.data.items ?? [];
984
1074
  const hasFinal = hasAttachedCut(items);
985
1075
  const stop = resolveStop(classifyRun(run), hasFinal);
986
- const guidance = stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run));
1076
+ const failedStoryboard = failedStoryboardNode(run);
1077
+ const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
1078
+ ? { ...stop, error: undefined }
1079
+ : stop, runId, reviewUrl(deps.dashboardUrl, run));
987
1080
  const warnings = run.nodes
988
1081
  .filter((n) => n.warning)
989
1082
  .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
@@ -991,7 +1084,20 @@ export async function statusFlow(runId, json, deps) {
991
1084
  return {
992
1085
  code: 0,
993
1086
  lines: [
994
- JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings, guidance }),
1087
+ JSON.stringify({
1088
+ runId,
1089
+ status: run.status,
1090
+ stop,
1091
+ items,
1092
+ hasFinal,
1093
+ warnings,
1094
+ guidance,
1095
+ ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1096
+ ...(failedStoryboard?.planFailure
1097
+ ? { storyboardNodeId: failedStoryboard.nodeId }
1098
+ : {}),
1099
+ ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1100
+ }),
995
1101
  ],
996
1102
  };
997
1103
  }
@@ -1026,6 +1132,7 @@ export async function statusFlow(runId, json, deps) {
1026
1132
  ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
1027
1133
  ...guidance,
1028
1134
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1135
+ ...planFailureLines(failedStoryboard, runId),
1029
1136
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
1030
1137
  ];
1031
1138
  if (byScene.size === 0) {
@@ -1038,6 +1145,11 @@ export async function statusFlow(runId, json, deps) {
1038
1145
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1039
1146
  if (row.clip?.lastRedo)
1040
1147
  lines.push(` clip: ${row.clip.lastRedo.label}`);
1148
+ if (row.clip?.lastRedo || row.clip?.flagged) {
1149
+ for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
1150
+ lines.push(` ${line}`);
1151
+ }
1152
+ }
1041
1153
  if (row.voiceover?.lastRedo)
1042
1154
  lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1043
1155
  if (row.frame?.lastRedo)
@@ -1074,6 +1186,45 @@ export async function statusFlow(runId, json, deps) {
1074
1186
  : "Final cut: not uploaded yet.");
1075
1187
  return { code: 0, lines };
1076
1188
  }
1189
+ export async function rejectedDraftFlow(runId, json, deps) {
1190
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1191
+ if (!runRes.ok)
1192
+ return errorResult(runRes, json);
1193
+ const node = failedStoryboardNode(asVideoRun(runRes.data));
1194
+ if (!node) {
1195
+ const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
1196
+ return {
1197
+ code: 0,
1198
+ lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
1199
+ };
1200
+ }
1201
+ const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
1202
+ if (missingRouteLine(res, "exodus video status --rejected-draft")) {
1203
+ return {
1204
+ code: 1,
1205
+ lines: json
1206
+ ? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
1207
+ : [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
1208
+ };
1209
+ }
1210
+ if (!res.ok)
1211
+ return errorResult(res, json);
1212
+ const draft = res.data.draft ?? null;
1213
+ if (json)
1214
+ return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
1215
+ if (!draft)
1216
+ return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
1217
+ return {
1218
+ code: 0,
1219
+ lines: [
1220
+ `REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
1221
+ "The system turned this draft down and threw it away. It is kept only as a record of what happened.",
1222
+ "Nothing in it is part of your ad. There is nothing here to act on or edit.",
1223
+ "",
1224
+ ...draft.split("\n"),
1225
+ ],
1226
+ };
1227
+ }
1077
1228
  export async function storyboardFlow(runId, json, deps) {
1078
1229
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1079
1230
  if (!res.ok)
@@ -2066,8 +2217,12 @@ export async function run(flags, occurrences) {
2066
2217
  if (needsRunId.includes(sub) && !runId) {
2067
2218
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
2068
2219
  }
2069
- if (sub === "status")
2220
+ if (sub === "status") {
2221
+ if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
2222
+ return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
2223
+ }
2070
2224
  return printResult(await statusFlow(runId, json, defaultDeps));
2225
+ }
2071
2226
  if (sub === "storyboard")
2072
2227
  return printResult(await storyboardFlow(runId, json, defaultDeps));
2073
2228
  if (sub === "approve") {
@@ -0,0 +1 @@
1
+ export declare const HELPER_LEDGER_BASE = 900000;
@@ -0,0 +1 @@
1
+ export const HELPER_LEDGER_BASE = 900000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.4",
3
+ "version": "2026.9.2000-beta.6",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",