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

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.
@@ -101,7 +101,7 @@ exodus video approve <runId> keep going
101
101
  exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
102
102
  exodus video status <runId> where it is, per scene
103
103
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
104
- exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"]
104
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"] [--voice-first]
105
105
  redo one clip at the final watch
106
106
  exodus video revoice <runId> --all | --scene <n> redo only the voice; no new video
107
107
  … make the cut (your choice of tool)
@@ -138,7 +138,11 @@ under it:
138
138
  lines under `guidance`). It is also
139
139
  the one park where a finished clip can be redone, with `retry-clip`. A run
140
140
  only reaches this park if its video node sets `finalWatch: true` — otherwise
141
- it skips straight to finished.
141
+ it skips straight to finished. If a scene has no clip, the stop block names
142
+ it instead (`stop.missingScenes` in `--json`) and approval is refused until
143
+ it has one. A scene whose picture the video service refused needs
144
+ `retry-frame --note` before `retry-clip`; the clip redo alone resends the
145
+ refused picture.
142
146
  - paused: a builder checkpoint neither video verb resolves —
143
147
  `exodus workflow checkpoint <runId>` is the verb for that one.
144
148
  - failed: `status` shows how far it got, and a `--wait` that was running exits
@@ -602,6 +606,9 @@ scenes[] one per scene, in order
602
606
  on record when the voice pass replaced the
603
607
  clip with a new file; null when the clip IS
604
608
  that file, or there is no clip
609
+ voiceMode "voice-first" when the clip was redone voice
610
+ first (its line recorded, then the clip made
611
+ to match it); null otherwise
605
612
  provenance which saved rulebook this run followed:
606
613
  {format: {via, nodeId, rigId, rigName,
607
614
  rulesFrom, specVersion}}. Absent when the
@@ -746,6 +753,7 @@ is no second run, no new storyboard and no re-render of anything else.
746
753
  exodus video retry-clip <runId> --scene 3
747
754
  exodus video retry-clip <runId> --scene 3 --note "keep both hands in frame on the handshake"
748
755
  exodus video retry-clip <runId> --scene 3 --node video-2
756
+ exodus video retry-clip <runId> --scene 3 --voice-first
749
757
  ```
750
758
 
751
759
  `--node` is only needed when a run has clips for that scene on two video steps;
@@ -768,7 +776,17 @@ revoiced and trimmed the same way, with fresh findings and fresh word timings.
768
776
  scene's motion prompt only (`withRedoDirection`,
769
777
  `scout/src/trigger/workflow-scene-retry.ts`); the spoken line still comes from
770
778
  the approved storyboard. Asking for different words in a note will not change
771
- them. If the words are wrong, that is a new run.
779
+ them. If the script's words are wrong, that is a new run.
780
+
781
+ **`--voice-first` fixes a clip that said its line wrong** (#2299). It is for a
782
+ clip whose findings include `speech-mismatch`, `speech-repeat`,
783
+ `speech-after-line` or `speech-long-pause`. The scene's line is recorded in each
784
+ speaker's pinned ElevenLabs voice, and MiniMax (`minimax-h3/reference-to-video`)
785
+ makes the clip to match the recording. Only that clip changes; the word check
786
+ and the other checks still run on it. It needs an ElevenLabs key and a voice for
787
+ every speaker in the scene, and it is refused for a scene with no spoken line.
788
+ The clip then reads `clip: redone voice first` in `status`, carries
789
+ `voiceMode: "voice-first"` in the manifest, and is listed in the `pull` summary.
772
790
 
773
791
  **A redo whose take fails the checks keeps the original clip.** On a finished
774
792
  clip the row stays `done` with the ORIGINAL take and only its findings are
@@ -74,6 +74,7 @@ export type ArtifactSubset = {
74
74
  speechTrimmed?: boolean;
75
75
  tailTrimmed?: boolean;
76
76
  rawStorageId?: string;
77
+ voiceMode?: "voice-first";
77
78
  } | {
78
79
  type: "audio";
79
80
  sceneIndex?: number;
@@ -114,6 +115,11 @@ export interface VideoRunNode {
114
115
  planFailure?: PlanFailureRecord;
115
116
  hasRejectedDraft?: boolean;
116
117
  outputs?: ArtifactSubset[];
118
+ missingScenes?: MissingScene[];
119
+ }
120
+ export interface MissingScene {
121
+ sceneIndex: number;
122
+ pictureRefused: boolean;
117
123
  }
118
124
  export type BuilderPauseReason = "taste" | "repair" | "slots" | "call" | "checkpoint";
119
125
  export interface PullCastLockMember {
@@ -161,6 +167,7 @@ export interface NodeItem {
161
167
  take: number;
162
168
  outcome: string;
163
169
  label: string;
170
+ reason?: string;
164
171
  displacedHistory?: {
165
172
  title: string;
166
173
  lines: string[];
@@ -220,6 +227,8 @@ export type RunStop = {
220
227
  showAd?: true;
221
228
  } | {
222
229
  at: "final-watch";
230
+ missingScenes?: MissingScene[];
231
+ framesNodeId?: string;
223
232
  } | {
224
233
  at: "paused";
225
234
  nodeId?: string;
@@ -236,10 +245,11 @@ export type RunStop = {
236
245
  };
237
246
  export type ResolvedStop = Exclude<RunStop, {
238
247
  at: "final-watch";
239
- }> | {
248
+ }> | (Extract<RunStop, {
240
249
  at: "final-watch";
250
+ }> & {
241
251
  cutAttached: boolean | null;
242
- };
252
+ });
243
253
  export declare function classifyRun(run: VideoRun): RunStop;
244
254
  export declare function hasAttachedCut(items: NodeItem[]): boolean;
245
255
  export declare function resolveStop(stop: RunStop, cutAttached: boolean | null): ResolvedStop;
@@ -273,6 +283,7 @@ export interface ManifestScene {
273
283
  revoiced: boolean | null;
274
284
  speechTrimmed: boolean | null;
275
285
  rawStorageId: string | null;
286
+ voiceMode: "voice-first" | null;
276
287
  clipStatus: string;
277
288
  error: string | null;
278
289
  flagged: boolean;
@@ -411,7 +422,11 @@ export interface ClipRedoTarget {
411
422
  nodeId?: string;
412
423
  }
413
424
  export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
414
- export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
425
+ export interface ClipRedoOptions {
426
+ note?: string;
427
+ voiceFirst?: boolean;
428
+ }
429
+ export declare function retryClipFlow(runId: string, target: ClipRedoTarget, { note, voiceFirst }: ClipRedoOptions, json: boolean, deps: VideoDeps): Promise<FlowResult>;
415
430
  export declare const VOICE_PATHS_NEVER_HEARD: Set<string>;
416
431
  export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
417
432
  export type RevoiceTarget = ClipRedoTarget | {
@@ -70,7 +70,9 @@ The whole loop, in order:
70
70
 
71
71
  exodus video retry-clip <runId> --scene <n>
72
72
  Redo ONE finished clip while the run waits for the cut. Everything
73
- else stays. A --note steers the motion, never the words.
73
+ else stays. A --note steers the motion, never the words. Add
74
+ --voice-first when the words came out wrong: the line is recorded in
75
+ the character's own voice first, then the clip is made to match it.
74
76
 
75
77
  exodus video revoice <runId> --all
76
78
  Redo only the VOICE on clips that kept the video model's own voice
@@ -93,7 +95,7 @@ Usage:
93
95
  exodus video storyboard <runId> [--json]
94
96
  exodus video approve <runId> [--approve-stale-cut] [--json]
95
97
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
96
- exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
98
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
97
99
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
98
100
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
99
101
  exodus video pull <runId> --out <dir> [--json]
@@ -113,6 +115,9 @@ Options:
113
115
  (retry-frame, retry-clip, revoice)
114
116
  --all Every finished clip that kept the video model's voice
115
117
  (revoice)
118
+ --voice-first Record the scene's line in the character's own voice,
119
+ then make the clip to match it (retry-clip). For a clip
120
+ whose words came out wrong
116
121
  --approve-stale-cut Deliver the cut you uploaded even though a clip changed
117
122
  after you uploaded it (approve). Without it, approving
118
123
  stops and names the scenes that changed
@@ -151,6 +156,7 @@ Examples:
151
156
  exodus video approve run_123
152
157
  exodus video retry-frame run_123 --node frames-1 --scene 2
153
158
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
159
+ exodus video retry-clip run_123 --scene 3 --voice-first
154
160
  exodus video revoice run_123 --all
155
161
  exodus video voices run_123
156
162
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
@@ -171,6 +177,9 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
171
177
  const APPROVE_PATH = "/api/v2/workflow/approve";
172
178
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
173
179
  const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
180
+ const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
181
+ const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
182
+ "was spent. It arrives with the next server update.";
174
183
  const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
175
184
  "was spent. It arrives with the next server update.";
176
185
  const FINAL_PATH = "/api/v2/video/final";
@@ -214,6 +223,9 @@ export function renderQcTakeHistory(takes) {
214
223
  });
215
224
  return lines;
216
225
  }
226
+ function redoLine(lastRedo) {
227
+ return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
228
+ }
217
229
  function printDisplacedHistory(lines, lastRedo) {
218
230
  const history = lastRedo.displacedHistory;
219
231
  if (!history)
@@ -337,8 +349,17 @@ export function classifyRun(run) {
337
349
  ...(isShowAd(run) ? { showAd: true } : {}),
338
350
  };
339
351
  }
340
- if (parkedAtFinalWatch(run))
341
- return { at: "final-watch" };
352
+ if (parkedAtFinalWatch(run)) {
353
+ const missingScenes = run.nodes
354
+ .filter((n) => n.kind === "video")
355
+ .flatMap((n) => n.missingScenes ?? []);
356
+ const framesNodeId = run.nodes.find((n) => n.kind === "scene-frames")?.nodeId;
357
+ return {
358
+ at: "final-watch",
359
+ ...(missingScenes.length > 0 ? { missingScenes } : {}),
360
+ ...(missingScenes.length > 0 && framesNodeId ? { framesNodeId } : {}),
361
+ };
362
+ }
342
363
  return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
343
364
  }
344
365
  const active = run.nodes.find((n) => n.status === "running");
@@ -350,7 +371,7 @@ export function hasAttachedCut(items) {
350
371
  export function resolveStop(stop, cutAttached) {
351
372
  if (stop.at !== "final-watch")
352
373
  return stop;
353
- return { at: "final-watch", cutAttached };
374
+ return { ...stop, cutAttached };
354
375
  }
355
376
  export async function resolveStopAtPark(stop, runId, deps) {
356
377
  if (stop.at !== "final-watch")
@@ -395,6 +416,30 @@ export function reviewUrl(dashboardUrl, run) {
395
416
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
396
417
  return `${dashboardUrl}/runs/${run._id}`;
397
418
  }
419
+ function scenesPhrase(scenes) {
420
+ if (scenes.length === 1)
421
+ return `scene ${scenes[0]}`;
422
+ return `scenes ${scenes.slice(0, -1).join(", ")} and ${scenes[scenes.length - 1]}`;
423
+ }
424
+ function missingSceneLines(missing, framesNodeId, runId, runUrl) {
425
+ const scenes = missing.map((m) => m.sceneIndex);
426
+ const lines = [
427
+ `Parked: ${scenesPhrase(scenes)} ${scenes.length === 1 ? "has" : "have"} no clip yet, so the ad isn't complete.`,
428
+ ];
429
+ for (const { sceneIndex, pictureRefused } of missing) {
430
+ if (!pictureRefused) {
431
+ lines.push(`Redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
432
+ continue;
433
+ }
434
+ lines.push(`Scene ${sceneIndex}'s picture was refused by the video service as too close to an existing character, so redraw it before redoing the clip.`);
435
+ lines.push(framesNodeId
436
+ ? `Redraw frame ${sceneIndex}: exodus video retry-frame ${runId} --node ${framesNodeId} --scene ${sceneIndex} --note "<what to change>"`
437
+ : `Redraw frame ${sceneIndex}: on the run page, ${runUrl}`);
438
+ lines.push(`Then redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
439
+ }
440
+ lines.push("The ad can't be approved until every scene has a clip.", `Watch it here: ${runUrl}`);
441
+ return lines;
442
+ }
398
443
  export function stopLines(stop, runId, runUrl) {
399
444
  if (stop.at === "storyboard-gate") {
400
445
  const lines = [
@@ -411,6 +456,9 @@ export function stopLines(stop, runId, runUrl) {
411
456
  return lines;
412
457
  }
413
458
  if (stop.at === "final-watch") {
459
+ if (stop.missingScenes?.length) {
460
+ return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
461
+ }
414
462
  if (stop.cutAttached === null) {
415
463
  return [
416
464
  "Parked: every piece is made.",
@@ -540,6 +588,7 @@ function clipFromArtifact(sceneIndex, artifact) {
540
588
  revoiced: artifact.revoiced === true,
541
589
  speechTrimmed: artifact.speechTrimmed === true,
542
590
  rawStorageId: artifact.rawStorageId ?? null,
591
+ voiceMode: artifact.voiceMode ?? null,
543
592
  };
544
593
  }
545
594
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -849,6 +898,7 @@ export function planPull(run, items, opts) {
849
898
  revoiced: clip ? clip.revoiced : null,
850
899
  speechTrimmed: clip ? clip.speechTrimmed : null,
851
900
  rawStorageId: clip ? clip.rawStorageId : null,
901
+ voiceMode: clip ? clip.voiceMode : null,
852
902
  clipStatus: item?.status ?? "missing",
853
903
  error: item?.error ?? null,
854
904
  flagged: item?.flagged === true,
@@ -922,6 +972,7 @@ export function markPullFailure(manifest, failure) {
922
972
  scene.revoiced = null;
923
973
  scene.speechTrimmed = null;
924
974
  scene.rawStorageId = null;
975
+ scene.voiceMode = null;
925
976
  }
926
977
  if (scene.voice === failure.file)
927
978
  scene.voice = null;
@@ -1419,8 +1470,14 @@ export async function statusFlow(runId, json, deps) {
1419
1470
  };
1420
1471
  }
1421
1472
  const revoicedByScene = new Set();
1473
+ const voiceFirstByScene = new Set();
1422
1474
  const timedOnOriginalVoice = new Set();
1423
1475
  for (const artifact of outputsOfNodeKind(run, "video")) {
1476
+ if (artifact.type === "video" &&
1477
+ artifact.voiceMode === "voice-first" &&
1478
+ typeof artifact.sceneIndex === "number") {
1479
+ voiceFirstByScene.add(artifact.sceneIndex);
1480
+ }
1424
1481
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
1425
1482
  revoicedByScene.add(artifact.sceneIndex);
1426
1483
  if ((artifact.words?.length ?? 0) > 0)
@@ -1461,7 +1518,7 @@ export async function statusFlow(runId, json, deps) {
1461
1518
  const row = byScene.get(sceneIndex);
1462
1519
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1463
1520
  if (row.clip?.lastRedo) {
1464
- lines.push(` clip: ${row.clip.lastRedo.label}`);
1521
+ lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
1465
1522
  printDisplacedHistory(lines, row.clip.lastRedo);
1466
1523
  }
1467
1524
  if (row.clip?.lastRedo || row.clip?.flagged) {
@@ -1470,7 +1527,7 @@ export async function statusFlow(runId, json, deps) {
1470
1527
  }
1471
1528
  }
1472
1529
  if (row.voiceover?.lastRedo) {
1473
- lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1530
+ lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
1474
1531
  printDisplacedHistory(lines, row.voiceover.lastRedo);
1475
1532
  }
1476
1533
  if (row.frame?.lastRedo)
@@ -1489,6 +1546,8 @@ export async function statusFlow(runId, json, deps) {
1489
1546
  lines.push(` ${row.clip.error}`);
1490
1547
  if (revoicedByScene.has(sceneIndex))
1491
1548
  lines.push(" voice: cast voice applied");
1549
+ if (voiceFirstByScene.has(sceneIndex))
1550
+ lines.push(" clip: redone voice first");
1492
1551
  if (timedOnOriginalVoice.has(sceneIndex)) {
1493
1552
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1494
1553
  }
@@ -1789,7 +1848,7 @@ function findClipRow(run, items, target) {
1789
1848
  }
1790
1849
  return { row, uploadedCut };
1791
1850
  }
1792
- export async function retryClipFlow(runId, target, note, json, deps) {
1851
+ export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
1793
1852
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1794
1853
  if (!runRes.ok)
1795
1854
  return errorResult(runRes, json);
@@ -1805,12 +1864,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1805
1864
  lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1806
1865
  };
1807
1866
  }
1808
- const res = await deps.post(SCENE_RETRY_PATH, {
1867
+ const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
1809
1868
  runId,
1810
1869
  nodeId: plan.nodeId,
1811
1870
  sceneIndex: plan.sceneIndex,
1812
1871
  ...(note ? { note } : {}),
1813
1872
  });
1873
+ if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
1874
+ return {
1875
+ code: 1,
1876
+ lines: json
1877
+ ? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
1878
+ : [VOICE_FIRST_NOT_ON_THIS_SERVER],
1879
+ };
1880
+ }
1814
1881
  if (!res.ok)
1815
1882
  return errorResult(res, json);
1816
1883
  const triggerRunId = res.data.triggerRunId;
@@ -1829,6 +1896,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1829
1896
  reviewUrl: review,
1830
1897
  warnings: plan.warnings,
1831
1898
  ...(note ? { note } : {}),
1899
+ ...(voiceFirst ? { voiceMode: "voice-first" } : {}),
1832
1900
  }),
1833
1901
  ],
1834
1902
  };
@@ -1836,7 +1904,10 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1836
1904
  return {
1837
1905
  code: 0,
1838
1906
  lines: [
1839
- `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1907
+ voiceFirst
1908
+ ? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
1909
+ "recorded in the character's own voice, then the clip is made to match it."
1910
+ : `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1840
1911
  `triggerRunId: ${triggerRunId ?? "-"}`,
1841
1912
  "Every other scene stays as it is, and so do the pictures, the voices and the script.",
1842
1913
  ...(note
@@ -2207,6 +2278,11 @@ export async function pullFlow(runId, dir, json, deps) {
2207
2278
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2208
2279
  ...musicLines(plan.manifest),
2209
2280
  ];
2281
+ const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
2282
+ if (voiceFirst.length > 0) {
2283
+ lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
2284
+ `scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
2285
+ }
2210
2286
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
2211
2287
  if (flagged.length > 0) {
2212
2288
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -2583,7 +2659,7 @@ export async function run(flags, occurrences) {
2583
2659
  if (!Number.isInteger(sceneIndex)) {
2584
2660
  usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
2585
2661
  }
2586
- return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
2662
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
2587
2663
  }
2588
2664
  if (sub === "revoice") {
2589
2665
  const sceneRaw = flagString(flags, "scene");
@@ -409,7 +409,7 @@ export interface RunFormatProvenance {
409
409
  };
410
410
  specVersion: string | null;
411
411
  }
412
- export type VoicePath = "sts-revoice" | "lipsync-retarget" | "native-prompt" | "omni-audio-ids" | "gemini-direct";
412
+ export type VoicePath = "sts-revoice" | "lipsync-retarget" | "native-prompt" | "omni-audio-ids" | "gemini-direct" | "voice-first";
413
413
  export type RunVoiceProvenance = {
414
414
  source: "show";
415
415
  path: VoicePath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.10",
3
+ "version": "2026.9.2000-beta.12",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",