@aicopycoders/exodus 2026.7.200-beta.13 → 2026.7.200-beta.14

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.
@@ -59,7 +59,7 @@ These are the **Exodus** pipelines — the automatic system that drives the Gene
59
59
  | **Creative** | `exodus-creative` | A specific creative-suite engine (native / copy-derived renders from copy, or ref-match to a reference image) when you've already picked one. Otherwise start at **Image**. |
60
60
  | **Template** | `exodus-template` | Finished ad copy → many static ads across 50 structured formats (testimonial, hero, UGC…). Otherwise start at **Image**. |
61
61
  | **Meme** | `exodus-meme` | Meme ads — recommends formats for a brief, then one batched run renders AI-image and classic Imgflip-template memes server-side. |
62
- | **Workflow** | `exodus-workflow` | Run and build saved multi-node workflow automations from the CLI, resolve gate/repair parks from the inbox, continue bot sessions, fire triggers, and read banks or promote winners. |
62
+ | **Workflow** | `exodus-workflow` | Run and build saved multi-node workflow automations from the CLI, resolve checkpoint/repair parks from the inbox, continue bot sessions, fire triggers, and read banks or promote winners. |
63
63
  | **Browse** | `exodus-browse` | View history and retrieve past outputs |
64
64
  | **Drive** | `exodus-drive` | Read/write Google Docs, Sheets, and Drive files via `npx @aicopycoders/exodus drive` (uses the dashboard's Google OAuth — no local CLI) |
65
65
  | **Winners** | `exodus-winners` | Mine your own Meta ad account for winning ads (via the Meta Ads MCP) and import them into Exodus as generative fuel — interview, visual confirmation, `npx @aicopycoders/exodus winners import` |
@@ -19,7 +19,7 @@ This skill ROUTES. Read what the user wants, name the destination skill, and han
19
19
  | A specific image engine they already chose | `exodus-creative` (native / copy-derived / ref-match) or `exodus-template` (33 ad-type formats) |
20
20
  | Meme ads ("exodus, meme this offer", "make me meme ads") | `exodus-meme` |
21
21
  | Run / chain / build / edit a saved workflow, enable/fire its triggers, or roll back a version ("run my workflow", "what does this workflow need", "build a workflow that…", "list my workflows", "enable/fire the trigger", "roll back my workflow", "workflow versions") | `exodus-workflow` |
22
- | Check the workflow inbox or review/resolve a parked run — gate or repair ("check my workflow inbox", "review that gate", "approve the run", "repair that run") | `exodus-workflow` |
22
+ | Check the workflow inbox or review/resolve a parked run — checkpoint or repair ("check my workflow inbox", "approve that checkpoint", "approve the run", "repair that run") | `exodus-workflow` |
23
23
  | Continue a bot chat session ("continue that session", "chat with the bot session", "list my sessions") | `exodus-workflow` |
24
24
  | Read copy banks or promote a winner ("show my hook bank", "list my banks", "promote this winner") | `exodus-workflow` |
25
25
  | Onboard a new brand / build the primer | `exodus-primer` (with winning ads) or `exodus-foundation` (no ads yet) |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: exodus-workflow
3
- description: Run, chain, build, edit, and automate saved Exodus Workflows — the multi-node automations that wire Genesis bots, primers, briefs, transforms, and image nodes together on the dashboard canvas — from the CLI. Use it to list a brand's workflows, describe what a workflow needs before running it (inputs, per-workspace primer prerequisites, outputs), run one and collect its outputs, chain one workflow's output into another's input, browse the bot catalog, author or update a workflow contract via templates → edit → validate → import, resolve runs that park (review a gate from the terminal, check the workflow inbox, repair a stalled run, answer a nested workflow's slots), continue a bot session in chat, enable/disable/fire triggers, read the brand's copy banks or promote a winner, and list versions or roll one back. Only invoke when the user has explicitly invoked Exodus: they said "exodus" in the request ("exodus, run my launch workflow", "exodus, what does this workflow need", "exodus, build a workflow that turns a swipe into hooks then ads", "list my exodus workflows", "exodus, edit that workflow", "exodus, review this gate", "exodus, check my workflow inbox", "exodus, resolve that parked run", "exodus, continue that session", "exodus, enable/fire the trigger", "exodus, read my hooks bank", "exodus, promote this winner", "exodus, list the versions / roll it back"), named this skill or /exodus-workflow, ran an `npx @aicopycoders/exodus workflow` / `session` / `bank` command, or the `exodus` hub skill routed here. Never claim generic "workflow"/"automation"/"inbox"/"promote a winner"/"continue the session" requests ("automate this", "build me a workflow", "check my inbox") without Exodus context — in shared folders those may belong to the user's other tools; if the user did not say exodus, this skill is not for them. The bare word "Genesis" without "exodus" refers to the member's own Genesis API key and personal bot recipes, NOT to Exodus.
3
+ description: Run, chain, build, edit, and automate saved Exodus Workflows — the multi-node automations that wire Genesis bots, primers, briefs, transforms, and image nodes together on the dashboard canvas — from the CLI. Use it to list a brand's workflows, describe what a workflow needs before running it (inputs, per-workspace primer prerequisites, outputs), run one and collect its outputs, chain one workflow's output into another's input, browse the bot catalog, author or update a workflow contract via templates → edit → validate → import, resolve runs that park (approve a step paused at a checkpoint, check the workflow inbox, repair a stalled run, answer a nested workflow's slots), continue a bot session in chat, enable/disable/fire triggers, read the brand's copy banks or promote a winner, and list versions or roll one back. Only invoke when the user has explicitly invoked Exodus: they said "exodus" in the request ("exodus, run my launch workflow", "exodus, what does this workflow need", "exodus, build a workflow that turns a swipe into hooks then ads", "list my exodus workflows", "exodus, edit that workflow", "exodus, approve that checkpoint", "exodus, check my workflow inbox", "exodus, resolve that parked run", "exodus, continue that session", "exodus, enable/fire the trigger", "exodus, read my hooks bank", "exodus, promote this winner", "exodus, list the versions / roll it back"), named this skill or /exodus-workflow, ran an `npx @aicopycoders/exodus workflow` / `session` / `bank` command, or the `exodus` hub skill routed here. Never claim generic "workflow"/"automation"/"inbox"/"promote a winner"/"continue the session" requests ("automate this", "build me a workflow", "check my inbox") without Exodus context — in shared folders those may belong to the user's other tools; if the user did not say exodus, this skill is not for them. The bare word "Genesis" without "exodus" refers to the member's own Genesis API key and personal bot recipes, NOT to Exodus.
4
4
  ---
5
5
 
6
6
  ```operator-guide
@@ -8,8 +8,8 @@ Core — discover & run:
8
8
  exodus workflow list [--json] List the brand's saved workflows
9
9
  exodus workflow describe <workflowId|name> [--json] Inputs, prerequisites, outputs
10
10
  exodus workflow bots [--category <cat>] [--slug <slug>] [--json] Bot catalog / one bot's spec
11
- exodus workflow run <workflowId|name> [--input key=value ...] [--terminal <nodeId> ...] [--wait] [--json] Run it
12
- exodus workflow status [--id <runId>] [--json] Poll a run / read its outputs
11
+ exodus workflow run <workflowId|name> [--input key=value ...] [--fill <name>] [--terminal <nodeId> ...] [--wait] [--out <dir>] [--json] Run it
12
+ exodus workflow status [--id <runId>] [--out <dir>] [--json] Poll a run / read its outputs
13
13
  exodus workflow export <workflowId|name> [--version <n>] [--out <file>] [--json] Dump the contract (YAML)
14
14
 
15
15
  Authoring:
@@ -21,7 +21,8 @@ Authoring:
21
21
 
22
22
  Parked runs:
23
23
  exodus workflow inbox [--json] Every run parked waiting on you
24
- exodus workflow gate <runId> [pick <n,..> | edit <n> | push "<msg>" | approve | reject [--reason "..."]] [--json] Resolve a gate
24
+ exodus workflow checkpoint <runId> [show | edit <n> | approve [--reject <n,..>] [--wait] | retry [--wait] | cancel [--reason "..."]] [--json] Resolve a checkpoint park
25
+ exodus workflow gate <runId> RETIRED in 2.0 — prints a pointer at "checkpoint" and exits 1
25
26
  exodus workflow repair <runId> retry|skip|kill [--wait] [--json] Resolve a repair park
26
27
  exodus workflow answer <runId> [--slot key=value ...] [--json] Answer a nested workflow's slots (no --slot → list them)
27
28
 
@@ -40,10 +41,25 @@ Banks + versions:
40
41
  Flag notes that add agent-level guidance (everything else is in --help):
41
42
  --input key=@path Load an input's value from a file (path resolved from cwd);
42
43
  key=@@text keeps a leading "@" as a literal character.
44
+ --input key=<path> (run) A FILE input — one `describe` reports with source
45
+ "asset" — takes a local file path instead of text: the CLI
46
+ uploads the file and sends the stored asset in its place
47
+ (--input hero=./photos/hero.png). An asset id from an
48
+ earlier upload is relayed as-is. Accepted: image PNG/JPEG/
49
+ WebP/GIF ≤15MB, video MP4/MOV/WebM ≤200MB, audio MP3/M4A/
50
+ WAV/OGG ≤50MB, document PDF/TXT/MD/DOC/DOCX ≤25MB.
43
51
  --terminal <nodeId> (run) Repeatable. Scope the run to the upstream closure of
44
52
  these end node(s); omit to run the whole graph.
53
+ --out <dir> (run --wait / status --id) Save the finished run's delivered
54
+ outputs into this directory and print the paths: text as
55
+ .md, storyboards/frame sets as .json, images/video/audio
56
+ downloaded. Unfulfilled slots are reported, not written.
45
57
  --version <n> (export) A real 1-based id from `workflow versions`; a
46
58
  version export carries NO triggers/description.
59
+ --reject <n,..> (checkpoint approve) Only when the paused step ran once
60
+ per item (a Splitter fan-out). Drops those item numbers —
61
+ the "Item N of M" headings in `checkpoint show` — and
62
+ approves the rest. Rejecting every item is refused.
47
63
 
48
64
  Every workflow/session/bank verb takes --json, and that output IS the machine API —
49
65
  there is no separate agent command set.
@@ -53,7 +69,7 @@ Auth: Bearer (EXODUS_API_KEY). The canvas lives on the dashboard at /workflows.
53
69
 
54
70
  # Workflow — Run, Resolve, Build, and Automate Saved Workflows
55
71
 
56
- An Exodus **Workflow** is a saved graph the member builds on the dashboard canvas (`/workflows`): brief and primer sources feed Genesis bots, bots feed transforms, other bots, gates, or an image node, deposits land copy in banks, and an Output node collects the deliverables. This skill is the CLI operator for those graphs — a **peer door** to the same artifact the web canvas edits. Anything you can do to a run in the browser (review a gate, repair a stall, continue a session, promote a winner), you can do here.
72
+ An Exodus **Workflow** is a saved graph the member builds on the dashboard canvas (`/workflows`): brief and primer sources feed Genesis bots, bots feed transforms, formatters, other bots, or an image node, deposits land copy in banks, and an Output node collects the deliverables. This skill is the CLI operator for those graphs — a **peer door** to the same artifact the web canvas edits. Anything you can do to a run in the browser (approve a checkpoint, repair a stall, continue a session, promote a winner), you can do here.
57
73
 
58
74
  **The `--json` contract (read this once).** Every `workflow`, `session`, and `bank` verb takes `--json`, and that structured output **is** the machine API — there is no separate agent command set and no MCP server. Read for humans without it; parse `--json` when you need to chain or branch on the result.
59
75
 
@@ -67,9 +83,9 @@ The end-to-end narrative, every step naming its verb. You rarely walk all of it
67
83
  2. **Validate** — `workflow validate <file>` until it comes back clean (it's `import --dry-run` under its own door).
68
84
  3. **Import** — `workflow import <file>` to create, or `import <file> --update <id>` to edit in place.
69
85
  4. **Describe** — `workflow describe` to confirm inputs, prerequisites (✓/✗ primers), and outputs before spending a run.
70
- 5. **Run** — `workflow run … --wait` (streams progress, prints outputs) or omit `--wait` and poll `workflow status --id <runId>`.
86
+ 5. **Run** — `workflow run … --wait` (streams progress, prints outputs) or omit `--wait` and poll `workflow status --id <runId>`. Inputs come from `--input key=value` flags, from `--fill <name>` (a saved fill — a named, reusable set of launch answers saved from the app's Run dialog; flags win per key when you pass both), or a mix. Launches that don't satisfy the workflow's entry contract are rejected at the door with the gap named — `describe` shows each input's type, required/optional, and accepted values.
71
87
  6. **Notice the park** — a run can stop and wait on you. There are no webhooks in v1; you notice a park one of three ways: `run --wait` prints a pause notice naming the verb to use (the command keeps waiting — resolve from another shell, or Ctrl-C and pick it up via `inbox`), `workflow inbox` lists it, or `workflow status` shows `awaiting-review`.
72
- 7. **Resolve** — `workflow gate <runId> …` (taste review), `workflow repair <runId> …` (a stalled collector), or `workflow answer <runId> …` (a nested workflow's slots).
88
+ 7. **Resolve** — `workflow checkpoint <runId> …` (a step flagged "pause for approval"), `workflow repair <runId> …` (a stalled collector), or `workflow answer <runId> …` (a nested workflow's slots).
73
89
  8. **Harvest** — read deliverables with `workflow status --id <runId> --json`; read banked deposits with `bank show <key>`; keep thinking with a bot via `session chat`.
74
90
  9. **Promote** — `bank promote <key>` lands the winner **and** fires the Winner Flywheel; then check `workflow inbox` for any background run it kicked off.
75
91
 
@@ -93,7 +109,7 @@ npx @aicopycoders/exodus workflow describe "Launch Flow"
93
109
 
94
110
  Accepts a workflow id **or** a name. It reports three things:
95
111
 
96
- - **Inputs** — each free input the run needs: field name, source (`text`, `swipe-ad`, `swipe-bundle`, `organic-url`, `ad-url`), whether it's required, and a description. These are the `--input key=value` pairs you'll pass.
112
+ - **Inputs** — each free input the run needs: field name, source (`text`, `swipe-ad`, `swipe-bundle`, `organic-url`, `ad-url`, `asset`), whether it's required, and a description. These are the `--input key=value` pairs you'll pass. A source of **`asset`** is a **file** input — the workflow wants an actual image, video, audio clip, or document, and describe names which family it takes (e.g. `hero — asset (image), required`).
97
113
  - **Prerequisites** — the per-workspace primer markers the graph's primer nodes depend on, each shown **✓ stored** or **✗ missing** for *this brand*. **If any prerequisite is ✗, warn the user before running** — the run will not produce good output (or will fail) without it. Route them to the `exodus-primer` / `exodus-foundation` skill to fill the gap first, rather than burning a run.
98
114
  - **Outputs** — the deliverables the Output node collects (text or image, with labels), so you know what to expect back.
99
115
 
@@ -101,6 +117,18 @@ Accepts a workflow id **or** a name. It reports three things:
101
117
 
102
118
  Fill every required input. For a free-text brief you can compose a **bespoke brief from in-session research** — the winning angle you just worked out, the swipe you analyzed — and pass it inline (`--input brief="..."`) or, if it's long, write it to a project file and pass it with `@file` (see Chain, below). For URL/swipe sources, pass the value the source expects.
103
119
 
120
+ For an **`asset` (file) input, pass the path to a local file** and the CLI does the uploading for you — mint, push the bytes, register, then start the run with the stored asset:
121
+
122
+ ```bash
123
+ npx @aicopycoders/exodus workflow run "Product Shots" --input hero=./photos/hero.png --wait
124
+ ```
125
+
126
+ A bare path is the argument (a leading `@` is accepted and ignored — a file field never text-expands). Accepted files: **image** PNG/JPEG/WebP/GIF ≤15MB · **video** MP4/MOV/WebM ≤200MB · **audio** MP3/M4A/WAV/OGG ≤50MB · **document** PDF/TXT/MD/DOC/DOCX ≤25MB. **Every file you passed is checked first — all of them — before a single byte moves**, so one bad path can't leave the others half-uploaded; you get every problem in one message and fix them in one edit. If an upload itself dies partway, the message names the asset ids that DID land so you can pass those instead of re-uploading them.
127
+
128
+ There is no file picker and no prompt: a required file input with no `--input` fails immediately, naming the flag to pass — **unless you scoped the run with `--terminal`**, since the file's node may not even be in that slice; there the server decides. If you already uploaded the file on an earlier run, pass the asset id instead of the path and it's relayed as-is.
129
+
130
+ One edge case worth knowing: the CLI reads the workflow's input list before it uploads anything. If that read fails (the server is down or flaking) and any value you passed looks like a file path, the run stops right there rather than sending your path as literal text. Retry, or pass an already-uploaded asset id.
131
+
104
132
  ### Step 4 — Run and collect
105
133
 
106
134
  ```bash
@@ -115,38 +143,81 @@ npx @aicopycoders/exodus workflow status --id <runId>
115
143
 
116
144
  To run only part of a graph, scope it with `--terminal <nodeId>` (repeatable): only nodes feeding the picked end node(s) execute; the rest are recorded out-of-scope.
117
145
 
146
+ **Read the `Deliveries:` block first.** Each Output node on the canvas is a named, typed promise — "Final ad copy · text", "Hero image · asset" — and a finished run answers every one of them: `delivered` with the artifacts underneath, or `unfulfilled` with the reason the upstream node gave. That block is the honest scorecard; the `Outputs:` block below it is the same artifacts as one flat pile, kept for chaining. If a run says `completed` but a slot says `unfulfilled`, the workflow did NOT deliver what it promised — say so.
147
+
148
+ **Save the deliverables to disk with `--out <dir>`** (works on `run --wait` and on `status --id`):
149
+
150
+ ```bash
151
+ npx @aicopycoders/exodus workflow run "Launch Flow" --input brief="new offer" --wait --out ./deliverables
152
+ npx @aicopycoders/exodus workflow status --id <runId> --out ./deliverables
153
+ ```
154
+
155
+ Every delivered slot becomes a file named `<workflow>-<runId tail>-<slot key>.<ext>` — text as `.md`, storyboards and frame sets as `.json`, images/video/audio downloaded from their URLs — and each written path is printed. Unfulfilled slots are reported, never written. The directory is created if it doesn't exist.
156
+
118
157
  Report the outputs plus a short take. Don't call a run "done" off the kickoff line alone — a run reaches `completed`, `partial`, `failed`, or **parks** (`awaiting-review`). A `partial` means some nodes were skipped or failed; a park means it's waiting on you (see the next section). Read `status` before declaring victory.
119
158
 
120
159
  ## 2. Runs that park
121
160
 
122
161
  A run doesn't always finish on its own — it can **park** and wait for a human decision. There are three actionable park kinds:
123
162
 
124
- - **gate** — a taste-review Gate node is holding candidates for your pick/edit/approval.
163
+ - **checkpoint** — any step whose author flipped the "pause for approval" switch has finished, and the run is holding until you approve what it wrote.
125
164
  - **repair** — a require-all collector stalled on a dead input and needs a decision.
126
165
  - **slots** — a nested sub-workflow is waiting on inputs (slot answers).
127
166
 
128
- **No webhooks in v1 — nothing parks silently, but nothing pings you either.** You find parks three ways: `run --wait` prints a pause notice naming the verb to use (a park is not terminal — the command keeps polling so it can resume after you resolve; resolve from another shell, or Ctrl-C and pick it up via `inbox`); `workflow inbox` lists every parked run badged by kind (`gate`/`repair`/`slots`) and how it started (`bg`, `trig:<event>`); or `workflow status` shows `awaiting-review`. **`inbox` is the one place nothing hides** — including background runs a trigger fired or a promote's flywheel kicked off.
167
+ **No webhooks in v1 — nothing parks silently, but nothing pings you either.** You find parks three ways: `run --wait` prints a pause notice naming the verb to use (a park is not terminal — the command keeps polling so it can resume after you resolve; resolve from another shell, or Ctrl-C and pick it up via `inbox`); `workflow inbox` lists every parked run badged by kind (`checkpoint`/`repair`/`slots`; the badge `gate` shows up only on a pre-2.0 run parked at the retired Gate node — those can't be resumed, cancel and run the workflow again) and how it started (`bg`, `trig:<event>`); or `workflow status` shows `awaiting-review`. **`inbox` is the one place nothing hides** — including background runs a trigger fired or a promote's flywheel kicked off.
129
168
 
130
169
  ```bash
131
170
  npx @aicopycoders/exodus workflow inbox
132
171
  ```
133
172
 
134
- ### Gate parks — pick / edit / push / approve / reject
173
+ ### The `gate` verbs retired in 2.0
174
+
175
+ `exodus workflow gate …` no longer does anything: the Gate node it drove was
176
+ removed. Running it prints a pointer at `workflow checkpoint` and exits 1.
177
+
178
+ A run that parked at a Gate box **before** 2.0 is frozen there — it can't be
179
+ resumed, because the step it's waiting on no longer exists. Cancel it and run the
180
+ workflow again:
181
+
182
+ ```bash
183
+ npx @aicopycoders/exodus workflow checkpoint <runId> cancel --reason "legacy gate park"
184
+ ```
185
+
186
+ ### Checkpoint parks — show / edit / approve / retry / cancel
187
+
188
+ A **checkpoint** is the "pause for approval" switch on an ordinary step (any node kind can carry it). The step runs, finishes, and then the whole run stops there until a human signs off — it waits **indefinitely**, so a `run --wait` left unattended simply keeps polling; nothing times out and nothing is lost.
189
+
190
+ There are **no candidates to pick** — there's one step's output to read and either bless, fix, or redo. Outputs are numbered **1-based**:
191
+
192
+ ```bash
193
+ npx @aicopycoders/exodus workflow checkpoint <runId> # show the paused step + its output
194
+ npx @aicopycoders/exodus workflow checkpoint <runId> edit 1 --text "tighter opener" # rewrite an output in place
195
+ npx @aicopycoders/exodus workflow checkpoint <runId> approve --wait # sign off, the run continues
196
+ npx @aicopycoders/exodus workflow checkpoint <runId> retry --wait # redo the step, wait for its fresh output
197
+ npx @aicopycoders/exodus workflow checkpoint <runId> cancel --reason "wrong direction" # stop the run here
198
+ ```
199
+
200
+ - **`edit`** takes `--text`, `--file <path>`, or piped stdin. It does **not** resume the run — edit first, then `approve`.
201
+ - **`retry`** discards what the step produced and runs that one step again. The run parks right back at the *same* checkpoint with the fresh output — that re-park **is** the success. So `retry --wait` finishes as soon as the new output is ready, prints it, and hands you back the same four choices; it does **not** wait for the run to complete (it never would). Review the fresh output and `approve` when you're happy.
202
+ - **`cancel`** stops the run for good, recording your reason — the same audit trail the web uses.
203
+
204
+ #### When the step ran once per item (a Splitter fan-out)
135
205
 
136
- Candidates are numbered **1-based**. Show them, then act:
206
+ If a Splitter (below) sits upstream, the flagged step ran **once per item**, and `show` groups its output under **`Item 1 of 7`**-style headings. The run still parks **once**, after every lane has finished — you review the whole batch in one sitting.
207
+
208
+ The numbering does **not** restart per item: rows are numbered straight through, so `edit 4` still means row 4 wherever it sits. What's new is that you can drop whole items:
137
209
 
138
210
  ```bash
139
- npx @aicopycoders/exodus workflow gate <runId> # show the candidates
140
- npx @aicopycoders/exodus workflow gate <runId> pick 1,3 # keep candidates 1 and 3
141
- npx @aicopycoders/exodus workflow gate <runId> edit 2 --text "punchier hook" # rewrite one in place
142
- npx @aicopycoders/exodus workflow gate <runId> push "make them shorter" # steer the upstream session
143
- npx @aicopycoders/exodus workflow gate <runId> approve --wait # resume the run
144
- npx @aicopycoders/exodus workflow gate <runId> reject --reason "off-brand" # cancel it
211
+ npx @aicopycoders/exodus workflow checkpoint <runId> # grouped by item
212
+ npx @aicopycoders/exodus workflow checkpoint <runId> approve --reject 2 --wait # keep all but item 2
213
+ npx @aicopycoders/exodus workflow checkpoint <runId> approve --reject 2,5 --wait # drop items 2 and 5
145
214
  ```
146
215
 
147
- - **`edit`** takes `--text`, `--file <path>`, or piped stdin for the replacement copy.
148
- - **`push`** sends a steering message into the gate's live chat session and banks a **fresh** candidate from the bot's reply — use it when none of the current candidates are right but the direction is fixable by asking. It does not resume the run; approve when you're happy.
149
- - **`reject`** records the reason on the cancel **identically to the web** — the reason is not cosmetic, it's the same audit trail.
216
+ - `--reject` takes the **item numbers you see** (1-based, comma-separated) — not row numbers, not the 0-based indexes in `--json`.
217
+ - **Rejecting is filtering, not failing.** The dropped items disappear from the batch and the run carries on with the survivors; nothing is marked failed and no warning is raised. Say it that way to the user: "kept 5 of 7."
218
+ - **Approve with no `--reject` keeps everything** — the ordinary approve, unchanged.
219
+ - **Rejecting every item is refused.** If nothing is worth keeping, `cancel` the run — that's the honest action, and the CLI relays the server's message telling you so.
220
+ - `edit <n>` works inside a fan exactly as it does anywhere else; edit the rows you want to fix, then approve (with or without `--reject`).
150
221
 
151
222
  ### Repair parks — retry / skip / kill
152
223
 
@@ -192,9 +263,9 @@ npx @aicopycoders/exodus workflow triggers "Winner Flywheel" fire 1 --text "fake
192
263
 
193
264
  - **`enable`/`disable` flip exactly one switch** — never rewrite a trigger by re-importing the whole contract to toggle it. The CLI reads the live trigger list and sends a **fingerprint** of the trigger's fields as a guard, so if the workflow changed under you the flip **fails loud** rather than hitting the wrong trigger. If it fails, re-list and retry.
194
265
  - **Add or remove triggers by editing the YAML export** (they live in the head export) and re-importing — enable/disable never adds or removes, only toggles.
195
- - **`fire`** starts a **real** background run on the workflow owner's keys. An event trigger's `fire` needs `--text` (the input the event would carry); a cron trigger's `fire` rejects `--text`. Its gates park into the **inbox** like any other run.
266
+ - **`fire`** starts a **real** background run on the workflow owner's keys. An event trigger's `fire` needs `--text` (the input the event would carry); a cron trigger's `fire` rejects `--text`. Its checkpoints park into the **inbox** like any other run.
196
267
 
197
- **The ruled test flow:** author the workflow with the trigger **disabled** → `fire <n> --text "fake winner copy"` to dry-fire it and watch the run (gates land in the inbox) → `enable <n>` once you're happy with what it produces.
268
+ **The ruled test flow:** author the workflow with the trigger **disabled** → `fire <n> --text "fake winner copy"` to dry-fire it and watch the run (checkpoints land in the inbox) → `enable <n>` once you're happy with what it produces.
198
269
 
199
270
  ## 5. Banks + the flywheel
200
271
 
@@ -241,12 +312,16 @@ Cold-start is a **template, not a blank file** — there is no `workflow new`. L
241
312
  ```bash
242
313
  npx @aicopycoders/exodus workflow bots # full catalog, grouped by category
243
314
  npx @aicopycoders/exodus workflow bots --slug new-hook-bot # one bot's full port + param spec
244
- npx @aicopycoders/exodus workflow schema # LIVE node kinds, ports, config rules, faces, gate/wiring rules
315
+ npx @aicopycoders/exodus workflow schema # LIVE node kinds, ports, config rules, faces, wiring rules
245
316
  npx @aicopycoders/exodus workflow schema --kind transform # one node kind
317
+ npx @aicopycoders/exodus workflow schema --kind formatter # the Formatter's two modes, bounds, and notes
318
+ npx @aicopycoders/exodus workflow schema --kind splitter # the Splitter's three modes, the item cap, and the lane rules
246
319
  npx @aicopycoders/exodus workflow schema --face splitter # one transform face's ports + spec
247
320
  ```
248
321
 
249
- Per bot you get its **input ports** (id, what it `accepts` — `text` / `primer` / `image`, whether it's required, and for primer ports which `primerKinds` gate it), its **params**, and its output type. `workflow schema` prints the **live** graph grammar from the backend you're deployed against — node kinds, port ids, config rules, transform faces, gate policies, wiring rules — so what you author matches what will validate. This is always current; pull it fresh rather than trusting memory.
322
+ Per bot you get its **input ports** (id, what it `accepts` — `text` / `primer` / `image`, whether it's required, and for primer ports which `primerKinds` gate it), its **params**, and its output type. `workflow schema` prints the **live** graph grammar from the backend you're deployed against — node kinds, port ids, config rules, transform faces, collector policy, wiring rules — so what you author matches what will validate. This is always current; pull it fresh rather than trusting memory.
323
+
324
+ **Faces now carry a `retired` flag.** `schema` (and `schema --face <face>`) reports it per face. Five faces are retired — `parser` (Parser/Extractor), `edit-shorten` (Shorten), the old `formatter` reshaper, and (newest) `splitter` (Splitter (Text)) and `decomposer`. **Retired means "no new ones," not "broken":** they still validate, still run, and every existing graph that names one keeps working unchanged, so never rewrite a member's graph to get rid of one. Just don't author a fresh one — use the `formatter` node kind instead of `parser`/`formatter`, and the `splitter` **node kind** instead of the `splitter`/`decomposer` faces (both below). The ad-hoc edit-pass buttons on finished run results, Shorten included, are a different surface and are unaffected.
250
325
 
251
326
  ### Step 2 — Start from a template
252
327
 
@@ -265,7 +340,7 @@ The contract is `nodes` (each with `id`, `kind`, a `config`, and optional `posit
265
340
  npx @aicopycoders/exodus workflow validate my.yaml
266
341
  ```
267
342
 
268
- `validate` is the front door; `import --dry-run` is the same check under a different door. It **needs network + login** — there is no offline validation, ever. It returns compiler-style issues, each naming the **node id**, the **port id** where relevant, and a concrete **remedy**. Codes include `dangling-edge`, `unknown-port`, `type-mismatch`, `missing-required-input`, `duplicate-input`, `cycle`, `unknown-kind`, and `bad-config`. Fix, re-run `validate`, repeat until clean.
343
+ `validate` is the front door; `import --dry-run` is the same check under a different door. It **needs network + login** — there is no offline validation, ever. It returns compiler-style issues, each naming the **node id**, the **port id** where relevant, and a concrete **remedy**. Codes include `dangling-edge`, `unknown-port`, `type-mismatch`, `missing-required-input`, `duplicate-input`, `cycle`, `unknown-kind`, `bad-config`, and the three Splitter lane rules `nested-split`, `lane-ineligible`, and `fan-overlap`. Fix, re-run `validate`, repeat until clean.
269
344
 
270
345
  ### Step 4 — Import (create or update)
271
346
 
@@ -278,16 +353,131 @@ Then **tell the user to review it on the canvas at `/workflows`** — the CLI wr
278
353
 
279
354
  **The 409 rule.** `--update` sends the export's `updatedAt` as an optimistic-concurrency guard. If the workflow moved on the dashboard since your export, the import 409s. Re-export the current workflow, reapply your edits onto the fresh contract, and import again — never force past a 409, or you'll clobber their change.
280
355
 
356
+ ### The `formatter` node — guarantee the shape of what comes out
357
+
358
+ Reach for a `formatter` node whenever the next step needs a **known shape**: a rendered layout, or named fields split onto their own wires. Two modes on one kind, chosen by `config.mode`. Its ports are **derived from its config** in both modes, so edit the config and the handles change — re-check your edges after any edit.
359
+
360
+ **`mode: template`** — deterministic, no model call. Write a layout with `{{variable}}` slots; **each distinct slot becomes a required `text` input port named for the variable**, and the single output port is `formatted`. Same inputs → identical output, every time. A slot that gets nothing renders empty and warns; it never fails the run.
361
+
362
+ ```yaml
363
+ - id: assemble
364
+ kind: formatter
365
+ config:
366
+ mode: template
367
+ template: |
368
+ HOOK: {{hook}}
369
+
370
+ {{body}}
371
+
372
+ CTA: {{cta}}
373
+ ```
374
+
375
+ Ports: inputs `hook`, `body`, `cta` (all required, one wire each); output `formatted`.
376
+
377
+ **`mode: extract`** — the AI reads loose text, the node **validates the answer against your declaration**. Declare 1–12 fields; **each field key becomes its own output port**, and the one input port is `source` (required, accepts many wires — their texts are joined). A `list` field emits **one artifact per element** on its port, so downstream nodes get hooks one at a time, not one blob.
378
+
379
+ ```yaml
380
+ # Winner ad text in → hooks, offer, and a mechanism line out, each on its own wire.
381
+ - id: mine-winner
382
+ kind: formatter
383
+ config:
384
+ mode: extract
385
+ temperature: 0.2 # 0..1, default 0.2
386
+ maxRetries: 2 # 0..3, default 2 → up to 3 model attempts
387
+ fields:
388
+ - { key: hooks, label: Hooks, type: list, required: true }
389
+ - { key: offer, label: Offer, type: text, required: true }
390
+ - { key: mechanism, label: Mechanism, type: text, required: false }
391
+ ```
392
+
393
+ Ports: input `source`; outputs `hooks`, `offer`, `mechanism`. Wire `sourceHandle: hooks` into a hook-consuming bot.
394
+
395
+ **The contract that makes it worth using: extract mode never emits a wrong shape.** If the reply isn't a JSON object with exactly the declared keys, correctly typed, and non-empty where you marked `required`, the node re-asks the model with the exact violations attached. `maxRetries` counts those re-asks *after* the first attempt (default `2` = up to 3 attempts). When the budget is spent the node **fails the run** with the last answer and its errors on the failure note — it never passes nonconforming output downstream. Tell the user that plainly when an extract node fails: the model couldn't produce the declared shape, so the node refused to guess.
396
+
397
+ **Field rules** (each row needs all three of `key`/`type`/`required`): `key` is letters, digits, spaces, underscores, hyphens, ≤40 chars, unique in the list; `type` is `text`, `number`, or `list`; `required` must be an explicit `true`/`false`. `label` is optional and defaults to the key.
398
+
399
+ ### The `splitter` node — one thing in, N parallel lanes out
400
+
401
+ A **Splitter** turns one thing into many. Feed it a list (or text a model can cut into pieces) and it emits N **items** — and here is the part that matters: **every box after it runs once per item.** Five items means five copies of the same downstream chain running side by side, like five assembly lines. Each piece of work knows its own number, so the run view and the CLI can say "Item 3 of 7".
402
+
403
+ It has the same ports in every mode: one required input `source` (it accepts several wires), one output `items`. Three modes, picked by `config.mode`:
404
+
405
+ - **`structural`** — no model call. Whatever artifacts arrive on `source` **are** the items. This is the natural partner of a Formatter `list` field, which already emits one artifact per element. (A single artifact whose text is a JSON list of strings gets exploded too.)
406
+ - **`rule`** — no model call. Joins the incoming text and cuts it on a `delimiter`: `newline`, `blank-line`, `numbered` (1. 2. 3. list markers), or `custom` plus a `customPattern` (a **literal** separator, not a regular expression).
407
+ - **`semantic`** — the only mode that calls a model. Your `instruction` says what counts as one item ("separate this into ad concepts"); the reply must be a clean list, and a bad one is re-asked before the node gives up.
408
+
409
+ ```yaml
410
+ - id: split-modules
411
+ kind: splitter
412
+ config:
413
+ mode: semantic
414
+ instruction: "Break this winning ad into its reusable modules — hook, mechanism, proof, offer, CTA. One module per item."
415
+ maxItems: 25
416
+ ```
417
+
418
+ **`maxItems` is a hard cap and it is required in every mode** (default 25, raise it to at most 100 deliberately — you cannot remove it). Going over the cap **fails the node loudly** with the real count ("Splitter produced 61 items; cap is 25"). It never quietly trims the list: silent data loss is worse than a visible failure. Producing **zero** items fails too, for the same reason.
419
+
420
+ **Reviewing a fan item by item.** If a step inside the fan has the "pause for approval" switch on, the run parks once, after all the lanes finish, and you approve or reject **each item**. Rejecting is filtering, not failing — see "When the step ran once per item" above.
421
+
422
+ **Three rules the validator enforces — all three scoped to a Splitter's OPEN fan** (the chain running once per item, which ends at the Collector that closes it):
423
+
424
+ - **No split inside an open fan** (`nested-split`) — a Splitter can't sit inside another Splitter's open fan. An artifact carries exactly one item number, so items-of-items has nowhere to live. The way through is **sequential**: close the first fan with a Collector, then split the combined result again. That is fully legal.
425
+ - **One open fan per node** (`fan-overlap`) — no node may sit inside two different Splitters' open fans at once. Their item numbering is unrelated, so the lanes can't merge mid-fan. Two fans that have **each closed through their own Collector** may merge downstream freely.
426
+ - **Lane eligibility** (`lane-ineligible`) — every node inside the open fan must be a `bot`, `formatter`, `transform`, or `output` node. Video nodes, Call nodes, and the video member-gate kinds are blocked, because running them once per item would corrupt state they keep per node. The `collector` kind is **exempt** (it ends the lane rather than running in it), and **after** a Collector any kind at all is allowed again — video, Call, all of it.
427
+
428
+ **One lane failing doesn't sink the batch.** If a lane exhausts its retries, that item drops out and the others carry on; the step ends `done` with a warning naming the casualties. Only *every* item failing fails the step. (A Collector downstream can be told to be stricter than that — see the next section.)
429
+
430
+ **The old cards are gone, the old graphs are fine.** The `splitter` (Splitter (Text)) and `decomposer` **transform faces** are retired — they put N snippets on one wire and ran downstream **once**, which is a different thing entirely. Existing workflows that use them keep running exactly as before, and there is no migration; just don't author new ones. The Winner Flywheel starter has been rebuilt on the Splitter node, so its Output step now files each module separately.
431
+
432
+ **Blocks Run** (`bad-config`, so `validate` catches it): no/unknown `mode`; template mode with an empty template, over 20000 characters, or more than 12 distinct slots; extract mode with fewer than 1 or more than 12 fields, a bad/duplicate key, a bad type, a non-boolean `required`, a `temperature` outside 0..1, or a `maxRetries` outside 0..3. A template slot with no wire into it is `missing-required-input` — same block, different code.
433
+
434
+ ### The `collector` node — close the lanes
435
+
436
+ A **Collector** is the Splitter's other half. Where a Splitter opens N lanes, a Collector waits for all of them and folds them back into **one** result. That result carries no item number, which is the whole point: **after a Collector the run is ordinary again.** Anything may follow it, you may split again, and two fans that each closed through their own Collector may merge.
437
+
438
+ Its ports are fixed in every mode: one required input `items` (a whole fan lands on that single port), one output `combined`. Three modes, picked by `config.mode`:
439
+
440
+ - **`assemble`** — no model call. Stitches the surviving items into one block, in order, joined by `separator` (`newline`, `blank-line`, or `custom` plus a literal `customSeparator`; newline if you say nothing). An optional `itemTemplate` wraps each item, with the slots `{{item.value}}`, `{{item.index}}` (1-based), and `{{item.total}}`.
441
+ - **`list`** — no model call. Emits one artifact whose text is JSON: the items with their numbers, a count, and what dropped out. Use it when the next step wants structure, not prose. It is the only mode that puts the dropped detail *in* the artifact.
442
+ - **`synthesize`** — the only mode that calls a model. Your `instruction` says what the merge should produce; the model writes one new piece out of every item. The model itself is sealed (you get `temperature` and `maxRetries`, not a model pick).
443
+
444
+ ```yaml
445
+ - id: fold-modules
446
+ kind: collector
447
+ config:
448
+ mode: assemble
449
+ separator: blank-line
450
+ itemTemplate: |
451
+ ### Module {{item.index}} of {{item.total}}
452
+ {{item.value}}
453
+ onBranchFailure: survivors
454
+ minItems: 2
455
+ ```
456
+
457
+ **The lane policies ride every mode.** `onBranchFailure: survivors` (the default) carries on with whatever made it through and warns you who didn't; `strict` stops the run right there if any lane broke. `minItems` (default 1, up to 100) is a floor that applies under **both** policies — fewer survivors than that and the node fails, so a fan where everything died can never quietly hand you an empty result. `ordering` is `index` (the Splitter's original order, the default) or `arrival` (whichever lane finished first).
458
+
459
+ **A rejection is never a failure.** An item the user rejected at a checkpoint was filtered on purpose, so it never trips `strict` — it only counts against `minItems` by not being there. The node's warning spells the difference out, and it's the sentence to quote back to a user asking why the run kept going: *"Collected 7 of 10 items — items 2 and 5 rejected at your checkpoint; item 8 failed."*
460
+
461
+ **Every wire into a Collector must come from inside ONE Splitter's open fan** (`collector-unpaired`, blocks Run) — the Splitter itself or a node running in its lanes. A Collector with nothing split upstream has no fan to close, and a Brief wired straight into one is merging unrelated branches, which is a Formatter's job in template mode, not a Collector's.
462
+
463
+ **The old card is gone, the old graphs are fine.** The `collector` **transform face** is retired — it compiled whatever text arrived on merged wires and ran once, which is a different job from closing a lane fan. Existing workflows using the face keep running unchanged and there is no migration; author the `collector` **node** instead. Note the vocabulary split while you're at it: the node reads `onBranchFailure`, and the legacy `onFailedInput` key is rejected on it outright.
464
+
465
+ **Blocks Run** (`bad-config`): no/unknown `mode`; assemble mode with an illegal `separator`, an empty or over-200-character `customSeparator` on `custom`, or an `itemTemplate` over 4000 characters; synthesize mode with an empty or over-2000-character `instruction`, a `temperature` outside 0..1, or a `maxRetries` outside 0..3; any mode with an illegal `onBranchFailure`/`ordering`, a `minItems` outside 1..100, or a stray `onFailedInput`.
466
+
281
467
  ### Authoring gotchas
282
468
 
283
469
  A hand-picked list of the traps that bite first. `workflow schema` is authoritative on any conflict with what's written here:
284
470
 
285
471
  - **Edge handles are port ids** — `sourceHandle`/`targetHandle` name a **port**, not a node id or a label. An edge only connects if the target port `accepts` the source port's type.
286
472
  - **Required inputs hard-block** — a run refuses to start until every required input is supplied; there's no "run anyway".
287
- - **Transform faces are sealed** — each face has fixed, per-face **output port ids** you must wire from by name; `workflow schema --face <face>` prints them.
288
- - **`session` outputs are single-consumer** — a session-typed output feeds exactly one downstream edge; a Push node's `last`/`all` output ports fan out to many consumers freely.
473
+ - **Transform faces are sealed** — each face has fixed, per-face **output port ids** you must wire from by name; `workflow schema --face <face>` prints them, along with whether the face is `retired`.
474
+ - **Don't author a retired face** — `parser`, `edit-shorten`, the old `formatter` face, `splitter` / `decomposer`, and now `collector` are no longer offered; author a `formatter` node (parser/formatter), a `splitter` node (splitter/decomposer), or a `collector` node instead. Existing graphs that use them keep running, so leave those alone.
475
+ - **The fan rules are scoped to the OPEN fan** — inside a Splitter's open fan (the chain up to the Collector that closes it) you may not place another Splitter (`nested-split`), every node must be a `bot`, `formatter`, `transform`, or `output` (`lane-ineligible`), and no node may sit in two Splitters' open fans at once (`fan-overlap`). Close the fan with a Collector and all three restrictions lift: split again, run any kind, merge two collected fans.
476
+ - **A Collector must be paired** — every wire into a `collector` node has to come from inside one Splitter's open fan, or it's `collector-unpaired` and the run is blocked. To merge branches that were never split, use a Formatter in template mode.
477
+ - **A Formatter's handles come from its own config** — template slots name its inputs, field keys name its outputs. Change the config and old edges go `unknown-port`.
478
+ - **`session` outputs have nowhere to wire** — a session-typed output exists so you can re-open the chat from the run page; no node kind accepts a session wire.
289
479
  - **Deposit shapes are strict** — a deposit's config must match the target bank's expected shape exactly, or import rejects it.
290
- - **Empty defaults are deliberately invalid** — `rig`, `call`, `transform`, and `bot` nodes ship with empty config placeholders that fail validation on purpose; you MUST fill them, not leave them.
480
+ - **Empty defaults are deliberately invalid** — `rig`, `call`, `transform`, `bot`, and `formatter` nodes ship with empty config placeholders that fail validation on purpose; you MUST fill them, not leave them.
291
481
  - **Quote YAML that looks numeric or boolean** — `"true"`, `"3.0"`, `"01"` need quotes or the parser coerces them to the wrong type.
292
482
  - **A 409 on `--update` means re-export first** — reapply your edits onto the fresh contract; don't force.
293
483
 
@@ -325,13 +515,13 @@ The same contract is on the v2 API, so a script can skip the CLI:
325
515
  - **Export:** `GET /api/v2/workflows/export?id=<workflowId>&format=yaml` returns the canonical YAML (`text/yaml`); drop `format` (or `format=json`) for the legacy JSON body.
326
516
  - **Import:** `POST /api/v2/workflows/import`. A JSON object body carries the contract *plus* the control fields inline (`dryRun`, `targetWorkflowId`, `expectedUpdatedAt`). A raw **YAML** body is the contract text alone — pass the control fields as **query params** instead (`?dryRun=true&targetWorkflowId=<id>&expectedUpdatedAt=<ts>`).
327
517
 
328
- The gate/inbox/session/bank verbs have v2 routes too, but the CLI is the maintained path — reach for HTTP only when you're already scripting, and prefer the CLI's `--json` output over hand-rolling routes. Both are Bearer-auth'd (`EXODUS_API_KEY`) like every v2 route.
518
+ The inbox/checkpoint/session/bank verbs have v2 routes too, but the CLI is the maintained path — reach for HTTP only when you're already scripting, and prefer the CLI's `--json` output over hand-rolling routes. Both are Bearer-auth'd (`EXODUS_API_KEY`) like every v2 route.
329
519
 
330
520
  ## Failure handling
331
521
 
332
522
  - **`describe` shows a ✗ prerequisite** — don't run yet. Fill the primer via `exodus-primer` / `exodus-foundation`, then re-check `describe`.
333
523
  - **Run comes back `partial` or `failed`** — read `status --id <runId>` (drop `--json` for a readable view) to see which node failed and why **before re-running**. A partial still has usable outputs; harvest them first.
334
- - **A verb refuses because the run isn't in that state** — the gate/repair/answer verbs preflight the run and name its **actual** state (e.g. "parked for repair" when you tried a gate verb). Read the state it reports and switch to the matching verb, or check `inbox` for what's actually pending.
524
+ - **A verb refuses because the run isn't in that state** — the checkpoint/repair/answer verbs preflight the run and name its **actual** state (e.g. "parked for repair" when you tried a checkpoint verb). Read the state it reports and switch to the matching verb, or check `inbox` for what's actually pending.
335
525
  - **Trigger fingerprint mismatch** — the workflow changed since you listed its triggers. Re-list (`workflow triggers <wf>`) and retry the enable/disable/fire against the fresh numbering.
336
526
  - **`validate` / `import` errors** — fix the exact node/port each issue names using its remedy; don't guess. `validate` needs network + login — there is no offline check.
337
527
  - **`import --update` 409** — re-export, reapply, re-import. This is a conflict, not a bug.
@@ -1,5 +1,6 @@
1
1
  import { apiGet } from "../lib/client.js";
2
2
  import { formatBrowse, formatError } from "../lib/format.js";
3
+ import { isActiveRunStatus } from "../lib/runStatus.js";
3
4
  export const helpText = `
4
5
  exodus browse — List recent pipeline runs (hooks, ads, image concepts, etc.)
5
6
 
@@ -119,7 +120,7 @@ export async function run(flags) {
119
120
  ? all
120
121
  : all.filter((g) => {
121
122
  const status = g["status"];
122
- if (status !== "running" && status !== "pending")
123
+ if (!status || !isActiveRunStatus(status))
123
124
  return true;
124
125
  const created = normalizeCreatedAt(g);
125
126
  return created > 0 && now - created < STUCK_THRESHOLD_MS;
@@ -1,5 +1,5 @@
1
1
  import { apiGet, apiGetDashboard, apiPost, getDashboardUrl, } from "../lib/client.js";
2
- import { formatError } from "../lib/format.js";
2
+ import { displayRunStatus, formatError } from "../lib/format.js";
3
3
  export const helpText = `
4
4
  exodus creative — Creative-suite engines (native, copy-derived, ref-match)
5
5
 
@@ -165,7 +165,7 @@ async function runStatus(flags) {
165
165
  console.log(`name: ${d.name}`);
166
166
  if (d.engine)
167
167
  console.log(`engine: ${d.engine}`);
168
- console.log(`status: ${d.status ?? "—"}${d.isTerminal ? " (terminal)" : ""}`);
168
+ console.log(`status: ${displayRunStatus(d.status, "—")}${d.isTerminal ? " (terminal)" : ""}`);
169
169
  if (d.completedImages !== undefined || d.totalImages !== undefined) {
170
170
  console.log(`progress: ${d.completedImages ?? 0} / ${d.totalImages ?? "?"} completed${d.failedImages ? `, ${d.failedImages} failed` : ""}`);
171
171
  }
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
5
5
  import { pollUntilDone } from "../lib/poll.js";
6
- import { formatGenesisRun, formatError } from "../lib/format.js";
6
+ import { displayRunStatus, formatGenesisRun, formatError, tickerRunStatus } from "../lib/format.js";
7
7
  import { formatCcCommand } from "../lib/cc-command.js";
8
8
  import { resolveActiveBrand } from "../lib/layout.js";
9
9
  import { captureReelAndWrite } from "../lib/reel-write.js";
@@ -441,7 +441,7 @@ async function waitForGenesisRun(runId) {
441
441
  const status = data["status"];
442
442
  const currentStep = data["currentStep"];
443
443
  if (status) {
444
- process.stdout.write(`\r ${status}${currentStep ? ` — ${currentStep}` : ""} `);
444
+ process.stdout.write(`\r ${tickerRunStatus(status)}${currentStep ? ` — ${currentStep}` : ""} `);
445
445
  }
446
446
  },
447
447
  });
@@ -515,7 +515,7 @@ async function runHooks(flags) {
515
515
  process.exit(1);
516
516
  }
517
517
  if (res.data.status !== "awaiting_hook_selection") {
518
- console.log(`Run ${runId} is not awaiting hook selection (status: ${res.data.status ?? "unknown"}).`);
518
+ console.log(`Run ${runId} is not awaiting hook selection (status: ${displayRunStatus(res.data.status)}).`);
519
519
  return;
520
520
  }
521
521
  const pool = Array.isArray(res.data.hookPool) ? res.data.hookPool : [];
@@ -677,7 +677,7 @@ async function pollScrape(runId, noWait, timeoutMs) {
677
677
  ? ` captured=${captured ?? 0} qualified=${qualified ?? 0}`
678
678
  : "";
679
679
  if (status)
680
- process.stdout.write(`\r status: ${status}${counts} `);
680
+ process.stdout.write(`\r status: ${tickerRunStatus(status)}${counts} `);
681
681
  },
682
682
  });
683
683
  console.log();
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { apiGetDashboard, apiPostDashboard } from "../lib/client.js";
4
4
  import { promptChoice, promptMultiline, openInEditor } from "../lib/prompts.js";
5
+ import { normalizeRunStatus } from "../lib/runStatus.js";
5
6
  import { refreshBrandProfileMd } from "./brand.js";
6
7
  export const helpText = `
7
8
  exodus primer — Build a brand's primer (the single foundation step)
@@ -152,7 +153,7 @@ async function buildPrimer(submission) {
152
153
  process.stdout.write("·");
153
154
  continue;
154
155
  }
155
- if (data.status === "failed") {
156
+ if (normalizeRunStatus(data.status) === "failed") {
156
157
  console.log("");
157
158
  fail(`primer build failed: ${data.error ?? "unknown error"}`);
158
159
  }
@@ -1,5 +1,5 @@
1
1
  import { apiGet } from "../lib/client.js";
2
- import { formatGeneration, formatGenesisRun, formatError } from "../lib/format.js";
2
+ import { formatGeneration, formatGenesisRun, formatError, displayRunStatus } from "../lib/format.js";
3
3
  import { hydrateScoutIdeasCount } from "../lib/scout-hydrate.js";
4
4
  import { formatIntelResult as formatIntelResultRich } from "../lib/intel-format.js";
5
5
  export const helpText = `
@@ -31,7 +31,7 @@ export function formatIntelResult(data) {
31
31
  export function formatPulseResult(data) {
32
32
  const lines = [];
33
33
  lines.push("## Pulse Status");
34
- lines.push(`**Status:** ${data["status"] ?? "unknown"}`);
34
+ lines.push(`**Status:** ${displayRunStatus(data["status"])}`);
35
35
  if (data["accountId"])
36
36
  lines.push(`**Account ID:** ${data["accountId"]}`);
37
37
  if (data["commentsAnalyzed"] !== undefined)
@@ -61,7 +61,7 @@ export function formatPulseResult(data) {
61
61
  export function formatScoutResult(data) {
62
62
  const lines = [];
63
63
  lines.push("## Scout Status");
64
- lines.push(`**Status:** ${data["status"] ?? "unknown"}`);
64
+ lines.push(`**Status:** ${displayRunStatus(data["status"])}`);
65
65
  const pipelineSlug = data["pipelineSlug"];
66
66
  if (pipelineSlug)
67
67
  lines.push(`**Pipeline:** ${pipelineSlug}`);
@@ -123,7 +123,7 @@ export function formatScoutResult(data) {
123
123
  export function formatCreativeSuiteStatus(title, data) {
124
124
  const lines = [];
125
125
  lines.push(`## ${title}`);
126
- lines.push(`**Status:** ${data["status"] ?? "unknown"}`);
126
+ lines.push(`**Status:** ${displayRunStatus(data["status"])}`);
127
127
  const show = (label, key) => {
128
128
  const v = data[key];
129
129
  if (v !== undefined && v !== null && v !== "")
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { apiGet, apiPost } from "../lib/client.js";
3
- import { formatError } from "../lib/format.js";
3
+ import { displayRunStatus, formatError } from "../lib/format.js";
4
4
  export const helpText = `
5
5
  exodus swipe — Swipe pipeline lifecycle + competitor watchlist
6
6
 
@@ -121,7 +121,7 @@ async function runRunPipeline(flags) {
121
121
  return;
122
122
  }
123
123
  console.log(`Run started: ${res.data.runId}`);
124
- console.log(` status: ${res.data.status ?? "selecting"}`);
124
+ console.log(` status: ${displayRunStatus(res.data.status, "Running")}`);
125
125
  if (res.data.triggerRunId)
126
126
  console.log(` triggerRunId: ${res.data.triggerRunId}`);
127
127
  console.log(`\nPoll: exodus swipe status ${res.data.runId}`);
@@ -224,13 +224,13 @@ async function runHistory(flags) {
224
224
  for (const r of runs) {
225
225
  const when = r.startedAt ? new Date(r.startedAt).toLocaleString() : "?";
226
226
  const doc = r.docUrl ? ` doc=${r.docUrl}` : r.error ? ` error=${r.error}` : "";
227
- console.log(` ${r.runId} ${r.status} ads=${r.adsSelectedCount} by=${r.triggeredBy} ${when}${doc}`);
227
+ console.log(` ${r.runId} ${displayRunStatus(r.status, "—")} ads=${r.adsSelectedCount} by=${r.triggeredBy} ${when}${doc}`);
228
228
  }
229
229
  }
230
230
  function printRunStatus(r) {
231
231
  console.log(`Run ${r.runId ?? "?"}`);
232
232
  console.log(` brand: ${r.targetBrandSlug ?? "?"}`);
233
- console.log(` status: ${r.status ?? "?"}`);
233
+ console.log(` status: ${displayRunStatus(r.status, "?")}`);
234
234
  console.log(` by: ${r.triggeredBy ?? "?"}`);
235
235
  console.log(` ads: ${r.adsSelectedCount ?? 0}`);
236
236
  if (r.startedAt)