@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.
- package/assets/skills/exodus-meme/SKILL.md +61 -11
- package/assets/skills/exodus-workflow/SKILL.md +90 -18
- package/dist/commands/bank.js +1 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +63 -1
- package/dist/commands/init.js +3 -0
- package/dist/commands/meme.js +13 -6
- package/dist/commands/update.js +41 -1
- package/dist/commands/workflow.d.ts +7 -1
- package/dist/commands/workflow.js +61 -15
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +17 -9
- package/package.json +1 -1
|
@@ -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).
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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`
|
|
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**
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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.
|
|
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 `
|
|
559
|
-
- **A verb refuses because the run isn't in that state** — the checkpoint/repair/answer verbs
|
|
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
|
|
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
|
|
package/dist/commands/bank.js
CHANGED
|
@@ -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 (
|
|
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>;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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);
|
package/dist/commands/init.js
CHANGED
|
@@ -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) {
|
package/dist/commands/meme.js
CHANGED
|
@@ -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 <
|
|
13
|
-
exodus meme regenerate --brief "<text>" --layer 2|3 --format <formatId> [--hint "<text>"] [--id <
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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);
|
package/dist/commands/update.js
CHANGED
|
@@ -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(
|
|
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 [--
|
|
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>
|
|
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
|
|
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
|
|
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
|
-
|
|
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(`
|
|
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
|
|
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
|
|
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
|
|
2646
|
-
return okLine(`
|
|
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) {
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -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
|
+
}
|