@aicopycoders/exodus 2026.8.1400 → 2026.8.1600

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.
@@ -7,8 +7,8 @@ description: Generate meme-style ad creative for the active brand — recommends
7
7
  Subcommands (three; the batch renders server-side):
8
8
  exodus meme recommend --brief "<text>" [--avatar "<text>"]
9
9
  exodus meme run --brief "<text>" --formats '<json>' [--formats-file <path>] [--avatar "<text>"] [--name "<label>"]
10
- exodus meme regenerate --brief "<text>" --layer 1 --template-id <id> --template-name "<name>" --boxes <N>
11
- exodus meme regenerate --brief "<text>" --layer 2|3 --format <formatId> [--hint "<text>"]
10
+ exodus meme regenerate --brief "<text>" --layer 1 --template-id <id> --template-name "<name>" --boxes <N> [--id <runId>]
11
+ exodus meme regenerate --brief "<text>" --layer 2|3 --format <formatId> [--hint "<text>"] [--id <runId>]
12
12
 
13
13
  Flow:
14
14
  1. recommend → { recommendations: [...] } — 15 picks (5 per layer), each with
@@ -20,9 +20,13 @@ Flow:
20
20
  3. Poll: exodus workflow status --id <runId>
21
21
  Memes render through the Image Rig, so a meme run IS a workflow run — the
22
22
  workflow verb is the one that can read it, NOT `status --type creative`.
23
- 4. regenerate → re-render one miss, synchronously (returns image_url). Do not
24
- pass its optional --id the run id from step 2 — that flag takes a
25
- creative-suite run id, a different id space.
23
+ 4. regenerate → re-render one miss, synchronously (returns image_url). Pass
24
+ --id <runId from step 2> so the fresh meme is saved with that run; leave it
25
+ off and you only get a URL that is never saved. A bad --id is a 400 that
26
+ names the mismatch, raised before anything is rendered — but 400 is not the
27
+ only refusal: a --template-id / --format that isn't a real format is a 404,
28
+ and a lookup or render that breaks is a 502. Read the message, not just the
29
+ status.
26
30
 
27
31
  Keys (strict BYOK, refused server-side before anything starts):
28
32
  layer 1 needs the member's Imgflip login · layers 2/3 need their Kie.ai key ·
@@ -135,18 +139,54 @@ the recommend output you're still holding:
135
139
  ```bash
136
140
  # Classic (layer 1) miss:
137
141
  npx @aicopycoders/exodus meme regenerate --brief "<brief>" --layer 1 \
138
- --template-id <imgflip_template_id> --template-name "<name>" --boxes <imgflip_box_count>
142
+ --template-id <imgflip_template_id> --template-name "<name>" --boxes <imgflip_box_count> \
143
+ --id <runId from step 2>
139
144
 
140
145
  # AI (layer 2/3) miss — --hint steers the re-roll:
141
146
  npx @aicopycoders/exodus meme regenerate --brief "<brief>" --layer 2 --format <format_id> \
142
- --hint "make the punchline land the product"
147
+ --hint "make the punchline land the product" --id <runId from step 2>
143
148
  ```
144
149
 
145
150
  Regenerate is synchronous (caption + render server-side) and returns the new
146
- `image_url` directly. **Do not pass the run id from step 2 to `--id`** — that
147
- flag attaches the fresh meme to a *creative-suite* run, and since memes moved
148
- onto the Image Rig the id `run` prints is a workflow run id, a different id
149
- space. Omit `--id` and hand the user the returned URL.
151
+ `image_url` directly.
152
+
153
+ **Pass `--id` the runId from step 2** (#1164). The server matches it to the run's
154
+ child for that format and files the fresh meme there, so it lands in the library
155
+ beside its siblings and the user finds it where they expect. Both id spaces are
156
+ accepted — the workflow run id `run` prints and a creative-suite child run id.
157
+
158
+ **What `--id` now guarantees is the front door: a bad id is refused before you
159
+ pay for anything.** The server resolves the id first, so a typo, an id from
160
+ another brand, a run that made no memes, or a run with no meme in *this* format
161
+ comes back as a 400 naming the mismatch — no caption call, no render, no credits
162
+ spent. That is the promise; it is not a promise that nothing can go wrong after
163
+ it.
164
+
165
+ The refusals are not all 400s, so branch on the message, not the number alone:
166
+
167
+ - **400** — the id didn't resolve (the message says which part didn't match), or
168
+ a required key is missing, or the brand couldn't be worked out ("No active
169
+ workspace for user"). Careful if you're reading the JSON envelope's `code`
170
+ rather than the status: that last one is labelled `INTERNAL_ERROR` even though
171
+ it is a plain 400, so route on the **status + message**, not the code.
172
+ - **404** — the `--template-id` (layer 1) or `--format` (layer 2/3) isn't a
173
+ format the server knows. Re-check it against the recommend output you're
174
+ holding.
175
+ - **429** — too many regenerates too fast (30 a minute). Wait and retry; the
176
+ response carries a `Retry-After`.
177
+ - **502** — something broke rather than being refused: the run lookup failed, or
178
+ the caption/render leg threw. This one is worth retrying; a 400 or 404 is not.
179
+
180
+ **One honest gap once the id resolves.** Filing the finished meme into the
181
+ library is deliberately allowed to fail quietly — if that write breaks (a Convex
182
+ hiccup), the route still answers **200** with the provider's own URL. So a 200
183
+ means "here is your image", not "it is saved". If the user later can't find a
184
+ regenerated meme in the library, that is the failure to suspect — and it is
185
+ recoverable only by regenerating, since the returned URL expires.
186
+
187
+ Leaving `--id` off is still valid and still returns the image URL, but that image
188
+ is saved nowhere and the URL expires — only do it when the user explicitly wants
189
+ a throwaway look.
150
190
 
151
191
  ### 5. Report
152
192
 
@@ -191,6 +231,16 @@ internals into chat.
191
231
  per-image provenance as any other rig render. That is why the poll verb is
192
232
  `exodus workflow status`, not `exodus status --type creative` — the creative
193
233
  status route reads `creativeSuiteRuns` for a Bearer caller.
234
+ - Since #1164 `regenerate`'s `--id` accepts BOTH id spaces. The route resolves it
235
+ instead of casting it: a `creativeSuiteRuns` id is used as-is, a `workflowRuns`
236
+ id is walked to the rig child whose `memeFormat` matches this request, and an
237
+ id it can't place is a 400 raised BEFORE any caption or render is paid for.
238
+ Before that fix a CLI `--id` silently failed to save and left an orphaned blob.
239
+ What #1164 removed is the CAUSE, not the swallow: `persistMemeImage` still
240
+ catches a `recordMemeImage` failure, logs it, and returns null, and the route
241
+ still answers 200 with the provider URL. A genuine Convex failure is therefore
242
+ still a silent miss (plus an orphaned storage blob) — findable only in the
243
+ `[meme/persist] ORPHANED STORAGE BLOB` log line.
194
244
  - The rig's child image runs are still `creativeSuiteRuns` with engine `meme`,
195
245
  so finished memes appear in the creative-suite library alongside other engines
196
246
  and `exodus browse --agent meme` still covers them.
@@ -9,7 +9,8 @@ Core — discover & run:
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
11
  exodus workflow run <workflowId|name> [--input key=value ...] [--fill <name>] [--auto-approve] [--wait] [--out <dir>] [--json] Run it
12
- exodus workflow status [--id <runId>] [--out <dir>] [--json] Poll a run / read its outputs
12
+ exodus workflow status [--id <runId>] [--out <dir>] [--json] Poll a run / read its outputs (no --id → the brand's 25 newest runs + their ids)
13
+ exodus workflow cancel <runId> [--reason "..."] [--json] Stop a run that hasn't finished — queued, running, or parked
13
14
  exodus workflow export <workflowId|name> [--version <n>] [--out <file>] [--json] Dump the contract (YAML)
14
15
 
15
16
  Authoring:
@@ -21,7 +22,7 @@ Authoring:
21
22
 
22
23
  Parked runs:
23
24
  exodus workflow inbox [--json] Every run parked waiting on you
24
- exodus workflow checkpoint <runId> [show | edit <n> | approve [--reject <n,..>] [--wait] | retry [--wait] | cancel [--reason "..."]] [--json] Resolve a checkpoint park
25
+ exodus workflow checkpoint <runId> [show | edit <n> | approve [--reject <n,..>] [--wait] | retry [--note "..."] [--wait] | cancel [--reason "..."]] [--json] Resolve a checkpoint park
25
26
  exodus workflow gate <runId> RETIRED in 2.0 — prints a pointer at "checkpoint" and exits 1
26
27
  exodus workflow repair <runId> retry|skip|kill [--wait] [--json] Resolve a repair park
27
28
  exodus workflow answer <runId> [--slot key=value ...] [--json] Answer a nested workflow's slots (no --slot → list them)
@@ -79,6 +80,25 @@ An Exodus **Workflow** is a saved graph the member builds on the dashboard canva
79
80
 
80
81
  **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.
81
82
 
83
+ **Polling a run in `--json`: the fields that matter.** `workflow status --id <runId> --json` returns the run object as-is. The keys a poll loop actually needs:
84
+
85
+ - **`status`** — the run's **stored lifecycle state**, as the machine spells it: `queued`, `running`, `awaiting-approval`, `succeeded`, `succeeded-with-warnings`, `failed`, `cancelled`. **Branch on this key, not on the printed words.** One warning: a run created before the 2.0 rename can still store the old spelling — `awaiting-review`, `completed`, `partial`, `canceled` — so treat those as the same states.
86
+ - **The human `status:` line is a JUDGEMENT, not this raw value.** It starts from this field and then reads the rest of the run, so the two can legitimately disagree: a run stored `succeeded-with-warnings` that delivered no slot at all prints `Failed — nothing delivered`, and a run stored `awaiting-approval` that is only waiting on a child workflow prints `Running — sub-workflow`. Neither is a bug — the printed line is the honest verdict for a human, the key is the state machine. If you need the verdict a person would see, don't recompute it from `status` alone: read `counts` and `deliveries` too, or just read the human output.
87
+ - **`isTerminal`** — `true` once the run is finished for good and polling should stop. Trust this over comparing status strings yourself; a park is **not** terminal.
88
+ - **`counts`** — step tallies: `done`, `failed`, `skipped`, `total`, plus `outOfScope` on runs that deliberately skipped steps outside the run's scope (absent otherwise). **This is a final tally, not a progress meter.** It is added up once, at the moment the run reaches its end — finished, failed, or cancelled — so on a run that is `queued`, `running`, or parked the key is simply **not there**. Never build a progress readout on it; count the `nodes` array yourself if you want live progress.
89
+ - **`nodes`** — one entry per step, each with its own `nodeId`, `kind`, `status`, and `outputs`.
90
+ - **`outputs`** — the flat pile of final deliverables, the chaining surface (see §8).
91
+ - **`deliveries`** — the named, typed promises answered slot by slot (`delivered` / `unfulfilled`). Present on newer backends; when it's there, read it before `outputs`.
92
+ - **`pauseReason`** — why the run parked. **Only read it when `status` is awaiting-approval**: it is left behind on a run that was cancelled out of a park, so a terminal row can still carry a stale one. **Five values are stored, and it can also be missing altogether — so always write a fallback branch, never a switch that assumes four cases:**
93
+ - `checkpoint`, `repair`, `slots` — each names the verb that resolves the park (`checkpoint` / `repair` / `answer`).
94
+ - `call` — informational. The run is waiting on a child workflow and resumes by itself when the child finishes, so there is nothing for you to do (which is also why `call` parks are kept out of `inbox`).
95
+ - `taste` — **frozen legacy**: a run that parked at the Gate box, which retired in 2.0. Nothing writes it any more, but old rows still carry it. It cannot be resumed — cancel the run and run the workflow again.
96
+ - **absent** — an old cost-gate park, from before parks recorded a reason. The run is still alive and still approvable, but **no CLI verb resolves it**: the checkpoint/repair/answer verbs all refuse it by name ("parked at the cost gate (legacy)"). Send the user to the dashboard run page to approve it there, or cancel it.
97
+
98
+ Run `status --json` with **no** `--id` and you get `{ "runs": [...] }` instead — a lighter row per recent run, each carrying `_id`, `workflowName`, `status`, `pauseReason`, and `createdAt`. That is the list to search when you need a run's id.
99
+
100
+ **That list is capped at the 25 newest runs, and it never says so.** Nothing in the output marks the cut-off, so a run that exists but is older than the last 25 simply isn't in it. Never read an absence there as "it didn't run" on a busy brand — say "not in the 25 most recent" and, if you have the run id, check it directly with `status --id <runId>`.
101
+
82
102
  **Auth is one bearer key.** `EXODUS_API_KEY` from `.env` authorizes every verb; there are no scoped tokens. If a call 401s, run `npx @aicopycoders/exodus doctor`.
83
103
 
84
104
  ## The agent operating loop
@@ -90,10 +110,10 @@ The end-to-end narrative, every step naming its verb. You rarely walk all of it
90
110
  3. **Import** — `workflow import <file>` to create, or `import <file> --update <id>` to edit in place.
91
111
  4. **Describe** — `workflow describe` to confirm inputs, prerequisites (✓/✗ primers), and outputs before spending a run.
92
112
  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. Add `--auto-approve` only when the run is meant to go start-to-finish with nobody watching: it approves every Checkpoint the run hits, unchanged, instead of stopping for a verdict. It's a per-launch choice, not a setting on the workflow, and the run keeps a record of each stop it waved through — so confirm with the user before you pass it.
93
- 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`.
113
+ 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 --id <runId>` prints `status: Awaiting approval`.
94
114
  7. **Resolve** — `workflow checkpoint <runId> …` (the run reached a Checkpoint box), `workflow repair <runId> …` (a stalled collector), or `workflow answer <runId> …` (a nested workflow's slots).
95
115
  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`.
96
- 9. **Promote** — `bank promote <key>` lands the winner **and** fires the Winner Flywheel; then check `workflow inbox` for any background run it kicked off.
116
+ 9. **Promote** — `bank promote <key>` lands the winner **and** emits the `winner-promoted` event, so any workflow with an **enabled** matching trigger starts a background run. To find out whether one actually started, run `workflow status` with **no** `--id` — the recent-run list. Don't send the user to `inbox` for this: `inbox` only holds runs waiting on a person, and a flywheel run with no approval step will never appear there (§5).
97
117
 
98
118
  ## 1. Discover & run
99
119
 
@@ -149,7 +169,7 @@ npx @aicopycoders/exodus workflow status --id <runId>
149
169
 
150
170
  There is no way to run only part of a graph: a run executes the whole workflow or it doesn't start.
151
171
 
152
- **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.
172
+ **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 `Succeeded` but a slot says `unfulfilled`, the workflow did NOT deliver what it promised — say so.
153
173
 
154
174
  **Save the deliverables to disk with `--out <dir>`** (works on `run --wait` and on `status --id`):
155
175
 
@@ -160,7 +180,7 @@ npx @aicopycoders/exodus workflow status --id <runId> --out ./deliverables
160
180
 
161
181
  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.
162
182
 
163
- 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.
183
+ Report the outputs plus a short take. Don't call a run "done" off the kickoff line alone. **`status` prints one `status:` line, and it says one of seven words:** `Queued`, `Running`, `Awaiting approval`, `Succeeded`, `Succeeded with warnings`, `Failed`, `Cancelled`. Some of them carry a short "why" after a dash — `Awaiting approval — needs repair`, `Awaiting approval — needs inputs`, `Running — sub-workflow` (it's waiting on a child workflow, not on you), `Failed — nothing delivered` (steps failed AND not one promised slot arrived, so it is not a success no matter what the run row says). `Succeeded with warnings` means some steps were skipped or failed but the run still delivered. `Awaiting approval` means it **parked** and is waiting on you (see the next section). Read `status` before declaring victory.
164
184
 
165
185
  ## 2. Runs that park
166
186
 
@@ -170,12 +190,49 @@ A run doesn't always finish on its own — it can **park** and wait for a human
170
190
  - **repair** — a require-all collector stalled on a dead input and needs a decision.
171
191
  - **slots** — a nested sub-workflow is waiting on inputs (slot answers).
172
192
 
173
- **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.
193
+ **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 --id <runId>` prints `status: Awaiting approval`.
194
+
195
+ ### `inbox` vs the run list — two different questions
196
+
197
+ **`inbox` answers one question: what is waiting on a person?** For that job it is the right list — how a run started makes no difference to it. A run fired by a trigger, by a promote's flywheel, or in the background shows up there when it parks for a decision, exactly like a run you launched by hand.
198
+
199
+ Two limits worth knowing before you treat an empty inbox as proof of anything:
200
+
201
+ - **A run waiting on a child workflow is deliberately left out.** That is the `call` park — the parent resumes by itself when the child finishes, so there is nothing for you to decide and it is filtered out on purpose. It is still parked; it just isn't your problem.
202
+ - **The inbox shows the 50 newest parked runs**, and prints no notice when it hits that ceiling. On a brand with more parks than that, the oldest ones fall off the end.
203
+
204
+ **What `inbox` does not list is work that isn't waiting on anybody.** A queued run, a running run, and any automation that ran start-to-finish without an approval step never appear there — not because they're hidden, but because there is nothing for you to decide. Plenty of workflows are built that way on purpose; the shipped **Winner Flywheel** starter is one (brief → splitter → output, no Checkpoint box anywhere), so its run *can never* show up in `inbox`. An empty inbox after a promote means "nothing needs you", not "nothing ran".
205
+
206
+ **To see runs that aren't waiting on you, run `workflow status` with no `--id`.** That prints the brand's recent runs — newest first, one row each with the workflow name, its status, the **date** it started, and its **run id**. That bare list is how you discover a background run at all, including one that started and finished while you weren't looking; take the id from it and `status --id <runId>` for the full detail.
207
+
208
+ Two things about that list you have to know before you answer a question with it:
209
+
210
+ - **It stops at the 25 newest runs and says nothing about the ones it dropped.** On a brand that runs a lot, "did my promote start a run?" can read as *no* when the honest answer is "yes, but more than 25 runs ago". Treat a miss as "not in the last 25", not as "never happened".
211
+ - **The `created` column is a date with no clock time** (`2026-08-15`). Every run started today looks identical, so it cannot tell you whether a run started before or after something else you did a few minutes ago. Use the ordering instead — the list is newest-first, so a run kicked off by your promote is at or near the top.
174
212
 
175
213
  ```bash
176
- npx @aicopycoders/exodus workflow inbox
214
+ npx @aicopycoders/exodus workflow status # recent runs: name, status, created, run id
215
+ npx @aicopycoders/exodus workflow status --id <runId> # one run in full
216
+ npx @aicopycoders/exodus workflow inbox # only the runs waiting on a person
177
217
  ```
178
218
 
219
+ ### Stopping a run early — `workflow cancel`
220
+
221
+ **A run you want to stop does not have to be parked.** `workflow cancel <runId>` stops any run that hasn't finished — waiting in the queue, halfway through, or parked at a checkpoint, all the same verb:
222
+
223
+ ```bash
224
+ npx @aicopycoders/exodus workflow cancel <runId> --reason "wrong brief"
225
+ ```
226
+
227
+ This is the verb to reach for the moment a run is going the wrong way. A run costs money the entire time it is queued or running, so you do **not** have to wait for it to reach a Checkpoint before you can pull the plug.
228
+
229
+ What cancelling guarantees: the run is marked cancelled and will not advance another step, and any sub-workflow it started is cancelled with it. What it does **not** guarantee: that the step already mid-flight dies instantly. A stop is sent to the worker, but that hop is best-effort — if it doesn't land, the step can finish what it was already doing before everything comes to rest. So cancel promptly, and don't promise the user that a half-finished image or video was stopped mid-render.
230
+
231
+ - **`--reason "..."`** is optional and recorded on the run's history, so anyone reading it later sees why it stopped. Use it.
232
+ - **A run that already finished is refused** — the CLI relays the server's own sentence ("This run has already finished — there is nothing to cancel."). That is a statement of fact, not an error to work around.
233
+ - **Get the run id from `workflow status`** (no `--id`) if you don't have it in front of you.
234
+ - **`workflow checkpoint <runId> cancel` still works** and does exactly the same thing — it's the older name for this, kept so existing habits and scripts don't break.
235
+
179
236
  ### The `gate` verbs retired in 2.0
180
237
 
181
238
  `exodus workflow gate …` no longer does anything: the Gate node it drove was
@@ -186,12 +243,12 @@ resumed, because the step it's waiting on no longer exists. Cancel it and run th
186
243
  workflow again:
187
244
 
188
245
  ```bash
189
- npx @aicopycoders/exodus workflow checkpoint <runId> cancel --reason "legacy gate park"
246
+ npx @aicopycoders/exodus workflow cancel <runId> --reason "legacy gate park"
190
247
  ```
191
248
 
192
249
  ### Checkpoint parks — show / edit / approve / retry / cancel
193
250
 
194
- A **Checkpoint** is its own box on the canvas, sitting on a wire between two steps: the step before it finishes, the work reaches the Checkpoint, and the whole run stops there until a human signs off. Because it's a box you can see, the diagram tells you exactly where a run will stop. It waits **indefinitely**, so a `run --wait` left unattended simply keeps polling; nothing times out and nothing is lost. A Checkpoint is never approved automatically — not in a background run, not in a triggered one. The single exception is a run someone launched with `run --auto-approve`, which is an explicit "nobody is watching this one" choice made at launch time; those runs approve each Checkpoint as they reach it and mark the stop as auto-approved.
251
+ A **Checkpoint** is its own box on the canvas, sitting on a wire between two steps: the step before it finishes, the work reaches the Checkpoint, and the whole run stops there until a human signs off. Because it's a box you can see, the diagram tells you exactly where a run will stop. **The park itself waits indefinitely** — nothing on the server expires it, and the sweep that kills stalled runs deliberately skips parked ones, so a run can sit at a Checkpoint for days and lose nothing. **The `--wait` command watching it does not wait that long:** after about an hour of polling it gives up, exits non-zero, and prints `Timed out waiting. Check later: exodus workflow status --id <runId>`. That is the *command* ending, not the run — the run is still parked exactly where it was, and `inbox` or `status --id` picks it back up. A Checkpoint is never approved automatically — not in a background run, not in a triggered one. The single exception is a run someone launched with `run --auto-approve`, which is an explicit "nobody is watching this one" choice made at launch time; those runs approve each Checkpoint as they reach it and mark the stop as auto-approved.
195
252
 
196
253
  The box itself does no work: whatever arrives goes straight back out, unchanged, the moment you approve.
197
254
 
@@ -201,13 +258,13 @@ There are **no candidates to pick** — there's held work to read and either ble
201
258
  npx @aicopycoders/exodus workflow checkpoint <runId> # show what's waiting at the checkpoint
202
259
  npx @aicopycoders/exodus workflow checkpoint <runId> edit 1 --text "tighter opener" # rewrite an output in place
203
260
  npx @aicopycoders/exodus workflow checkpoint <runId> approve --wait # sign off, the run continues
204
- npx @aicopycoders/exodus workflow checkpoint <runId> retry --wait # redo the step feeding it, wait for fresh output
261
+ npx @aicopycoders/exodus workflow checkpoint <runId> retry --note "make the hook punchier" --wait # redo the step feeding it
205
262
  npx @aicopycoders/exodus workflow checkpoint <runId> cancel --reason "wrong direction" # stop the run here
206
263
  ```
207
264
 
208
265
  - **`edit`** takes `--text`, `--file <path>`, or piped stdin. It does **not** resume the run — edit first, then `approve`.
209
- - **`retry`** discards what's waiting and re-runs the step **feeding** the checkpoint (re-running the checkpoint itself would hand back the identical text, since it only passes things through). 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.
210
- - **`cancel`** stops the run for good, recording your reason — the same audit trail the web uses.
266
+ - **`retry`** discards what's waiting and re-runs the step **feeding** the checkpoint (re-running the checkpoint itself would hand back the identical text, since it only passes things through). 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. Add **`--note "..."`** to say what was wrong: a step that calls a model is given the note on the re-run, and every step records it on the run's review trail either way.
267
+ - **`cancel`** stops the run for good, recording your reason — the same audit trail the web uses. It is the same action as the top-level `workflow cancel <runId>`, so you can use either; the top-level one is the one to remember, because it also works on a run that hasn't parked.
211
268
 
212
269
  **Authoring one** (when you're building or editing a graph from the CLI): add a `checkpoint` node and put it **on the wire** — producer → checkpoint `in`, checkpoint `out` → whoever used to receive it. `in` accepts any wire type so the box drops onto any wire; `out` is typed by `config.passType`, which must name the **same type as the wire feeding it** (`text` is the default and by far the common case). Get that wrong and `validate` returns `bad-config` naming the wire's real type.
213
270
 
@@ -235,7 +292,7 @@ npx @aicopycoders/exodus workflow checkpoint <runId> approve --reject 2,5 --wait
235
292
  - `--reject` takes the **item numbers you see** (1-based, comma-separated) — not row numbers, not the 0-based indexes in `--json`.
236
293
  - **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."
237
294
  - **Approve with no `--reject` keeps everything** — the ordinary approve, unchanged.
238
- - **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.
295
+ - **Rejecting every item is refused.** If nothing is worth keeping, stop the run instead (`workflow cancel <runId> --reason "..."`) — that's the honest action, and the CLI relays the server's message telling you so.
239
296
  - `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`).
240
297
 
241
298
  ### Repair parks — retry / skip / kill
@@ -296,7 +353,22 @@ npx @aicopycoders/exodus bank show hooks # entries, newest-first, with
296
353
  npx @aicopycoders/exodus bank promote hooks "Stop scrolling — your knees will thank you"
297
354
  ```
298
355
 
299
- `bank promote` lands a winning line **and fires the Winner Flywheel** — it emits the `winner-promoted` event with **exact web parity and no opt-out**. Any workflow with an enabled matching trigger then starts a background run. So **after a promote, check `workflow inbox`** for the background run it may have kicked off. Promote reads text from an argument, `--file <path>`, or stdin (one of the three), and takes optional flags for awareness tagging (`--awareness`, body bank), win metrics (`--spend`, `--roas`, `--ctr`, `--note`), and provenance (`--run`, `--node`) — see `bank promote --help`.
356
+ `bank promote` lands a winning line **and fires the Winner Flywheel** — it emits the `winner-promoted` event with **exact web parity and no opt-out**. Any workflow with an enabled matching trigger then starts a background run. Promote reads text from an argument, `--file <path>`, or stdin (one of the three), and takes optional flags for awareness tagging (`--awareness`, body bank), win metrics (`--spend`, `--roas`, `--ctr`, `--note`), and provenance (`--run`, `--node`) — see `bank promote --help`.
357
+
358
+ **Two honest things to tell the user after a promote.**
359
+
360
+ **First: a promote often fires nothing, and that is the default.** The event goes out every time, but it only starts a run if some workflow on this brand has a `winner-promoted` trigger that is **switched on**. The shipped **Winner Flywheel** starter arrives with its trigger **disabled** on purpose — so on a brand that imported it and never enabled it, a promote lands the line and stops there. Check before you promise a run: `workflow triggers "Winner Flywheel"` shows the switch, and `workflow triggers "Winner Flywheel" enable 1` flips it on.
361
+
362
+ **Second: check the run list, not the inbox.** `inbox` holds runs that are waiting on a person. The Winner Flywheel is three steps — brief → splitter → output — with **no Checkpoint box anywhere**, so its run has nothing to ask you and never lands in the inbox; it just runs and finishes. Looking for it there and finding nothing tells you nothing. Look here instead:
363
+
364
+ ```bash
365
+ npx @aicopycoders/exodus workflow triggers "Winner Flywheel" # is the switch even on?
366
+ npx @aicopycoders/exodus bank promote hooks "…" # land the winner
367
+ npx @aicopycoders/exodus workflow status # did a run start? (25 newest runs + ids)
368
+ npx @aicopycoders/exodus workflow status --id <runId> # what it produced
369
+ ```
370
+
371
+ Run it **right after** the promote: that list holds only the 25 newest runs, with no clock time on the rows, so the fresh run is only easy to spot while it's still near the top.
300
372
 
301
373
  ## 6. Versions
302
374
 
@@ -555,12 +627,12 @@ The inbox/checkpoint/session/bank verbs have v2 routes too, but the CLI is the m
555
627
  ## Failure handling
556
628
 
557
629
  - **`describe` shows a ✗ prerequisite** — don't run yet. Fill the primer via `exodus-primer` / `exodus-foundation`, then re-check `describe`.
558
- - **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.
559
- - **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.
630
+ - **Run comes back `Succeeded with warnings` or `Failed`** — read `status --id <runId>` (drop `--json` for a readable view) to see which node failed and why **before re-running**. "Succeeded with warnings" still has usable outputs; harvest them first. `Failed — nothing delivered` means the run produced none of what it promised.
631
+ - **A verb refuses because the run isn't in that state** — the checkpoint/repair/answer verbs check the run first 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. **`cancel` is the exception on purpose**: it doesn't care what state the run is in, only that it hasn't finished.
560
632
  - **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.
561
633
  - **`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.
562
634
  - **`import --update` 409** — re-export, reapply, re-import. This is a conflict, not a bug.
563
- - **After a promote** — the success line includes the flywheel note; check `workflow inbox` for the background run it may have started.
635
+ - **After a promote, and no run appeared** — first check the trigger is actually enabled (`workflow triggers "<workflow>"`); the Winner Flywheel starter ships **disabled**. If it is on, look in the **recent-run list** (`workflow status`, no `--id`), not in `inbox` — a flywheel run has no approval step, so it finishes without ever appearing in the inbox.
564
636
  - **Auth / whoami failure** — run `npx @aicopycoders/exodus doctor` and confirm `EXODUS_API_KEY`.
565
637
  - **Wrong brand** — workflows, sessions, and banks are per-brand. `npx @aicopycoders/exodus brand current` to confirm you're on the brand you think you are; switch with the `exodus-brand` skill.
566
638
 
@@ -51,7 +51,7 @@ const LIST_PATH = "/api/v2/banks";
51
51
  const SHOW_PATH = "/api/v2/banks/show";
52
52
  const PROMOTE_PATH = "/api/v2/banks/promote";
53
53
  const FLYWHEEL_LINE = 'Winner Flywheel: "winner-promoted" dispatched — any workflow with an enabled ' +
54
- "trigger starts a background run (watch: exodus workflow inbox)";
54
+ "trigger starts a background run (see it: exodus workflow status)";
55
55
  const VALUE_FLAGS = new Set(["file", "awareness", "spend", "roas", "ctr", "note", "run", "node"]);
56
56
  const defaultDeps = {
57
57
  get: (path) => apiGet(path),
@@ -13,6 +13,7 @@ export declare function checkEnvFile(): CheckResult;
13
13
  export declare function checkWhoami(): Promise<CheckResult>;
14
14
  export declare function checkActiveBrandMatch(): Promise<CheckResult>;
15
15
  export declare function checkExodusDistFreshness(pkgRootOverride?: string): CheckResult;
16
+ export declare function checkScaffoldedSkills(rootOverride?: string, assetsSearchOverride?: string): CheckResult;
16
17
  export declare function checkVersionCurrency(pkgRootOverride?: string, fetchImpl?: typeof fetch): Promise<CheckResult>;
17
18
  export declare function checkApiAndDrive(): Promise<CheckResult[]>;
18
19
  export declare function checkDashboardAuth(): Promise<CheckResult>;
@@ -3,7 +3,9 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { execSync } from "node:child_process";
5
5
  import { apiGet, apiGetDashboard, getApiUrl } from "../lib/client.js";
6
- import { findWorkspaceRoot } from "../lib/state.js";
6
+ import { findWorkspaceRoot, getScaffoldVersion } from "../lib/state.js";
7
+ import { skillsDir } from "../lib/assets.js";
8
+ import { getVersion } from "../lib/version.js";
7
9
  import { detectLayout, listBrandDirs, resolveActiveBrand, brandStateDir, } from "../lib/layout.js";
8
10
  import { auth401Hint, isPlaceholderApiUrl } from "../lib/backend-hint.js";
9
11
  import { compareVersions, readPackageVersion, } from "../lib/release.js";
@@ -252,6 +254,65 @@ export function checkExodusDistFreshness(pkgRootOverride) {
252
254
  }
253
255
  return { ok: true, label: "Exodus CLI build", detail: "dist up-to-date" };
254
256
  }
257
+ function dirNamesIn(dir) {
258
+ return fs
259
+ .readdirSync(dir, { withFileTypes: true })
260
+ .filter((e) => e.isDirectory())
261
+ .map((e) => e.name)
262
+ .sort();
263
+ }
264
+ export function checkScaffoldedSkills(rootOverride, assetsSearchOverride) {
265
+ const label = "Workspace skills";
266
+ const refresh = `run \`npx ${pkgRef()} update\` to refresh your skills`;
267
+ const root = rootOverride ?? findWorkspaceRoot();
268
+ const running = getVersion();
269
+ const problems = [];
270
+ const stamped = getScaffoldVersion(root);
271
+ if (stamped && stamped !== running) {
272
+ problems.push(`scaffolded by ${stamped}, CLI is ${running}`);
273
+ }
274
+ let bundled = null;
275
+ try {
276
+ bundled = dirNamesIn(skillsDir(assetsSearchOverride));
277
+ }
278
+ catch {
279
+ bundled = null;
280
+ }
281
+ let installed = null;
282
+ if (bundled) {
283
+ try {
284
+ installed = dirNamesIn(path.join(root, ".claude", "skills"));
285
+ }
286
+ catch {
287
+ installed = null;
288
+ }
289
+ if (installed === null) {
290
+ problems.push("no .claude/skills/ in this workspace");
291
+ }
292
+ else {
293
+ const missing = bundled.filter((n) => !installed.includes(n));
294
+ if (missing.length > 0) {
295
+ problems.push(`missing skill(s): ${missing.join(", ")}`);
296
+ }
297
+ }
298
+ }
299
+ if (problems.length > 0) {
300
+ return { ok: true, warn: true, label, detail: problems.join("; "), fix: refresh };
301
+ }
302
+ if (!bundled) {
303
+ return {
304
+ ok: true,
305
+ warn: true,
306
+ label,
307
+ detail: "couldn't read the bundled skills to compare",
308
+ };
309
+ }
310
+ return {
311
+ ok: true,
312
+ label,
313
+ detail: `${installed?.length ?? 0} skill(s) installed${stamped ? `, scaffolded by ${stamped}` : ""} — every bundled skill is present`,
314
+ };
315
+ }
255
316
  function npmRegistryUrl(channel) {
256
317
  return `https://registry.npmjs.org/@aicopycoders/exodus/${channel}`;
257
318
  }
@@ -550,6 +611,7 @@ export async function run(_flags) {
550
611
  results.push(checkEnvFile());
551
612
  results.push(checkLayout());
552
613
  results.push(checkExodusDistFreshness());
614
+ results.push(checkScaffoldedSkills());
553
615
  results.push(checkBrandProfileGenesisDepth());
554
616
  const apiAndDrive = await checkApiAndDrive();
555
617
  results.push(...apiAndDrive);
@@ -4,6 +4,8 @@ import { writeEnvScaffold, ensureGitignore, writeSkills, writeReferences, writeD
4
4
  import { loadWorkspaceEnv } from "../lib/load-env.js";
5
5
  import { ensureBrandDir } from "../lib/layout.js";
6
6
  import { pkgRef } from "../lib/channel.js";
7
+ import { setScaffoldVersion } from "../lib/state.js";
8
+ import { getVersion } from "../lib/version.js";
7
9
  export const helpText = `
8
10
  exodus init — Set up (or refresh) an Exodus workspace in the current folder
9
11
 
@@ -24,6 +26,7 @@ export function scaffoldInit(root) {
24
26
  const skills = writeSkills(root);
25
27
  writeReferences(root);
26
28
  const docs = writeDocs(root);
29
+ setScaffoldVersion(getVersion(), root);
27
30
  return { existing, envCreated, skills, docs };
28
31
  }
29
32
  export async function syncBrands(root) {
@@ -9,8 +9,8 @@ Usage:
9
9
  exodus meme recommend --brief "<text>" [--avatar "<text>"]
10
10
  exodus meme run --brief "<text>" --formats '<json>' [--avatar "<text>"] [--name "<label>"]
11
11
  exodus meme run --brief "<text>" --formats-file <path> [--avatar "<text>"] [--name "<label>"]
12
- exodus meme regenerate --brief "<text>" --layer 1 --template-id <id> --template-name "<name>" --boxes <N> [--id <runId>] [--avatar "<text>"]
13
- exodus meme regenerate --brief "<text>" --layer 2|3 --format <formatId> [--hint "<text>"] [--id <runId>] [--avatar "<text>"]
12
+ exodus meme regenerate --brief "<text>" --layer 1 --template-id <id> --template-name "<name>" --boxes <N> [--id <run id>] [--avatar "<text>"]
13
+ exodus meme regenerate --brief "<text>" --layer 2|3 --format <formatId> [--hint "<text>"] [--id <run id>] [--avatar "<text>"]
14
14
 
15
15
  Flow:
16
16
  1. recommend → { recommendations: [...] } — 15 picks (5 per layer), each with
@@ -25,8 +25,12 @@ Flow:
25
25
  workflow run — the workflow verb is the one that can read it.
26
26
  4. regenerate → re-render a single miss using the format fields from the
27
27
  recommend output. Synchronous; returns the new image URL.
28
- Its optional --id takes a CREATIVE-SUITE run id, which the id
29
- from step 2 is not — omit --id and use the returned URL.
28
+ Pass --id <the runId from step 2> to file the fresh meme with
29
+ the rest of that run, so it shows up in the library next to
30
+ its siblings. Leave --id off and you just get the URL — handy,
31
+ but that image is not saved anywhere. If the id doesn't match
32
+ a run (or that run never made this format) you get a plain
33
+ error saying so, not a silent miss.
30
34
 
31
35
  Keys (strict BYOK — checked server-side before anything starts):
32
36
  classic (layer 1) memes need your Imgflip login; AI (layer 2/3) memes need
@@ -36,6 +40,8 @@ Examples:
36
40
  exodus meme recommend --brief "grounding sheets reduce inflammation"
37
41
  exodus meme run --brief "grounding sheets reduce inflammation" --formats '[{"layer":2,"name":"Group Chat","format_id":"group-chat"}]'
38
42
  exodus meme regenerate --brief "grounding sheets reduce inflammation" --layer 2 --format group-chat --hint "make the last message land the product"
43
+ # Same re-roll, filed with the run it belongs to (the runId step 2 printed):
44
+ exodus meme regenerate --brief "grounding sheets reduce inflammation" --layer 2 --format group-chat --id <runId from step 2>
39
45
  `.trim();
40
46
  function dashboardUrlForRun(runId) {
41
47
  return `${getDashboardUrl()}/runs/${runId}`;
@@ -235,8 +241,9 @@ async function runRegenerate(flags) {
235
241
  }
236
242
  const res = await apiPostDashboard("/api/meme/regenerate", body);
237
243
  if (!res.ok) {
238
- console.error(formatError(res));
239
- if (res.status === 400) {
244
+ const detail = formatError(res);
245
+ console.error(detail);
246
+ if (res.status === 400 && /\bkey/i.test(detail)) {
240
247
  console.error(`Hint: run \`npx ${pkgRef()} doctor\` to check your keys.`);
241
248
  }
242
249
  process.exit(1);
@@ -1,4 +1,7 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { getVersion } from "../lib/version.js";
4
+ import { findParentRoot } from "../lib/layout.js";
2
5
  import { run as runInit } from "./init.js";
3
6
  export const helpText = `
4
7
  exodus update — refresh this workspace from the installed CLI (alias of init)
@@ -14,7 +17,44 @@ Note: the CLI itself updates because npx resolves your channel fresh on each
14
17
  run — so invoke this as \`npx @aicopycoders/exodus update\` (or @beta on the
15
18
  beta channel), not from a stale local install.
16
19
  `.trim();
20
+ function refuseRollback(root) {
21
+ console.error("`--rollback` isn't something this version can do.");
22
+ console.error("");
23
+ console.error("Rolling back was a feature of the old downloadable Exodus. This");
24
+ console.error("version installs fresh from npm every time you run it, so there is");
25
+ console.error("no saved copy here for it to put back — and refreshing now would");
26
+ console.error("overwrite your files again, which is the opposite of what you asked.");
27
+ console.error("");
28
+ const backupDir = path.join(root, ".backup");
29
+ let stamps = [];
30
+ try {
31
+ stamps = fs
32
+ .readdirSync(backupDir, { withFileTypes: true })
33
+ .filter((e) => e.isDirectory())
34
+ .map((e) => e.name)
35
+ .sort()
36
+ .reverse();
37
+ }
38
+ catch {
39
+ }
40
+ if (stamps.length > 0) {
41
+ console.error("Your old CLI did leave saved copies here:");
42
+ console.error(` ${backupDir}`);
43
+ console.error(` newest first: ${stamps.slice(0, 3).join(", ")}`);
44
+ console.error("");
45
+ console.error("To put one back, copy it over by hand, for example:");
46
+ console.error(` cp -R "${path.join(backupDir, stamps[0], ".claude", "skills")}" "${path.join(root, ".claude")}/"`);
47
+ }
48
+ else {
49
+ console.error(`No saved copies found at ${backupDir}.`);
50
+ }
51
+ process.exit(1);
52
+ }
17
53
  export async function run(flags) {
54
+ const root = typeof flags.root === "string" ? flags.root : findParentRoot();
55
+ if (flags.rollback)
56
+ refuseRollback(root);
57
+ const rooted = typeof flags.root === "string" ? flags : { ...flags, root };
18
58
  console.log(`Exodus ${getVersion()} — refreshing workspace scaffold…`);
19
- await runInit(flags);
59
+ await runInit(rooted);
20
60
  }
@@ -69,7 +69,7 @@ export interface WorkflowImportResult {
69
69
  unresolved: UnresolvedWorkflowRef[];
70
70
  warnings: string[];
71
71
  }
72
- export type GraphIssueCode = "bad-shape" | "unknown-kind" | "duplicate-node-id" | "dangling-edge" | "unknown-port" | "type-mismatch" | "duplicate-input" | "duplicate-input-key" | "cycle" | "missing-required-input" | "bad-config" | "bad-slot" | "bad-trigger" | "nested-split" | "fan-overlap" | "lane-ineligible" | "fan-image-budget" | "collector-unpaired" | "splitter-field-conflict";
72
+ export type GraphIssueCode = "bad-shape" | "unknown-kind" | "duplicate-node-id" | "dangling-edge" | "unknown-port" | "type-mismatch" | "duplicate-input" | "duplicate-input-key" | "cycle" | "missing-required-input" | "bad-config" | "bad-slot" | "bad-trigger" | "nested-split" | "fan-overlap" | "lane-ineligible" | "fan-image-budget" | "collector-unpaired" | "splitter-field-conflict" | "competitor-source";
73
73
  export interface GraphIssue {
74
74
  code: GraphIssueCode;
75
75
  message: string;
@@ -211,6 +211,7 @@ export interface WorkflowDescribeResponse {
211
211
  }>;
212
212
  outputs: WorkflowOutputDescriptor[];
213
213
  deliveries?: WorkflowDeliveryDescriptor[];
214
+ warnings?: string[];
214
215
  }
215
216
  export interface WorkflowTemplateListItem {
216
217
  key: string;
@@ -568,6 +569,11 @@ export declare function checkpointRetryFlow(runId: string, opts: {
568
569
  note?: string;
569
570
  onProgressLine?: (line: string) => void;
570
571
  }, deps: WorkflowRunDeps): Promise<FlowResult>;
572
+ export declare function cancelRunFlow(runId: string, opts: {
573
+ reason?: string;
574
+ json: boolean;
575
+ verb?: string;
576
+ }, deps: WorkflowRunDeps): Promise<FlowResult>;
571
577
  export declare function checkpointCancelFlow(runId: string, opts: {
572
578
  reason?: string;
573
579
  json: boolean;
@@ -4,7 +4,7 @@ import nodePath from "node:path";
4
4
  import { apiGet, apiGetText, apiPost, apiPostDashboard, getDashboardUrl, } from "../lib/client.js";
5
5
  import { formatApiError } from "../lib/format.js";
6
6
  import { pollUntilDone } from "../lib/poll.js";
7
- import { normalizeRunStatus, storedWorkflowStatusForms, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
7
+ import { normalizeRunStatus, isTerminalRunStatus, storedWorkflowStatusForms, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
8
8
  import { runVerdict } from "../lib/runVerdict.js";
9
9
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
10
10
  import { missingRouteLine } from "../lib/route-support.js";
@@ -20,7 +20,9 @@ Usage:
20
20
  exodus workflow templates export <key> [--out <file>] [--json]
21
21
  exodus workflow schema [--kind <kind>] [--face <face>] [--json]
22
22
  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 status [--id <runId>] [--out <dir>] [--json]
23
+ exodus workflow status [--json]
24
+ exodus workflow status --id <runId> [--out <dir>] [--json]
25
+ exodus workflow cancel <runId> [--reason "..."] [--json]
24
26
  exodus workflow versions <workflowId|name> [--json]
25
27
  exodus workflow export <workflowId|name> [--version <n>] [--out <file>] [--json]
26
28
  exodus workflow validate <file> [--update <workflowId>] [--json]
@@ -92,7 +94,12 @@ Flags:
92
94
  Checkpoint box and waits until its fresh output is
93
95
  ready and the run parks again — that IS its finish
94
96
  line; a redo never runs the workflow to completion.
95
- --id <runId> Workflow run id for status detail
97
+ --id <runId> (status) One run's full detail. LEAVE IT OFF and status
98
+ prints the brand's recent runs instead — workflow name,
99
+ status, when it started, and the run id — which is how
100
+ you find the id of a run that started in the background
101
+ (a trigger fired it, or a promoted winner did) and
102
+ never stopped to ask you anything.
96
103
  --out <file> Write the export to a file instead of stdout
97
104
  --out <dir> (run --wait / status --id) Save every delivered output
98
105
  of the finished run into this directory (created if
@@ -120,8 +127,8 @@ Flags:
120
127
  Required for event triggers; rejected for cron triggers.
121
128
  (checkpoint edit) The replacement copy for one output.
122
129
  --file <path> (checkpoint edit) Load the replacement copy from a file.
123
- --reason "..." (checkpoint cancel) Optional reason recorded on the
124
- cancel.
130
+ --reason "..." (cancel, checkpoint cancel) Optional reason recorded on
131
+ the cancel, so the run's history says why it stopped.
125
132
  --note "..." (checkpoint retry) Optional correction for the redo
126
133
  ("make the hook punchier"). Steps that call a model
127
134
  follow it on the re-run; deterministic steps only
@@ -160,8 +167,10 @@ Examples:
160
167
  exodus workflow run "Product Shots" --rig-overrides '{"rig_1":{"lines":{"line_1":{"count":3}}}}'
161
168
  exodus workflow run "Product Shots" --rig-overrides @rig.json --wait
162
169
  exodus workflow run "Launch Flow" --wait --out ./deliverables
170
+ exodus workflow status # recent runs + their ids
163
171
  exodus workflow status --id wr_123
164
172
  exodus workflow status --id wr_123 --out ./deliverables
173
+ exodus workflow cancel wr_123 --reason "wrong brief"
165
174
  exodus workflow versions "Launch Flow"
166
175
  exodus workflow export "Launch Flow" --out workflow.yaml
167
176
  exodus workflow export "Launch Flow" --version 3 --out v3.yaml
@@ -234,6 +243,19 @@ Notes:
234
243
  workflow bots --json emits the FULL catalog response verbatim (--category /
235
244
  --slug filters are ignored in that mode). workflow bots --slug <slug> --json
236
245
  emits just that one bot's catalog JSON.
246
+ workflow status with no --id lists the brand's recent runs — name, status,
247
+ when it started, and the run id — and that list is the ONLY place a run that
248
+ never stopped to ask you anything shows up. "workflow inbox" is a different
249
+ list: it holds runs waiting on a person, so a background run whose workflow
250
+ has no approval step finishes without ever appearing there. Take an id from
251
+ the run list, then read it with "workflow status --id <runId>". (#933/#934)
252
+ workflow cancel <runId> stops a run that has not finished — queued, running,
253
+ or parked, all of it. The run stops for good and any child workflow it started
254
+ is cancelled with it; a stop is also sent to the step still running, though
255
+ that last part is best-effort. --reason records why on the run's history.
256
+ A run that already finished is refused (nothing to cancel). The older
257
+ "workflow checkpoint <runId> cancel" is the same action under its old name and
258
+ keeps working. (#1231)
237
259
  workflow inbox lists every run parked waiting on you, badged by park kind
238
260
  (checkpoint/repair/slots/gate/legacy) and how it started (bg / trig:<event>).
239
261
  A run parked at a Checkpoint box is resolved with the "checkpoint" verbs: show
@@ -446,7 +468,7 @@ export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
446
468
  if (pauseReason === "taste") {
447
469
  return [
448
470
  " ⏸ paused at a Gate box, which retired in 2.0. Approvals now happen at a Checkpoint box.",
449
- ` Cancel it here: exodus workflow checkpoint ${runId} cancel`,
471
+ ` Cancel it here: exodus workflow cancel ${runId}`,
450
472
  ` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
451
473
  " Then run the workflow again.",
452
474
  ];
@@ -923,10 +945,19 @@ export function formatWorkflowList(workflows) {
923
945
  w._id,
924
946
  ]));
925
947
  }
948
+ const RECENT_RUNS_PAGE = 25;
926
949
  export function formatRecentRuns(runs) {
927
950
  if (runs.length === 0)
928
951
  return "No workflow runs found for the active brand.";
929
- return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, runVerdict(r), dateOnly(r.createdAt), r._id]));
952
+ const rows = table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, runVerdict(r), dateOnly(r.createdAt), r._id]));
953
+ const notes = [];
954
+ if (runs.length >= RECENT_RUNS_PAGE) {
955
+ notes.push(`Showing the ${RECENT_RUNS_PAGE} newest runs — there may be older ones this list doesn't reach.`);
956
+ }
957
+ if (runs.some((r) => !isTerminalRunStatus(r.status))) {
958
+ notes.push(`Stop one that's still going: exodus workflow cancel <id> --reason "..."`);
959
+ }
960
+ return notes.length > 0 ? `${rows}\n\n${notes.join("\n")}` : rows;
930
961
  }
931
962
  export function formatWorkflowVersions(versions) {
932
963
  if (versions.length === 0) {
@@ -1004,7 +1035,7 @@ export function formatWorkflowRun(run) {
1004
1035
  lines.push(`workflowId: ${run.workflowId}`);
1005
1036
  if (run.triggerRunId)
1006
1037
  lines.push(`triggerRunId: ${run.triggerRunId}`);
1007
- lines.push(`verdict: ${runVerdict(run)}${counts ? ` (${counts})` : ""}`);
1038
+ lines.push(`status: ${runVerdict(run)}${counts ? ` (${counts})` : ""}`);
1008
1039
  if (run.isTerminal)
1009
1040
  lines.push("terminal: yes");
1010
1041
  if (run.error)
@@ -1266,6 +1297,12 @@ export function formatDescribe(res) {
1266
1297
  if (res.description)
1267
1298
  lines.push(`description: ${res.description}`);
1268
1299
  lines.push(`updated: ${dateOnly(res.updatedAt)}`);
1300
+ if (res.warnings && res.warnings.length > 0) {
1301
+ lines.push("");
1302
+ lines.push(`✗ ${res.warnings.length === 1 ? "Warning" : "Warnings"}:`);
1303
+ for (const warning of res.warnings)
1304
+ lines.push(` ✗ ${warning}`);
1305
+ }
1269
1306
  lines.push("");
1270
1307
  lines.push(`Inputs (${res.inputs.length}):`);
1271
1308
  if (res.inputs.length === 0) {
@@ -2518,7 +2555,7 @@ export async function checkpointShowFlow(runId, opts, deps) {
2518
2555
  ...rejectLine,
2519
2556
  `Edit: exodus workflow checkpoint ${runId} edit 1 --text "..."`,
2520
2557
  `Redo: exodus workflow checkpoint ${runId} retry --wait`,
2521
- `Cancel: exodus workflow checkpoint ${runId} cancel --reason "..."`,
2558
+ `Cancel: exodus workflow cancel ${runId} --reason "..."`,
2522
2559
  ],
2523
2560
  };
2524
2561
  }
@@ -2633,17 +2670,17 @@ export async function checkpointRetryFlow(runId, opts, deps) {
2633
2670
  headline: " ⏸ The step re-ran — its fresh output is waiting on your approval.",
2634
2671
  });
2635
2672
  }
2636
- export async function checkpointCancelFlow(runId, opts, deps) {
2637
- const pf = await preflightPark(runId, ["checkpoint", "taste"], "workflow checkpoint cancel", opts.json, deps);
2638
- if (!pf.ok)
2639
- return pf.result;
2673
+ export async function cancelRunFlow(runId, opts, deps) {
2640
2674
  const res = await deps.post(CANCEL_PATH, {
2641
2675
  runId,
2642
2676
  ...(opts.reason !== undefined ? { reason: opts.reason } : {}),
2643
2677
  });
2644
2678
  if (!res.ok)
2645
- return triggerErrorResult(res, "workflow checkpoint cancel", opts.json);
2646
- return okLine(`Checkpoint canceled — run ${runId} stopped.`, { ok: true, runId }, opts.json);
2679
+ return triggerErrorResult(res, opts.verb ?? "workflow cancel", opts.json);
2680
+ return okLine(`Canceled — run ${runId} is marked cancelled and won't go any further. Any sub-workflow it started is cancelled too, and a stop was sent to the step still running.`, { ok: true, runId }, opts.json);
2681
+ }
2682
+ export async function checkpointCancelFlow(runId, opts, deps) {
2683
+ return cancelRunFlow(runId, { ...opts, verb: "workflow checkpoint cancel" }, deps);
2647
2684
  }
2648
2685
  export async function repairFlow(runId, action, opts, deps) {
2649
2686
  const verb = `workflow repair ${action}`;
@@ -2964,6 +3001,15 @@ export async function run(flags) {
2964
3001
  console.error(`Error: unknown checkpoint action "${action}" (expected show, edit, approve, retry, or cancel).`);
2965
3002
  process.exit(1);
2966
3003
  }
3004
+ if (sub === "cancel") {
3005
+ const runId = rest[0];
3006
+ if (!runId) {
3007
+ console.error("Error: workflow cancel requires <runId>.");
3008
+ console.log('Usage: exodus workflow cancel <runId> [--reason "..."] [--json]');
3009
+ process.exit(1);
3010
+ }
3011
+ return printResult(await cancelRunFlow(runId, { reason: flagString(flags, "reason"), json }, defaultDeps));
3012
+ }
2967
3013
  if (sub === "repair") {
2968
3014
  const runId = rest[0];
2969
3015
  if (!runId) {
@@ -4,3 +4,5 @@ export declare function setActiveBrand(slug: string): void;
4
4
  export declare function clearActiveBrand(): void;
5
5
  export declare function getLayoutVersion(): number | null;
6
6
  export declare function setLayoutVersion(version: number): void;
7
+ export declare function getScaffoldVersion(root?: string): string | null;
8
+ export declare function setScaffoldVersion(version: string, root?: string): void;
package/dist/lib/state.js CHANGED
@@ -4,14 +4,14 @@ import { findParentRoot } from "./layout.js";
4
4
  export function findWorkspaceRoot() {
5
5
  return findParentRoot();
6
6
  }
7
- function findStateDir() {
8
- return path.join(findWorkspaceRoot(), ".exodus");
7
+ function findStateDir(root) {
8
+ return path.join(root ?? findWorkspaceRoot(), ".exodus");
9
9
  }
10
- function statePath() {
11
- return path.join(findStateDir(), "state.json");
10
+ function statePath(root) {
11
+ return path.join(findStateDir(root), "state.json");
12
12
  }
13
- function readState() {
14
- const p = statePath();
13
+ function readState(root) {
14
+ const p = statePath(root);
15
15
  if (!fs.existsSync(p))
16
16
  return {};
17
17
  try {
@@ -21,10 +21,10 @@ function readState() {
21
21
  return {};
22
22
  }
23
23
  }
24
- function writeState(next) {
25
- const dir = findStateDir();
24
+ function writeState(next, root) {
25
+ const dir = findStateDir(root);
26
26
  fs.mkdirSync(dir, { recursive: true });
27
- fs.writeFileSync(statePath(), JSON.stringify(next, null, 2) + "\n", "utf-8");
27
+ fs.writeFileSync(statePath(root), JSON.stringify(next, null, 2) + "\n", "utf-8");
28
28
  }
29
29
  export function getActiveBrand() {
30
30
  return readState().activeBrand ?? null;
@@ -47,3 +47,11 @@ export function setLayoutVersion(version) {
47
47
  s.layoutVersion = version;
48
48
  writeState(s);
49
49
  }
50
+ export function getScaffoldVersion(root) {
51
+ return readState(root).scaffoldVersion ?? null;
52
+ }
53
+ export function setScaffoldVersion(version, root) {
54
+ const s = readState(root);
55
+ s.scaffoldVersion = version;
56
+ writeState(s, root);
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.8.1400",
3
+ "version": "2026.8.1600",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",