@aicopycoders/exodus 2026.9.1700-beta.1 → 2026.9.1900-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.
@@ -9,7 +9,7 @@ import { runVerdict } from "../lib/runVerdict.js";
9
9
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
10
10
  import { missingRouteLine } from "../lib/route-support.js";
11
11
  import { getChannel } from "../lib/channel.js";
12
- import { asVideoRun, classifyRun, stopLines } from "./video.js";
12
+ import { asVideoRun, classifyRun, reviewUrl, stopLines, } from "./video.js";
13
13
  export const helpText = `
14
14
  exodus workflow — List, describe, run, inspect, import, and export saved workflows
15
15
 
@@ -20,7 +20,7 @@ Usage:
20
20
  exodus workflow templates [list] [--json]
21
21
  exodus workflow templates export <key> [--out <file>] [--json]
22
22
  exodus workflow schema [--kind <kind>] [--face <face>] [--json]
23
- exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--rig-overrides <json|@file>] [--auto-approve] [--wait] [--out <dir>] [--json]
23
+ exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--auto-approve] [--wait] [--out <dir>] [--json]
24
24
  exodus workflow status [--json]
25
25
  exodus workflow status --id <runId> [--out <dir>] [--json]
26
26
  exodus workflow cancel <runId> [--reason "..."] [--json]
@@ -83,6 +83,25 @@ Flags:
83
83
  before anything runs, and the error names it. On
84
84
  "triggers fire" it REPLACES the schedule's own
85
85
  overrides for that one test fire.
86
+ --voices <json> (run) Give the ad's characters their voices at the
87
+ moment the run starts, instead of at the storyboard
88
+ review. Keyed by the names the SCRIPT uses for its
89
+ speakers ("HOST 1") — the planner's character ids
90
+ (C1, C2) do not exist yet when a run starts. Takes a
91
+ JSON object, or @path to a .json file holding one:
92
+ --voices @voices.json
93
+ --voices '{"HOST 1":"<voiceId>"}'
94
+ Same file "exodus video voices <run> --from" reads at
95
+ the review, so a brand keeps ONE voices.json for both.
96
+ A speaker the script does not have, the same speaker
97
+ named twice, or a voice ElevenLabs does not have stops
98
+ the launch before the run is created — nothing is
99
+ spent, and the message says which one. A workflow whose
100
+ script is written by a bot (so nobody knows the speaker
101
+ names at the start) is told to use "exodus video voices
102
+ <run>" at the storyboard review instead. Voice ids
103
+ belong to the brand's own folder — never put them in a
104
+ shared workflow or template.
86
105
  --auto-approve (run) Deliberately unattended launch: every Checkpoint
87
106
  box this run stops at is approved automatically, with
88
107
  whatever it was holding left exactly as it is, and the
@@ -174,6 +193,7 @@ Examples:
174
193
  exodus workflow run "Launch Flow" --auto-approve --wait
175
194
  exodus workflow run "Product Shots" --rig-overrides '{"rig_1":{"lines":{"line_1":{"count":3}}}}'
176
195
  exodus workflow run "Product Shots" --rig-overrides @rig.json --wait
196
+ exodus workflow run "Podcast Ad" --input script=@script.txt --voices @voices.json --wait
177
197
  exodus workflow run "Launch Flow" --wait --out ./deliverables
178
198
  exodus workflow status # recent runs + their ids
179
199
  exodus workflow status --id wr_123
@@ -235,6 +255,19 @@ Notes:
235
255
  message says which key was wrong. A trigger can carry the same payload in its
236
256
  YAML ("imageRigOverrides:" beside its schedule), so a Monday schedule and a
237
257
  Friday one can fire the same workflow at different sizes.
258
+ "workflow run --voices" hands a video run its voices at the start, so a brand
259
+ that always uses the same hosts stops choosing them by hand on every run. The
260
+ file is keyed by the names the script uses for its speakers, because that is
261
+ all that exists before the planner has run — it is the SAME voices.json
262
+ "exodus video voices <run> --from" reads at the storyboard review, so one file
263
+ per brand serves both moments. A speaker the script does not have, the same
264
+ speaker named twice, or a voice ElevenLabs does not have stops the launch
265
+ before the run exists and nothing is spent. When the storyboard is planned,
266
+ each named speaker's character already has their voice, and "exodus video
267
+ voices <run>" shows them set. A workflow whose script is written by a bot has
268
+ no speaker names to key on at the start, so it is refused with a pointer at
269
+ the review-time command. Keep voice ids in the brand's own folder, never in a
270
+ shared workflow or template. (#1846)
238
271
  workflow triggers are addressed by 1-based position — a trigger carries no id,
239
272
  so the CLI reads the live list from the export contract and sends a fingerprint
240
273
  of the trigger's fields as the guard; a concurrent edit fails loud rather than
@@ -320,6 +353,7 @@ const VALUE_FLAGS = new Set([
320
353
  "slot",
321
354
  "reject",
322
355
  "rig-overrides",
356
+ "voices",
323
357
  "note",
324
358
  ]);
325
359
  function defaultMkdirp(dir) {
@@ -917,6 +951,63 @@ export function parseRigOverridesFlag(args, readFile) {
917
951
  }
918
952
  return parsed;
919
953
  }
954
+ export function parseVoicesFlag(args, readFile) {
955
+ let raw;
956
+ for (let i = 0; i < args.length; i++) {
957
+ const arg = args[i];
958
+ let value;
959
+ if (arg === "--voices") {
960
+ value = args[i + 1];
961
+ i++;
962
+ }
963
+ else if (arg.startsWith("--voices=")) {
964
+ value = arg.slice("--voices=".length);
965
+ }
966
+ else {
967
+ continue;
968
+ }
969
+ if (value === undefined || value.startsWith("--")) {
970
+ throw new Error("--voices requires JSON or @path/to/voices.json");
971
+ }
972
+ raw = value;
973
+ }
974
+ if (raw === undefined)
975
+ return undefined;
976
+ let text = raw.trim();
977
+ if (!text)
978
+ throw new Error("--voices requires JSON or @path/to/voices.json");
979
+ if (text.startsWith("@@")) {
980
+ text = text.slice(1);
981
+ }
982
+ else if (text.startsWith("@")) {
983
+ const filePath = text.slice(1);
984
+ if (!filePath) {
985
+ throw new Error("--voices @<file> needs a file path after \"@\"");
986
+ }
987
+ if (!readFile) {
988
+ throw new Error(`--voices: cannot load @${filePath} here (no file access)`);
989
+ }
990
+ try {
991
+ text = readFile(filePath);
992
+ }
993
+ catch (e) {
994
+ const msg = e instanceof Error ? e.message : String(e);
995
+ throw new Error(`--voices: could not read file "${filePath}": ${msg}`);
996
+ }
997
+ }
998
+ let parsed;
999
+ try {
1000
+ parsed = JSON.parse(text);
1001
+ }
1002
+ catch (e) {
1003
+ const msg = e instanceof Error ? e.message : String(e);
1004
+ throw new Error(`--voices is not valid JSON: ${msg}`);
1005
+ }
1006
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1007
+ throw new Error("--voices must be a JSON object keyed by the script's speaker names, e.g. {\"HOST 1\":\"<voiceId>\"}");
1008
+ }
1009
+ return parsed;
1010
+ }
920
1011
  export function parseFillFlag(args) {
921
1012
  let name;
922
1013
  for (let i = 0; i < args.length; i++) {
@@ -1723,6 +1814,20 @@ export async function botsFlow(opts, deps) {
1723
1814
  return { code: 0, lines: [JSON.stringify(catalog)] };
1724
1815
  return { code: 0, lines: [formatBotsList(catalog, opts.category)] };
1725
1816
  }
1817
+ function launchVoicesLine(attached) {
1818
+ if (!Array.isArray(attached))
1819
+ return undefined;
1820
+ const said = [];
1821
+ for (const raw of attached) {
1822
+ if (!raw || typeof raw !== "object")
1823
+ continue;
1824
+ const { speaker, voiceId, label } = raw;
1825
+ if (typeof speaker !== "string" || typeof voiceId !== "string")
1826
+ continue;
1827
+ said.push(`${speaker} → ${typeof label === "string" && label !== "" ? label : voiceId}`);
1828
+ }
1829
+ return said.length > 0 ? `Voices: ${said.join(", ")}` : undefined;
1830
+ }
1726
1831
  export async function runFlow(workflowRef, opts, deps) {
1727
1832
  let workflowId;
1728
1833
  try {
@@ -1765,6 +1870,7 @@ export async function runFlow(workflowRef, opts, deps) {
1765
1870
  ...(opts.fill && opts.fill.trim() !== "" ? { fill: opts.fill.trim() } : {}),
1766
1871
  ...(opts.autoApprove === true ? { autoApprove: true } : {}),
1767
1872
  ...(opts.imageRigOverrides ? { imageRigOverrides: opts.imageRigOverrides } : {}),
1873
+ ...(opts.voices ? { voices: opts.voices } : {}),
1768
1874
  launchedVia: "cli",
1769
1875
  };
1770
1876
  const start = await deps.post(RUN_PATH, body);
@@ -1778,6 +1884,7 @@ export async function runFlow(workflowRef, opts, deps) {
1778
1884
  const base = { ...data, workflowId };
1779
1885
  if (opts.json && !opts.wait)
1780
1886
  return { code: 0, lines: [JSON.stringify(base)] };
1887
+ const voicesLine = launchVoicesLine(data?.launchVoices);
1781
1888
  const lines = opts.json
1782
1889
  ? []
1783
1890
  : [
@@ -1785,6 +1892,7 @@ export async function runFlow(workflowRef, opts, deps) {
1785
1892
  "Workflow run started.",
1786
1893
  `runId: ${data.runId}`,
1787
1894
  `triggerRunId: ${data.triggerRunId}`,
1895
+ ...(voicesLine ? [voicesLine] : []),
1788
1896
  `Poll: exodus workflow status --id ${data.runId}`,
1789
1897
  ];
1790
1898
  if (!opts.wait) {
@@ -1903,7 +2011,8 @@ async function waitForRun(runId, opts, deps) {
1903
2011
  lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
1904
2012
  }
1905
2013
  if (videoStop) {
1906
- lines.push("", ...stopLines(videoStop, runId, deps.dashboardUrl ?? getDashboardUrl()));
2014
+ const review = reviewUrl(deps.dashboardUrl ?? getDashboardUrl(), asVideoRun(pollResult.data));
2015
+ lines.push("", ...stopLines(videoStop, runId, review));
1907
2016
  }
1908
2017
  return { code: pollResult.ok ? 0 : 1, lines };
1909
2018
  }
@@ -2900,19 +3009,21 @@ export async function run(flags) {
2900
3009
  const workflowRef = rest[0];
2901
3010
  if (!workflowRef) {
2902
3011
  console.error("Error: workflow run requires <workflowId|name>.");
2903
- console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--auto-approve] [--wait] [--json]");
3012
+ console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--auto-approve] [--wait] [--json]");
2904
3013
  process.exit(1);
2905
3014
  }
2906
3015
  let inputs;
2907
3016
  let fill;
2908
3017
  let autoApprove;
2909
3018
  let imageRigOverrides;
3019
+ let voices;
2910
3020
  try {
2911
3021
  inputs = parseRawInputFlags(process.argv.slice(3));
2912
3022
  rejectTerminalFlag(process.argv.slice(3));
2913
3023
  fill = parseFillFlag(process.argv.slice(3));
2914
3024
  autoApprove = parseAutoApproveFlag(process.argv.slice(3));
2915
3025
  imageRigOverrides = parseRigOverridesFlag(process.argv.slice(3), defaultDeps.readFile);
3026
+ voices = parseVoicesFlag(process.argv.slice(3), defaultDeps.readFile);
2916
3027
  }
2917
3028
  catch (e) {
2918
3029
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -2923,6 +3034,7 @@ export async function run(flags) {
2923
3034
  fill,
2924
3035
  autoApprove,
2925
3036
  imageRigOverrides,
3037
+ voices,
2926
3038
  wait: flags["wait"] === true,
2927
3039
  json,
2928
3040
  out: flagString(flags, "out"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1700-beta.1",
3
+ "version": "2026.9.1900-beta.0",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",