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

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.
@@ -104,7 +104,10 @@ under it:
104
104
  - storyboard gate: needs `approve`. When the stop block prints a `Redo one
105
105
  frame:` line with a node id, one still can be redone first with `retry-frame`;
106
106
  a gate parked before the stills are drawn has nothing to redraw.
107
- - final watch: every piece is made; needs `pull`, a cut, and `upload`. It is also
107
+ - final watch: every piece is made; needs `pull`, a cut, and `upload`. Once a
108
+ cut is uploaded the stop block leads with `approve` instead, and `status
109
+ --json` says which state it is in (`stop.cutAttached`, plus the same next-step
110
+ lines under `guidance`). It is also
108
111
  the one park where a finished clip can be redone, with `retry-clip`. A run
109
112
  only reaches this park if its video node sets `finalWatch: true` — otherwise
110
113
  it skips straight to finished.
@@ -677,8 +680,11 @@ them. If the words are wrong, that is a new run.
677
680
  **A redo whose take fails the checks keeps the original clip.** On a finished
678
681
  clip the row stays `done` with the ORIGINAL take and only its findings are
679
682
  refreshed, because a worse take must never replace a delivered one. So never
680
- report that the clip changed. Read `status` again for the new findings, then pull
681
- and compare the file before and after.
683
+ report that the clip changed without reading `status` again: under the scene it
684
+ prints what the redo did, `new take accepted (take N)` or `new take rejected,
685
+ original kept (take N)`, and `status --json` carries the same sentence on the
686
+ row as `lastRedo.label`. A row with no `lastRedo` was redone before outcomes
687
+ were recorded; for those, pull and compare the file before and after.
682
688
 
683
689
  **Re-cut afterwards.** A cut you already uploaded still holds the old clip, and
684
690
  the CLI says so when that is the case. Pull again, re-cut and upload again before
@@ -1,6 +1,5 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { createInterface } from "node:readline/promises";
4
3
  import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
5
4
  import { pollUntilDone } from "../lib/poll.js";
6
5
  import { displayRunStatus, formatGenesisRun, formatError, tickerRunStatus } from "../lib/format.js";
@@ -278,52 +277,13 @@ export async function run(flags) {
278
277
  return submitGenesis(action.body, { noWait, cc });
279
278
  }
280
279
  }
281
- async function listBank(limit) {
282
- const res = await apiGet(`/api/v2/scout/bank?limit=${limit}`);
283
- if (!res.ok) {
284
- console.log(formatError(res));
285
- process.exit(1);
286
- }
287
- const ideas = Array.isArray(res.data.ideas) ? res.data.ideas : [];
288
- if (ideas.length === 0) {
289
- console.log("Bank is empty.");
290
- console.log('Fill it with: exodus genesis scrape (or paste a reel: exodus genesis --reel "<url>")');
291
- return;
292
- }
293
- console.log(`## Idea Bank (${ideas.length} reel${ideas.length === 1 ? "" : "s"})`);
294
- console.log("");
295
- for (const i of ideas) {
296
- const hook = i.hook ?? "(no hook)";
297
- const user = typeof i.sourceUsername === "string" && i.sourceUsername.length > 0
298
- ? ` @${i.sourceUsername.replace(/^@+/, "")}`
299
- : "";
300
- const score = typeof i.relevanceScore === "number" ? ` score=${i.relevanceScore.toFixed(2)}` : "";
301
- const used = typeof i.useCount === "number" && i.useCount > 0 ? " · used" : "";
302
- console.log(` • ${hook}${user}${score}`);
303
- console.log(` id: ${i._id ?? "?"} [${i.status ?? "new"}${used}]`);
304
- if (i.sourceUrl)
305
- console.log(` ${i.sourceUrl}`);
306
- }
307
- console.log("");
308
- console.log("Write from one: exodus genesis --from-bank --idea <id>");
280
+ async function listBank(_limit) {
281
+ console.error(`This command has been retired. View the idea bank in the dashboard: ${getDashboardUrl()}/idea-bank`);
282
+ process.exit(1);
309
283
  }
310
- async function runFromBank(ideaId, opts, rt) {
311
- const res = await apiPost("/api/v2/scout/bank/ideas", { ideaIds: [ideaId] }, { ccCommand: rt.cc });
312
- if (!res.ok) {
313
- console.log(formatError(res));
314
- process.exit(1);
315
- }
316
- const idea = (res.data.ideas ?? [])[0];
317
- if (!idea) {
318
- console.error(`No banked idea found for id "${ideaId}". List the bank: exodus genesis --from-bank`);
319
- process.exit(1);
320
- }
321
- if (!idea.sourceUrl) {
322
- console.error(`Banked idea "${ideaId}" has no source reel URL to write from.`);
323
- process.exit(1);
324
- }
325
- console.log(`Writing from banked reel: ${idea.sourceUrl}`);
326
- return runReel([idea.sourceUrl], opts, rt);
284
+ async function runFromBank(_ideaId, _opts, _rt) {
285
+ console.error(`This command has been retired. Write from banked ideas in the dashboard: ${getDashboardUrl()}/idea-bank`);
286
+ process.exit(1);
327
287
  }
328
288
  async function runReel(urls, opts, rt) {
329
289
  console.log(`Transcribing ${urls.length === 1 ? "the reel" : `${urls.length} reels`} → idea → Genesis…`);
@@ -613,92 +573,11 @@ function requireActiveBrand() {
613
573
  }
614
574
  return slug;
615
575
  }
616
- async function runConnectInstagram(flags, cc) {
617
- const client = requireActiveBrand();
618
- const start = await apiPost("/api/v2/genesis/instagram/connect/start", { client }, { ccCommand: cc });
619
- if (!start.ok || !start.data.liveViewUrl) {
620
- console.log(formatError(start));
621
- process.exit(1);
622
- }
623
- console.log(`\nConnecting an Instagram account to brand "${client}".`);
624
- console.log("\n1. Open this link in your browser:\n");
625
- console.log(` ${start.data.liveViewUrl}\n`);
626
- console.log('2. Go to instagram.com and log into the account for THIS brand.');
627
- console.log("3. Scroll your feed for a bit so the algorithm learns the niche.");
628
- console.log("4. Come back here and press Enter.\n");
629
- const rl = createInterface({ input: process.stdin, output: process.stdout });
630
- try {
631
- await rl.question("Press Enter once you're logged in and done grooming... ");
632
- let username = typeof flags["username"] === "string" ? flags["username"].trim() : "";
633
- while (!username) {
634
- username = (await rl.question("Instagram handle you just logged in as (e.g. mybrand): ")).trim();
635
- }
636
- const finish = await apiPost("/api/v2/genesis/instagram/connect/finish", { client, igUsername: username }, { ccCommand: cc });
637
- if (!finish.ok) {
638
- console.log(formatError(finish));
639
- process.exit(1);
640
- }
641
- console.log(`\n✓ Instagram @${username.replace(/^@+/, "")} connected to "${client}".`);
642
- console.log("Fill the idea bank now: exodus genesis scrape --organic");
643
- }
644
- finally {
645
- rl.close();
646
- }
576
+ async function runConnectInstagram(_flags, _cc) {
577
+ console.error(`This command has been retired. Connect Instagram accounts in the dashboard: ${getDashboardUrl()}/settings`);
578
+ process.exit(1);
647
579
  }
648
- async function runScrape(flags, cc) {
649
- const client = requireActiveBrand();
650
- const noWait = flags["wait"] === false || flags["no-wait"] === true;
651
- if (flags["organic"] === true) {
652
- const smoke = flags["smoke"] === true;
653
- const res = await apiPost("/api/v2/scout/organic", { client, ...(smoke ? { smoke: true } : {}) }, { ccCommand: cc });
654
- if (!res.ok || !res.data.runId) {
655
- console.log(formatError(res));
656
- process.exit(1);
657
- }
658
- console.log(`Organic scrape started for "${client}": ${res.data.runId}${smoke ? " (smoke)" : ""}`);
659
- return pollScrape(res.data.runId, noWait, 1_800_000);
660
- }
661
- const term = typeof flags["term"] === "string" ? flags["term"] : undefined;
662
- const body = { sourceMode: "fresh", client };
663
- if (term)
664
- body.term = term;
665
- const res = await apiPost("/api/v2/scout", body, { ccCommand: cc });
666
- if (!res.ok || !res.data.runId) {
667
- console.log(formatError(res));
668
- process.exit(1);
669
- }
670
- console.log(`Discovery scrape started for "${client}": ${res.data.runId}`);
671
- return pollScrape(res.data.runId, noWait, 1_200_000);
672
- }
673
- async function pollScrape(runId, noWait, timeoutMs) {
674
- if (noWait) {
675
- console.log(`Run ID: ${runId}`);
676
- console.log(`Check status: exodus status --type scout --id ${runId}`);
677
- return;
678
- }
679
- console.log("Polling for completion...");
680
- const result = await pollUntilDone({
681
- path: `/api/v2/scout?runId=${runId}`,
682
- intervalMs: 15_000,
683
- timeoutMs,
684
- onProgress: (data) => {
685
- const status = data["status"];
686
- const captured = data["organicCaptured"];
687
- const qualified = data["organicQualified"];
688
- const counts = captured !== undefined || qualified !== undefined
689
- ? ` captured=${captured ?? 0} qualified=${qualified ?? 0}`
690
- : "";
691
- if (status)
692
- process.stdout.write(`\r status: ${tickerRunStatus(status)}${counts} `);
693
- },
694
- });
695
- console.log();
696
- if (result.timedOut) {
697
- console.log(`Timed out waiting. Check later: exodus status --type scout --id ${runId}`);
698
- }
699
- else {
700
- console.log(`Done. Banked reels are ready to write: exodus genesis --from-bank`);
701
- }
702
- if (!result.ok && !result.timedOut)
703
- process.exit(1);
580
+ async function runScrape(_flags, _cc) {
581
+ console.error(`This command has been retired. Fill the idea bank from the Mining page in the dashboard: ${getDashboardUrl()}/mining`);
582
+ process.exit(1);
704
583
  }
@@ -1,6 +1,5 @@
1
- import { apiGet } from "../lib/client.js";
1
+ import { apiGet, getDashboardUrl } from "../lib/client.js";
2
2
  import { formatGeneration, formatGenesisRun, formatError, displayRunStatus } from "../lib/format.js";
3
- import { hydrateScoutIdeasCount } from "../lib/scout-hydrate.js";
4
3
  import { formatIntelResult as formatIntelResultRich } from "../lib/intel-format.js";
5
4
  export const helpText = `
6
5
  exodus status — Check the status of a pipeline run
@@ -12,19 +11,13 @@ Required:
12
11
  --id <runId> The generation or run ID returned by a prior exodus command
13
12
 
14
13
  Options:
15
- --type <pipeline> Pipeline type: generation (default) | genesis | intel | pulse | scout
14
+ --type <pipeline> Pipeline type: generation (default) | genesis | intel | pulse
16
15
  | creative | template
17
16
  Spark, Viral, Mirror, Remix runs use --type generation (default).
18
- Intel, Pulse, Scout require --type intel|pulse|scout.
17
+ Intel, Pulse require --type intel|pulse.
19
18
  Creative-suite (native/copy-derived/ref-match/meme)
20
19
  use --type creative; Template uses --type template.
21
20
  `.trim();
22
- async function fetchScoutIdeasCount(runId) {
23
- const res = await apiGet(`/api/v2/scout/ideas?runId=${runId}&limit=1`);
24
- if (res.ok && typeof res.data.count === "number")
25
- return res.data.count;
26
- return null;
27
- }
28
21
  export function formatIntelResult(data) {
29
22
  return formatIntelResultRich(data).replace("## Intel Analysis Result", "## Intel Status");
30
23
  }
@@ -157,18 +150,21 @@ export async function run(flags) {
157
150
  process.exit(1);
158
151
  }
159
152
  const type = flags["type"] ?? "generation";
153
+ if (type === "scout") {
154
+ console.error(`--type scout has been retired. Check run status in the dashboard: ${getDashboardUrl()}/runs`);
155
+ process.exit(1);
156
+ }
160
157
  const pathMap = {
161
158
  generation: `/api/v2/generations?id=${id}`,
162
159
  genesis: `/api/v2/genesis?id=${id}`,
163
160
  intel: `/api/v2/intel?id=${id}`,
164
161
  pulse: `/api/v2/pulse?id=${id}`,
165
- scout: `/api/v2/scout?runId=${id}`,
166
162
  creative: `/api/v2/creative?runId=${id}`,
167
163
  template: `/api/v2/template?runId=${id}`,
168
164
  };
169
165
  const path = pathMap[type];
170
166
  if (!path) {
171
- console.error(`Unknown type: "${type}". Valid types: generation, genesis, intel, pulse, scout, creative, template`);
167
+ console.error(`Unknown type: "${type}". Valid types: generation, genesis, intel, pulse, creative, template`);
172
168
  process.exit(1);
173
169
  }
174
170
  const res = await apiGet(path);
@@ -176,9 +172,6 @@ export async function run(flags) {
176
172
  console.log(formatError(res));
177
173
  process.exit(1);
178
174
  }
179
- if (type === "scout") {
180
- await hydrateScoutIdeasCount(id, res.data, fetchScoutIdeasCount);
181
- }
182
175
  switch (type) {
183
176
  case "generation":
184
177
  console.log(formatGeneration(res.data));
@@ -192,9 +185,6 @@ export async function run(flags) {
192
185
  case "pulse":
193
186
  console.log(formatPulseResult(res.data));
194
187
  break;
195
- case "scout":
196
- console.log(formatScoutResult(res.data));
197
- break;
198
188
  case "creative":
199
189
  console.log(formatCreativeSuiteStatus("Creative Suite Status", res.data));
200
190
  break;
@@ -117,6 +117,11 @@ export interface NodeItem {
117
117
  flagged?: boolean;
118
118
  findings?: ClipFinding[];
119
119
  staleClaim?: boolean;
120
+ lastRedo?: {
121
+ take: number;
122
+ outcome: string;
123
+ label: string;
124
+ };
120
125
  artifact?: ArtifactSubset;
121
126
  }
122
127
  export interface ShowRow {
@@ -184,11 +189,20 @@ export type RunStop = {
184
189
  at: "finished";
185
190
  status: string;
186
191
  };
192
+ export type ResolvedStop = Exclude<RunStop, {
193
+ at: "final-watch";
194
+ }> | {
195
+ at: "final-watch";
196
+ cutAttached: boolean | null;
197
+ };
187
198
  export declare function classifyRun(run: VideoRun): RunStop;
199
+ export declare function hasAttachedCut(items: NodeItem[]): boolean;
200
+ export declare function resolveStop(stop: RunStop, cutAttached: boolean | null): ResolvedStop;
201
+ export declare function resolveStopAtPark(stop: RunStop, runId: string, deps: Pick<VideoDeps, "get">): Promise<ResolvedStop>;
188
202
  export declare function stageWord(stage: string): string;
189
203
  export declare function stepName(kind: string | undefined): string;
190
204
  export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
191
- export declare function stopLines(stop: RunStop, runId: string, runUrl: string): string[];
205
+ export declare function stopLines(stop: ResolvedStop, runId: string, runUrl: string): string[];
192
206
  export interface PullDownload {
193
207
  file: string;
194
208
  url: string;
@@ -254,6 +254,22 @@ export function classifyRun(run) {
254
254
  const active = run.nodes.find((n) => n.status === "running");
255
255
  return { at: "running", stage: active?.kind ?? "starting" };
256
256
  }
257
+ export function hasAttachedCut(items) {
258
+ return items.some((i) => i.itemKind === "final" && i.status === "done");
259
+ }
260
+ export function resolveStop(stop, cutAttached) {
261
+ if (stop.at !== "final-watch")
262
+ return stop;
263
+ return { at: "final-watch", cutAttached };
264
+ }
265
+ export async function resolveStopAtPark(stop, runId, deps) {
266
+ if (stop.at !== "final-watch")
267
+ return stop;
268
+ const res = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
269
+ if (!res.ok)
270
+ return resolveStop(stop, null);
271
+ return resolveStop(stop, hasAttachedCut(res.data.items ?? []));
272
+ }
257
273
  const STAGE_WORDS = {
258
274
  brief: "reading the script",
259
275
  storyboard: "writing the storyboard",
@@ -300,6 +316,21 @@ export function stopLines(stop, runId, runUrl) {
300
316
  return lines;
301
317
  }
302
318
  if (stop.at === "final-watch") {
319
+ if (stop.cutAttached === null) {
320
+ return [
321
+ "Parked: every piece is made.",
322
+ `Couldn't check whether a cut is already uploaded. See where it stands: exodus video status ${runId}`,
323
+ `Watch it here: ${runUrl}`,
324
+ ];
325
+ }
326
+ if (stop.cutAttached) {
327
+ return [
328
+ "Parked: your cut is uploaded and waiting for your approval.",
329
+ `Approve it: exodus video approve ${runId}`,
330
+ `Watch it here: ${runUrl}`,
331
+ "Changed your mind? You can still redo a clip and upload a new cut.",
332
+ ];
333
+ }
303
334
  return [
304
335
  "Parked: every piece is made and the run is waiting for a cut.",
305
336
  `Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
@@ -891,15 +922,16 @@ export async function waitFlow(runId, opts, deps) {
891
922
  }
892
923
  continue;
893
924
  }
925
+ const resolved = await resolveStopAtPark(stop, runId, deps);
894
926
  if (opts.json) {
895
927
  return {
896
928
  code: stop.at === "failed" ? 1 : 0,
897
- lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
929
+ lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
898
930
  };
899
931
  }
900
932
  return {
901
933
  code: stop.at === "failed" ? 1 : 0,
902
- lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
934
+ lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
903
935
  };
904
936
  }
905
937
  const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
@@ -933,15 +965,18 @@ export async function statusFlow(runId, json, deps) {
933
965
  if (!itemsRes.ok)
934
966
  return errorResult(itemsRes, json);
935
967
  const items = itemsRes.data.items ?? [];
936
- const stop = classifyRun(run);
937
- const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
968
+ const hasFinal = hasAttachedCut(items);
969
+ const stop = resolveStop(classifyRun(run), hasFinal);
970
+ const guidance = stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run));
938
971
  const warnings = run.nodes
939
972
  .filter((n) => n.warning)
940
973
  .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
941
974
  if (json) {
942
975
  return {
943
976
  code: 0,
944
- lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
977
+ lines: [
978
+ JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings, guidance }),
979
+ ],
945
980
  };
946
981
  }
947
982
  const revoicedByScene = new Set();
@@ -972,7 +1007,7 @@ export async function statusFlow(runId, json, deps) {
972
1007
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
973
1008
  const lines = [
974
1009
  headline,
975
- ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
1010
+ ...guidance,
976
1011
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
977
1012
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
978
1013
  ];
@@ -984,6 +1019,12 @@ export async function statusFlow(runId, json, deps) {
984
1019
  for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
985
1020
  const row = byScene.get(sceneIndex);
986
1021
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1022
+ if (row.clip?.lastRedo)
1023
+ lines.push(` clip: ${row.clip.lastRedo.label}`);
1024
+ if (row.voiceover?.lastRedo)
1025
+ lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1026
+ if (row.frame?.lastRedo)
1027
+ lines.push(` picture: ${row.frame.lastRedo.label}`);
987
1028
  for (const finding of row.clip?.findings ?? []) {
988
1029
  lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
989
1030
  const judgeSeverity = finding.judgeSeverity ?? finding.severity;
@@ -1201,7 +1242,7 @@ export function planClipRedo(run, items, target) {
1201
1242
  };
1202
1243
  }
1203
1244
  function findClipRow(run, items, target) {
1204
- const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1245
+ const uploadedCut = hasAttachedCut(items);
1205
1246
  const rows = items.filter((i) => i.itemKind === "clip" &&
1206
1247
  i.sceneIndex === target.sceneIndex &&
1207
1248
  (target.nodeId === undefined || i.nodeId === target.nodeId));
@@ -10,7 +10,7 @@ import { runDisplayName, runHeadingWord, runTypeWord } from "../lib/runNames.js"
10
10
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
11
11
  import { missingRouteLine } from "../lib/route-support.js";
12
12
  import { getChannel } from "../lib/channel.js";
13
- import { asVideoRun, classifyRun, reviewUrl, stopLines, } from "./video.js";
13
+ import { asVideoRun, classifyRun, resolveStopAtPark, reviewUrl, stopLines, } from "./video.js";
14
14
  export const helpText = `
15
15
  exodus workflow — List, describe, run, inspect, import, and export saved workflows
16
16
 
@@ -2085,9 +2085,10 @@ async function waitForRun(runId, opts, deps) {
2085
2085
  const saved = opts.out !== undefined && terminalRun
2086
2086
  ? await saveDeliveries(terminalRun, opts.out, deps)
2087
2087
  : undefined;
2088
- const videoStop = !pollResult.timedOut && isRecord(pollResult.data)
2088
+ const parkStop = !pollResult.timedOut && isRecord(pollResult.data)
2089
2089
  ? videoParkStop(pollResult.data)
2090
2090
  : undefined;
2091
+ const videoStop = parkStop ? await resolveStopAtPark(parkStop, runId, deps) : undefined;
2091
2092
  if (opts.json) {
2092
2093
  return {
2093
2094
  code: pollResult.ok ? 0 : 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.1",
3
+ "version": "2026.9.2000-beta.2",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",