@flowapt/flowiq-cli 0.9.0 → 0.9.3

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/README.md CHANGED
@@ -189,7 +189,8 @@ flowiq ct list
189
189
  - **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
190
190
  - Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates or built-in collisions), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, scalar headers, object-typed `parameters`/`injected_parameters`, valid auth/channels, timeout and response caps. Recursive schemas support strict nested objects, typed arrays, enums and numeric/string/item limits. Unknown tool/schema keys are rejected before anything writes.
191
191
  - **`display_name` (optional, 10 Sep 2026):** the label the TEAM sees on the inbox tool-activity card (e.g. `Checked loyalty points`) — plain string, ≤80 chars, never sent to the AI. Leave it out and the card derives a label from the tool name.
192
- - **Warnings (non-blocking):** an object with properties but no required fields. Unknown `{{placeholder}}` values are hard errors; injected parameters may additionally reference a declared top-level model parameter. Known runtime values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `text`, `unique_message_id`, `unix_timestamp`, `operation_idempotency_key`, `supabase_anon_key`, and `openai_api_key`.
192
+ - **Warnings (non-blocking):** an object with properties but no required fields. Unknown `{{placeholder}}` values are hard errors; injected parameters may additionally reference a declared top-level model parameter. Known runtime values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `text`, `unique_message_id`, `unix_timestamp`, `operation_idempotency_key`, `supabase_anon_key`, `flowmod_relay_secret`, `flowiq_queue_key`, and `openai_api_key`.
193
+ - **Queue credential:** `{{flowiq_queue_key}}` is super-admin-only and resolves to the service key only as the bearer token of a call to FlowIQ's own `add-to-api-queue` (`https://zvhwjpeeapujvuudfdps.supabase.co/functions/v1/add-to-api-queue`). The queue refuses the public anon key since 22 Sep 2026, so a tool that schedules a send (FlowMod Org's `schedule_reminder` / `schedule_a_task`) uses this. Validation and runtime both reject it anywhere else: another endpoint, another auth type, a body, a header.
193
194
  - **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
194
195
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
195
196
 
@@ -234,7 +235,14 @@ flowiq bc send <org_id> --tag <batch-tag> --template <name> --body param1="…"
234
235
  flowiq bc send … --at "2026-08-05 09:00" --needs-approval --commit # park it for approval
235
236
 
236
237
  flowiq bc scheduled list <org_id> # open rows (pending + awaiting-approval)
237
- flowiq bc scheduled list <org_id> --all # incl. fired / cancelled
238
+ flowiq bc scheduled list <org_id> --all # incl. fired / cancelled — an empty OPEN list now names what --all would show
239
+ flowiq bc scheduled list <org_id> --status completed
240
+
241
+ # SAVED SEND DRAFTS (v0.9.1) — the send dialog's stored configurations. A draft
242
+ # is NOT scheduled and never fires on its own; before this it was invisible
243
+ # outside the dashboard.
244
+ flowiq bc drafts <org_id> # newest first
245
+ flowiq bc drafts show <org_id> <draft_id> # the whole stored config
238
246
  flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (--at re-times it)
239
247
  flowiq bc scheduled cancel <org_id> <queue_id> --confirm
240
248
 
@@ -1129,29 +1137,32 @@ the prompt rows directly).
1129
1137
  Common uses: edit a master-group's moderator prompt, or flip its reasoning
1130
1138
  effort via `config.reasoningEffort` (`none` / `low` / `medium` / `high`).
1131
1139
 
1132
- ### Group chats — `flowiq groups list|pull <name-or-jid>` (alias `grp`)
1140
+ ### Group chats — ⛔ DEPRECATED (`flowiq groups`, alias `grp`)
1133
1141
 
1134
- Read-only pull of **WhatsApp group** chat history (e.g. the `… x Flowapt`
1135
- client comms groups) straight out of the FlowMod **Evolution** Postgres. This
1136
- is the analogue of `messages pull`, but for a group instead of a contact, so
1137
- you can pull a window and summarise it.
1142
+ **Evolution is retired (22 Sep 2026). Read a WhatsApp group chat with the
1143
+ WhatsApp MCP, not this command.** The flowapt Evolution instance logged out of
1144
+ WhatsApp on 29 Aug 2026 (`Instance.connectionStatus` = `close`, reason 401) and
1145
+ has stored nothing since, so this command answered from a frozen archive while
1146
+ looking current — on 22 Sep it reported the Johnson Fitness group's last
1147
+ message as 17 Aug while the group had traffic that morning.
1138
1148
 
1139
- > ⚠️ Unlike every other command, this talks to a database **directly** (the
1140
- > Evolution DB is not Supabase and not the `fiq_staff_…` API). To keep this
1141
- > published package secret-free, the connection string comes from your shell
1142
- > environment — set `FLOWMOD_EVO_DB_URL` (or the five `FLOWMOD_DB_*` vars the
1143
- > `flowmod-copilot` edge fn uses) before running. No auth token is needed.
1149
+ ```
1150
+ mcp__whatsapp-flowapt__list_chats { query: "<group name>" } → the JID
1151
+ mcp__whatsapp-flowapt__list_messages { chat_jid: "…@g.us", limit: 50 }
1152
+ mcp__whatsapp-flowmod__* if the group is not on the flowapt line
1153
+ ```
1154
+
1155
+ Both verbs now **refuse unless `--archive` is passed**, because a stale read
1156
+ that looks complete is worse than an error. `--archive` prints the frozen data
1157
+ with a banner, and `groups list` shows the instance's connection state so the
1158
+ cut-off is visible:
1144
1159
 
1145
1160
  ```bash
1146
1161
  export FLOWMOD_EVO_DB_URL='postgres://user:pass@app.flowmod.ai:5555/flowiq-db'
1147
1162
 
1148
- flowiq groups list # default instance: flowapt
1149
- flowiq groups list --instance FlowMod # the other connected instance
1150
-
1151
- flowiq groups pull "Phytoceutics x Flowapt" --last 7d
1152
- flowiq groups pull "Zorora x Flowapt" --since 2026-06-24 --until "2026-06-25 12:00"
1153
- flowiq grp pull 120363408455897717@g.us --last 48h # or pass the group JID
1154
- # writes ./.flowiq/groups/<instance>-<group-slug>.json + prints a timeline
1163
+ flowiq groups list --archive # frozen: 38 groups, all ending 28 Aug 2026
1164
+ flowiq groups pull "Zorora x Flowapt" --archive --since 2026-06-24 --until 2026-06-25
1165
+ # still writes ./.flowiq/groups/<instance>-<group-slug>.json
1155
1166
  ```
1156
1167
 
1157
1168
  - Group is matched by JID, exact name, or a unique case-insensitive substring
@@ -1179,23 +1190,30 @@ Editable fields: `name`, `description`, `status` (open / in_progress / done),
1179
1190
  null), `organizations[]`, `media[]`, `messages[]`, `created_by`. Array columns
1180
1191
  are full-replace within the row.
1181
1192
 
1182
- ### Client hours — `flowiq hours log|list|summary` (v0.5.0)
1193
+ ### Client hours — `flowiq hours log|list|summary|package` (v0.5.0; packages v0.9.2)
1183
1194
 
1184
- The client-hours ledger. Every org's package includes **10 hours of Flowapt
1185
- work per calendar month**; every piece of client work gets logged against it —
1186
- manually here, or automatically by Claude sessions. **The CLI is the only
1187
- way to log hours:** the FlowIQ MCP's `log_client_hours` tool was removed on
1188
- 29 Aug 2026, because the MCP advertises every tool to every connection and a
1189
- staff-only write does not belong on a client-facing surface. The MCP keeps
1190
- `get_client_hours` (read). Clients see their own usage on the Client Console;
1191
- the cross-org view lives in the super-admin Changelog dialog → Client hours.
1195
+ The client-hours ledger. Every piece of client work gets logged against the
1196
+ org's **monthly hours package** — manually here, or automatically by Claude
1197
+ sessions. **The CLI is the only way to log hours:** the FlowIQ MCP's
1198
+ `log_client_hours` tool was removed on 29 Aug 2026, because the MCP advertises
1199
+ every tool to every connection and a staff-only write does not belong on a
1200
+ client-facing surface. The MCP keeps `get_client_hours` (read, staff
1201
+ connections only). The Client Console card and the super-admin Changelog
1202
+ dialog → Client hours read the same figures.
1192
1203
 
1193
1204
  ```bash
1194
1205
  flowiq hours log <org_id> --hours 1.5 --desc "Rebuilt the abandoned-cart copy" # fractions fine
1195
1206
  flowiq hours log <org_id> --minutes 45 --desc "Fixed template header" --date 2026-08-18
1196
1207
  flowiq hours list <org_id> # this month's entries + package standing
1197
1208
  flowiq hours list <org_id> --month 2026-07 # a past month
1198
- flowiq hours summary # every org with logged work this month
1209
+ flowiq hours summary # every org with logged work or a package
1210
+
1211
+ flowiq hours package show [<org_id>] # every package, or the one an org is on
1212
+ flowiq hours package set <org_id> --hours 5 --notes "Order Form 12 Aug 2026"
1213
+ flowiq hours package set <pt> <es> <it> --hours 10 --name Barkyn # ONE package SHARED by three orgs
1214
+ flowiq hours package set <org_id> --hours 0 # a plan with no hours included
1215
+ flowiq hours package clear <org_id> # back to "no package on file"
1216
+ # add --dry-run to set / clear to see the change first
1199
1217
  ```
1200
1218
 
1201
1219
  - `log` takes exactly ONE of `--minutes <n>` (whole minutes) or `--hours <h>`
@@ -1204,10 +1222,25 @@ flowiq hours summary # every org with logged work this
1204
1222
  - `--by` overrides who did the work (default: your staff login email);
1205
1223
  `--date YYYY-MM-DD` backdates an entry (it counts toward THAT month);
1206
1224
  `--session` tags the session/task name.
1207
- - `list` / `summary` default to the current month and print each org's usage
1208
- against the 10h package, flagging any org that has gone over.
1209
- - Corrections (delete/edit) live in the Changelog → Client hours page, not the
1210
- CLI. Every `log` is audited.
1225
+ - **Packages (v0.9.2).** Each org has its own package, shares one with sibling
1226
+ orgs, or has none on file. There is no default: an org with no package reads
1227
+ "no package on file", never an implied 10h. A **shared** package is measured
1228
+ on the time of ALL its orgs together (Barkyn Portugal + Spain + Italy draw on
1229
+ one 10h), and every org on it shows the shared total. `0` hours is a real
1230
+ plan (self-service, nothing included).
1231
+ - `package set` puts EXACTLY the orgs you name on one package. If they already
1232
+ form exactly one package it is updated in place; otherwise they leave their
1233
+ old packages (a package left empty is removed) and get a new one. The output
1234
+ warns when orgs are left behind on the old package. `--name` defaults to the
1235
+ org's name (or the names joined); `--notes` records where the figure comes
1236
+ from.
1237
+ - **Only matt@flowapt.com and gidon@flowapt.com can set or clear a package**
1238
+ (enforced on the server; other super admins get a 403, logged as a blocked
1239
+ attempt). `package show` tells you whether you can.
1240
+ - `list` / `summary` default to the current month and flag any org, or shared
1241
+ package, that has gone over.
1242
+ - Corrections (delete/edit) to entries live in the Changelog → Client hours
1243
+ page, not the CLI. Every `log`, `package set` and `package clear` is audited.
1211
1244
 
1212
1245
  ### Client deck — `flowiq report deck status|build|narrate|generate|inputs|approve|unapprove|send|print|pull` (v0.6.5)
1213
1246
 
@@ -1225,7 +1258,7 @@ from flowiq@flowapt.com.
1225
1258
  flowiq report deck status <org_id> 2026-08 # what exists, workflow status, what blocks approval
1226
1259
  flowiq report deck generate <org_id> 2026-08 # build the metrics (if missing) + write the copy
1227
1260
  flowiq report deck generate <org_id> 2026-08 --refresh --force # rebuild everything
1228
- flowiq report deck build <org_id> 2026-08 --refresh # metrics only
1261
+ flowiq report deck build <org_id> 2026-08 --refresh # metrics only; status then says "Copy is older than the numbers" until you narrate
1229
1262
  flowiq report deck narrate <org_id> 2026-08 # copy only (hand edits are kept)
1230
1263
  flowiq report deck inputs <org_id> 2026-08 --file inputs.json # the super-admin input form
1231
1264
  flowiq report deck approve <org_id> 2026-08 # refused until every required input is present
@@ -1673,13 +1706,14 @@ flowiq agent config <organization_id> --use-settings-prompt --model gpt-5.6-luna
1673
1706
  --rename Zara --tool woo_order_build=true --tool view_cart_tool=true --discount true
1674
1707
  flowiq agent config <organization_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"
1675
1708
  flowiq agent config <organization_id> --model gpt-5.6-luna --reasoning-effort high
1709
+ flowiq agent config <organization_id> --model claude-sonnet-5 --reasoning-effort medium # Claude (needs the org's Claude key; warns if missing)
1676
1710
  flowiq agent config <organization_id> --disable-base-tool get_product_info
1677
1711
  flowiq agent config <organization_id> --enable-base-tool get_product_info
1678
1712
  ```
1679
1713
 
1680
1714
  Settable: `settings.use_settings_prompt`, `settings.model`,
1681
- `settings.reasoning_effort` (`--reasoning-effort low|medium|high`, `""` clears it —
1682
- pairs with reasoning models like `gpt-5.6-luna`), agent `--rename`,
1715
+ `settings.reasoning_effort` (`--reasoning-effort low|medium|high|xhigh|max`, `""` clears it —
1716
+ pairs with reasoning models like `gpt-5.6-luna`; `max` is Claude only), agent `--rename`,
1683
1717
  the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
1684
1718
  `view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
1685
1719
  `shopify_products_web_chat`, `ticket_tool_status`, `product_lookup`,
@@ -1734,18 +1768,30 @@ number already maps to an existing contact, the command prints a `⚠` warning
1734
1768
  naming it. Pass `--test-contact-number ""` (empty) to clear the override — the
1735
1769
  harness then falls back to a synthetic per-agent `test_<agent_id>` contact.
1736
1770
 
1737
- ### Agent updates — `flowiq agent-updates pull|list|resolve` (alias `au`)
1771
+ ### Agent updates — `flowiq agent-updates all|pull|list|resolve` (alias `au`)
1738
1772
 
1739
- Pull an org's client-raised "change the agent" requests (`agent_updates`),
1740
- each with the chat context around the triggering message + the linked contact
1741
- (attachments downloaded locally) — then, once the fix is verified, resolve the
1742
- ticket from the same terminal.
1773
+ Client-raised "change the agent" requests (`agent_updates`). **`all`** is the
1774
+ cross-org queue: every org in ONE call (about a second), grouped by org, no
1775
+ UUID needed. **`pull`** is the deep read for one org: each update with the chat
1776
+ context around the triggering message + the linked contact (attachments
1777
+ downloaded locally). Once a fix is verified, resolve the ticket from the same
1778
+ terminal.
1743
1779
 
1744
1780
  ```bash
1781
+ # every org at once (v0.9.3)
1782
+ flowiq au all # open = pending + in_progress, grouped by org
1783
+ flowiq au all --status all --since 14d # everything raised in the last 14 days
1784
+ flowiq au all --priority urgent,high --summary # just the per-org counts
1785
+ flowiq au all --search wallet --status all # title OR description contains "wallet"
1786
+ flowiq au all --org <uuid>,<uuid> --context # a few orgs, with the chat window around each trigger
1787
+ flowiq au all --json | jq '.updates[] | {org: .organization_name, title}'
1788
+ # → ./.flowiq/agent-updates/_all.<status>.json (aliases: `au queue`, `au list-remote`)
1789
+
1790
+ # one org in depth
1745
1791
  flowiq au pull <organization_id> # pending, with context
1746
1792
  flowiq au pull <organization_id> --status all --titles "EFT, bank"
1747
1793
  flowiq au pull <organization_id> --before 20 --after 10 --no-files
1748
- # → ./.flowiq/agent-updates/<slug>.json + files/<update-id>/<attachment>
1794
+ # → ./.flowiq/agent-updates/<slug>.json (pending) or <slug>.<status>.json + files/<update-id>/<attachment>
1749
1795
 
1750
1796
  flowiq au resolve <update_id> --note "We updated the agent so it no longer …"
1751
1797
  flowiq au resolve <update_id> --status declined --internal "duplicate of …"
@@ -1757,6 +1803,20 @@ flowiq au resolve <update_id> --status declined --internal "duplicate of …"
1757
1803
  the LAST step, after a live test proves the fix. Don't resolve on hope.
1758
1804
  - An interactive confirm shows exactly what the client will read; `--yes`
1759
1805
  skips the confirm (but never the `--note` requirement).
1806
+ - `all` flags: `--status` (`open` default, or any of `pending,in_progress,
1807
+ resolved,declined`, or `all`), `--since` / `--until` (`14d`, `12h`, `2w` or
1808
+ `YYYY-MM-DD`, SAST day start), `--priority`, `--org` (repeatable),
1809
+ `--titles`, `--search`, `--limit` (default 1000, max 5000; a ⚠ TRUNCATED
1810
+ line says when more matched), `--context` (+ `--before/--after/--recent`),
1811
+ `--files`, `--summary`, `--no-desc`, `--out`, `--json`. No context window and
1812
+ no attachment download unless asked, which is what keeps it fast.
1813
+ - Every update (in `all` and `pull`) names the **agent that answers that
1814
+ conversation**: the contact's binding, else the agent on the update, else the
1815
+ org's active agent, with a ⚠ when it is not the active one, so a fix goes to
1816
+ the right agent.
1817
+ - `pull` builds the context windows in parallel (v0.9.3), so `--status all` no
1818
+ longer times out on orgs with long ticket histories, and `--no-files` really
1819
+ skips downloads.
1760
1820
 
1761
1821
  ### Broadcast planning — `flowiq plans list|show|status` (alias `planning`) (v0.7.3)
1762
1822
 
@@ -1767,6 +1827,8 @@ and Flowapt reviews), from the terminal.
1767
1827
  flowiq plans list # open plans across every ACTIVE client
1768
1828
  flowiq plans list --status pending_review # waiting for Flowapt review
1769
1829
  flowiq plans list <organization_id> --status all --since 2026-09-01
1830
+ flowiq plans list --created-since 2026-09-22 # what clients submitted TODAY (v0.9.1)
1831
+ flowiq plans list --touched-since 2026-09-20 # anything edited or reviewed since
1770
1832
  flowiq plans show <plan_id> # copy, second message, buttons, creative, audience, notes
1771
1833
  flowiq plans show <plan_id> --json --out plan.json
1772
1834
  flowiq plans status <plan_id> --to approved # dry run
@@ -1779,6 +1841,9 @@ flowiq plans status <plan_id> --to sent --commit
1779
1841
  `--status all` / a status / a comma list to widen or narrow, `--since` to
1780
1842
  filter by send date, `--include-inactive` for inactive orgs. The header
1781
1843
  shows the totals for every status, and each row prints the full plan id.
1844
+ **`--created-since` / `--touched-since` (v0.9.1)** filter the ROW's own
1845
+ timestamps instead of the send date (SAST day start) — "what did a client
1846
+ submit today", "what has moved since Friday", which `--since` cannot answer.
1782
1847
  - **`show`** prints the plan the way the dialog does: message copy, the
1783
1848
  second message sent when a button is tapped, buttons with their URLs, the
1784
1849
  creative (file name + link), audience, template link, the response to the
@@ -1840,7 +1905,7 @@ version you have installed.
1840
1905
  | `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (local dev, staging). |
1841
1906
  | `FLOWIQ_TOKEN` | (saved in `~/.config/flowiq/auth.json`) | Override the auth token, useful for CI. |
1842
1907
  | `FLOWIQ_NO_UPDATE_CHECK` | (unset) | Set to `1` to silence the "you're behind" update hint (also honours `NO_UPDATE_NOTIFIER=1` and any `CI` env). |
1843
- | `FLOWMOD_EVO_DB_URL` | (none) | Full `postgres://` URL for `groups` (Evolution DB). Required for `groups`. |
1908
+ | `FLOWMOD_EVO_DB_URL` | (none) | Full `postgres://` URL for the DEPRECATED `groups --archive` (Evolution DB, frozen since 29 Aug 2026). Not needed for anything else. |
1844
1909
  | `FLOWMOD_DB_HOST/PORT/USER/PASS/NAME` | (none) | Alternative to `FLOWMOD_EVO_DB_URL` — assembled into a connection string. |
1845
1910
 
1846
1911
  ## Upgrading
package/TEAM-GUIDE.md CHANGED
@@ -96,6 +96,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
96
96
  | **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
97
97
  | See broadcast plans waiting for Flowapt review, across every client | `flowiq plans list --status pending_review`, then `flowiq plans show <plan_id>` for the copy, second message, buttons, creative and audience |
98
98
  | See one client's broadcast plans | `flowiq plans list <org_id>` (open plans) or `flowiq plans list <org_id> --status all --since 2026-09-01` |
99
+ | "What did clients submit today?" | `flowiq plans list --created-since 2026-09-22` (v0.9.1) — `--since` filters the SEND date, `--created-since` / `--touched-since` filter when the plan itself was created or last changed |
99
100
  | Mark a broadcast plan approved, scheduled or sent | `flowiq plans status <plan_id> --to sent` (a dry run), then add `--commit`. Rejecting needs `--comment "..."`, which the client reads |
100
101
  | What's the stock on a product, per branch? | `flowiq shopify stock <org_id> "olive oil"` — shows each location by NAME, and says "not tracked" rather than a confusing 0 |
101
102
  | Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
@@ -109,6 +110,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
109
110
  | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` — a new agent arrives ready to work (gpt-5.6-luna, high reasoning, prompt switched ON); no follow-up `agent config` needed |
110
111
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
111
112
  | Set the house model + reasoning tier | `flowiq agent config <org_id> --model gpt-5.6-luna --reasoning-effort high` |
113
+ | Run the agent on Claude (org needs a Claude key in Settings → Integrations) | `flowiq agent config <org_id> --model claude-sonnet-5 --reasoning-effort medium` |
112
114
  | Remove a conflicting built-in tool from one agent | `flowiq agent config <org_id> --disable-base-tool get_product_info` (repeatable; safety/delivery tools cannot be disabled) |
113
115
  | Agent says an in-stock product "isn't showing" | `flowiq agent config <org_id> --tool collapse_product_variants=true` — the search cap counts VARIANT rows until this is on |
114
116
  | Agent can't quote ANY price / "I can't pull the live menu" | `flowiq agent config <org_id> --tool product_lookup=true` — name-based fuzzy lookup that works without embeddings or a live OpenAI key (semantic `get_product_info` needs both) |
@@ -170,7 +172,10 @@ several.
170
172
  | Send the NEXT batch of a campaign that already has batches 01-12 | `flowiq seg plan <org_id> --tag-prefix f500 --ids-file next100.txt --batch-size 100 --start-index 13` → `flowiq seg apply <org_id> f500 --commit` (makes `f500-batch-13`; without `--start-index` the plan is REFUSED, because it would re-use `f500-batch-01` and merge the two cohorts) |
171
173
  | Split a whole tagged audience into batches of N (e.g. 90k → 7000s) | `flowiq seg plan <org_id> --tag-prefix 3-aug-bc --from-tag "3-aug-bc" --batch-size 7000` → `flowiq seg apply <org_id> 3-aug-bc --commit --yes` (makes `…-batch-01…13`; works at any size — big applies are chunked internally) |
172
174
  | Schedule a broadcast for later instead of sending now | `flowiq bc send <org_id> --tag <batch-tag> --template <name> --at "2026-08-05 09:00" --commit` (time is SAST; fires on its own; audience resolved at send time). **Works with --csv too (v0.6.1):** rows import+tag immediately, the send queues server-side, and it shows on the dashboard's Scheduled sends page. Nothing depends on your laptop being on at send time. |
173
- | See / approve / cancel what's scheduled | `flowiq bc scheduled list <org_id>` → `flowiq bc scheduled approve <org_id> <queue_id>` or `flowiq bc scheduled cancel <org_id> <queue_id> --confirm` |
175
+ | See / approve / cancel what's scheduled | `flowiq bc scheduled list <org_id>` → `flowiq bc scheduled approve <org_id> <queue_id>` or `flowiq bc scheduled cancel <org_id> <queue_id> --confirm`. The plain list shows only what is still OPEN; it now tells you how many fired / cancelled rows it is hiding, and `--all` shows them with who scheduled each one and how big the audience was |
176
+ | "Did a broadcast go out, and who scheduled it?" | `flowiq bc scheduled list <org_id> --all` — every queued send ever, with `via flowiq-cli (name@flowapt.com)`, the audience at schedule time, when it fired and its broadcastId |
177
+ | Find a broadcast someone half-built and saved but never sent | `flowiq bc drafts <org_id>` → `flowiq bc drafts show <org_id> <draft_id>` (v0.9.1). These are the send dialog's SAVED DRAFTS — a draft is not scheduled and will never fire on its own |
178
+ | Read a client's WhatsApp GROUP chat | **Not the CLI.** `flowiq groups` is deprecated — Evolution was retired on 29 Aug 2026 and its copy is frozen. Ask Claude to read it with the WhatsApp MCP (`whatsapp-flowapt`, then `whatsapp-flowmod`) |
174
179
  | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`). Sending a **different template to the SAME tag**? Add a distinct `--campaign <name>` — otherwise the CLI aborts (a campaign belongs to one template; reusing it would send the first template's image + skip everyone it already reached). |
175
180
  | Send a broadcast whose template has an IMAGE/VIDEO/DOCUMENT header | Same as above — the media is automatic (the template's own stored header). Override with `--header-media <public-url>` if needed. The CLI verifies the resolved media's actual type against the header format — `header video (video ✓ video/mp4)` means verified; a mismatch (e.g. a video template whose stored default is secretly a png — templates made before 3 Aug 2026 can carry this) ABORTS and tells you to pass `--header-media` with the real file. |
176
181
  | Send a **CAROUSEL** template (v0.4.9) | Tag mode only: `flowiq bc send <org_id> --tag <batch-tag> --template <carousel_name> --body param1="Hi {{first_name}}" --commit`. Card images are automatic (each card's stored template image, type-verified per card); override with repeatable `--card-media <url>` (one per card, in order). If the cards carry `{{n}}` body variables or URL-button variables, the CLI tells you exactly what to put in a `--cards-file <path>` JSON (one entry per card: `header_media` / `body_params` / `button_payloads` / `url_vars`). Always sends via the python engine (any size); no CSV mode for carousels. |
@@ -196,7 +201,8 @@ several.
196
201
  | Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
197
202
  | Manage outbound messaging webhooks (incl. their auth) | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
198
203
  | Create a brand-new client org | `flowiq org create --name "Client Name"` → then `agent create` on the printed id |
199
- | See a client's pending change requests | `flowiq au pull <org_id>` |
204
+ | See EVERY client's open change requests at once | `flowiq au all` (grouped by org; `--since 14d`, `--priority urgent,high`, `--search <text>`, `--status all`, `--summary`, `--json`) |
205
+ | See one client's pending change requests, with the chat around each | `flowiq au pull <org_id>` |
200
206
  | Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
201
207
  | Check an org's platform + active agent | `flowiq org info <org_id>` |
202
208
  | **See every Settings → Profile switch on an org** | `flowiq org flags show <org_id>` (credentials are never shown) |
@@ -208,8 +214,9 @@ several.
208
214
  | Store insights daily without emailing anyone (build history first) | `flowiq insights enable <org_id> --daily --store-only` |
209
215
  | Send one insights report right now | `flowiq insights run <org_id> --period weekly --recipient you@flowapt.com` (dry run) → add `--commit` |
210
216
  | Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
211
- | Log hours you worked for a client (every package = 10h Flowapt work/month) | `flowiq hours log <org_id> --hours 1.5 --desc "what you did"` — plain language, the client sees it on their Client Console |
212
- | Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` |
217
+ | Log hours you worked for a client (against the client's own monthly package) | `flowiq hours log <org_id> --hours 1.5 --desc "what you did"` — plain language, the client sees it on their Client Console |
218
+ | Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` (a shared package, like Barkyn's three stores, shows the total across all of them) |
219
+ | See what hours package a client is on | `flowiq hours package show <org_id>` (no org = every package). Setting one is Matt or Gidon only: `flowiq hours package set <org_id> --hours 5` |
213
220
  | Generate a client's monthly deck (metrics + copy) after the month has frozen | `flowiq report deck generate <org_id> 2026-08` — then review it at /reporting/deck |
214
221
  | See whether a client deck can be approved, and what is still missing | `flowiq report deck status <org_id> 2026-08` |
215
222
  | Send yourself a test copy of a client deck (PDF from flowiq@flowapt.com) | `flowiq report deck send <org_id> 2026-08 --test-to you@flowapt.com` — status untouched |
@@ -252,7 +259,9 @@ validates names, URLs, methods and parameter shapes. Unknown keys and unresolved
252
259
  object with no required fields remains a warning because it can be intentional.
253
260
  Nested object/array schemas are strict and supported. For ChatCart retailer tools, use
254
261
  `{{retailer_tools_internal_key}}` only as the `x-api-key` auth value on the
255
- trusted `express.chatcart.io/retailer-tools/*` gateway; org/contact identity is
262
+ trusted `express.chatcart.io/retailer-tools/*` gateway. A tool that schedules a
263
+ send through `add-to-api-queue` authenticates with `{{flowiq_queue_key}}` as its
264
+ bearer token (the queue refuses the public anon key); it works nowhere else; org/contact identity is
256
265
  injected server-side and mutations use `{{operation_idempotency_key}}` for stable,
257
266
  operation-scoped idempotency. Batch independent product requests in one
258
267
  `search_retailer_products.queries` array (1–12) instead of repeated search calls.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.3",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
- // `flowiq agent-updates pull <org_id> [flags]` / `list`.
1
+ // `flowiq agent-updates pull <org_id> [flags]` / `all` / `list` / `resolve`.
2
2
  // Read-only: fetches an org's agent_updates + chat context via /cli/agent-updates,
3
3
  // then downloads any attachments client-side into ./.flowiq/agent-updates/files/.
4
+ // `all` (v0.9.3) is the cross-org queue: one server query over every org.
4
5
 
5
6
  import fs from "node:fs/promises";
6
7
  import path from "node:path";
@@ -81,7 +82,9 @@ export async function pull(orgId, opts = {}) {
81
82
  process.exit(1);
82
83
  }
83
84
 
84
- const downloadFiles = !opts.noFiles;
85
+ // Commander turns --no-files into `files: false` (the old `noFiles` read was
86
+ // never set, so attachments downloaded regardless — gap row 14 Sep 2026).
87
+ const downloadFiles = opts.files !== false && !opts.noFiles;
85
88
  const maxMb = opts.maxMb != null ? Number.parseFloat(opts.maxMb) : DEFAULT_MAX_MB;
86
89
  if (!Number.isFinite(maxMb) || maxMb <= 0) {
87
90
  console.error(`Invalid --max-mb: ${opts.maxMb}`);
@@ -116,7 +119,10 @@ export async function pull(orgId, opts = {}) {
116
119
 
117
120
  await fs.mkdir(AU_DIR, { recursive: true });
118
121
  const slug = slugify(resp.organization_name, resp.organization_id);
119
- const filePath = path.join(AU_DIR, `${slug}.json`);
122
+ // A non-default status gets its own file, so pulling in_progress no longer
123
+ // overwrites the pending snapshot (gap rows 18 + 21 Sep 2026).
124
+ const statusKey = String(resp.status_filter || "pending");
125
+ const filePath = path.join(AU_DIR, statusKey === "pending" ? `${slug}.json` : `${slug}.${slugify(statusKey, "status")}.json`);
120
126
  const overwriting = await fileExists(filePath);
121
127
  await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
122
128
 
@@ -138,6 +144,7 @@ export async function pull(orgId, opts = {}) {
138
144
  console.log(` priority=${u.priority} status=${u.status} raised=${date}${u.created_by_name ? ` by ${u.created_by_name}` : ""}`);
139
145
  console.log(` desc: ${oneLine(u.description, 140)}`);
140
146
  if (u.contact) console.log(` contact: ${u.contact.full_name ?? "(no name)"} (${u.contact.whatsapp_id ?? u.contact.id})`);
147
+ if (u.agent) console.log(` agent: ${agentLine(u.agent)}`);
141
148
  if (u.attachments && u.attachments.length) {
142
149
  console.log(` attachments: ${u.attachments.length} (${u.attachments.map((a) => a.filename || a.type || "file").join(", ")})`);
143
150
  for (const a of u.attachments) {
@@ -158,6 +165,112 @@ export async function pull(orgId, opts = {}) {
158
165
  console.log(`${path.join(AU_DIR, FILES_DIRNAME)}/<update-id>/ — Read those files for visual context, then act.`);
159
166
  }
160
167
 
168
+ function agentLine(a) {
169
+ const src = { update: "set on the update", contact_binding: "contact is bound to it", org_active: "org's active agent" }[a.source] || a.source;
170
+ const warn = a.is_active ? "" : ` ⚠ NOT the active agent (${a.active_agent_name ?? a.active_agent_id ?? "none"}) — fix THIS agent`;
171
+ return `${a.name ?? a.id} (${src})${warn}`;
172
+ }
173
+
174
+ function sastStamp(iso) {
175
+ if (!iso) return "?";
176
+ const d = new Date(new Date(iso).getTime() + 2 * 3600e3);
177
+ return d.toISOString().slice(0, 16).replace("T", " ");
178
+ }
179
+
180
+ const PRIORITY_MARK = { urgent: "‼", high: "!", medium: "·", low: " " };
181
+
182
+ // --- all (cross-org queue) --------------------------------------------------
183
+ // One server query across every org: no per-org UUIDs, no context window and
184
+ // no attachment downloads unless asked, so the whole open queue is one call.
185
+ export async function all(opts = {}) {
186
+ const query = { scope: "all", status: opts.status || "open" };
187
+ if (opts.since) query.since = opts.since;
188
+ if (opts.until) query.until = opts.until;
189
+ if (opts.priority) query.priority = opts.priority;
190
+ if (opts.org && opts.org.length) query.org = [].concat(opts.org).join(",");
191
+ if (opts.titles) query.titles = opts.titles;
192
+ if (opts.search) query.search = opts.search;
193
+ if (opts.limit) query.limit = String(opts.limit);
194
+ if (opts.context) {
195
+ query.context = "1";
196
+ if (opts.before != null) query.before = String(opts.before);
197
+ if (opts.after != null) query.after = String(opts.after);
198
+ if (opts.recent != null) query.recent = String(opts.recent);
199
+ }
200
+
201
+ let resp;
202
+ try {
203
+ resp = await http.get("agent-updates", query);
204
+ } catch (e) {
205
+ console.error(`Pull failed: ${e.message}`);
206
+ process.exit(1);
207
+ }
208
+
209
+ if (opts.files) {
210
+ const maxMb = opts.maxMb != null ? Number.parseFloat(opts.maxMb) : DEFAULT_MAX_MB;
211
+ const maxBytes = Math.round(maxMb * 1024 * 1024);
212
+ for (const u of resp.updates || []) {
213
+ if (!(u.attachments || []).length && !u.superadmin_response_image) continue;
214
+ try { await downloadUpdateFiles(u, maxBytes); } catch (e) { u._file_download_error = e.message; }
215
+ }
216
+ }
217
+
218
+ if (opts.json) {
219
+ process.stdout.write(JSON.stringify(resp, null, 2) + "\n");
220
+ return;
221
+ }
222
+
223
+ await fs.mkdir(AU_DIR, { recursive: true });
224
+ const statusKey = Array.isArray(resp.filters?.status) ? resp.filters.status.join("-") : String(resp.filters?.status ?? "all");
225
+ const filePath = opts.out ? path.resolve(opts.out) : path.join(AU_DIR, `_all.${slugify(statusKey, "all")}.json`);
226
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
227
+
228
+ const updates = resp.updates || [];
229
+ const f = resp.filters || {};
230
+ console.log(`Wrote ${filePath} (${resp.elapsed_ms ?? "?"} ms server)`);
231
+ console.log(` status: ${Array.isArray(f.status) ? f.status.join(", ") : f.status}` +
232
+ `${f.since ? ` · since ${sastStamp(f.since)} SAST` : ""}${f.until ? ` · until ${sastStamp(f.until)}` : ""}` +
233
+ `${f.priority ? ` · priority ${f.priority.join(",")}` : ""}${f.search ? ` · search "${f.search}"` : ""}` +
234
+ `${f.titles ? ` · titles ${f.titles.join("|")}` : ""}${f.org ? ` · ${f.org.length} org(s)` : ""}`);
235
+ console.log(` updates: ${resp.returned} across ${(resp.by_org || []).length} org(s)` +
236
+ (resp.truncated ? ` ⚠ TRUNCATED: ${resp.total_matched} matched, raise --limit` : ""));
237
+ if (!updates.length) { console.log(" (nothing matches)"); return; }
238
+
239
+ console.log("");
240
+ console.log(" By org (count · pending · in progress · urgent/high · oldest):");
241
+ for (const b of resp.by_org || []) {
242
+ const age = Math.floor((Date.now() - new Date(b.oldest_created_at).getTime()) / 86400e3);
243
+ console.log(` ${String(b.count).padStart(4)} ${String(b.pending).padStart(3)} ${String(b.in_progress).padStart(3)} ${String(b.urgent_or_high).padStart(3)} ${String(age + "d").padStart(5)} ${b.organization_name ?? b.organization_id}`);
244
+ }
245
+ if (opts.summary) return;
246
+
247
+ const byOrg = new Map();
248
+ for (const u of updates) {
249
+ if (!byOrg.has(u.organization_id)) byOrg.set(u.organization_id, []);
250
+ byOrg.get(u.organization_id).push(u);
251
+ }
252
+ for (const b of resp.by_org || []) {
253
+ const list = byOrg.get(b.organization_id) || [];
254
+ console.log("");
255
+ console.log(` ── ${b.organization_name ?? b.organization_id} (${b.organization_id})`);
256
+ for (const u of list) {
257
+ const mark = PRIORITY_MARK[u.priority] ?? "?";
258
+ const st = u.status === "pending" ? "" : ` [${u.status}]`;
259
+ console.log(` ${mark} ${u.id.slice(0, 8)} ${String(u.age_days + "d").padStart(4)} ${oneLine(u.title, 70)}${st}`);
260
+ const bits = [];
261
+ if (u.created_by_name) bits.push(`by ${u.created_by_name}`);
262
+ if (u.contact) bits.push(`contact ${u.contact.full_name ?? u.contact.whatsapp_id ?? u.contact.id}`);
263
+ if ((u.attachments || []).length) bits.push(`${u.attachments.length} attachment(s)`);
264
+ if (u.agent && !u.agent.is_active) bits.push(`⚠ agent ${u.agent.name ?? u.agent.id} is NOT active`);
265
+ if (bits.length) console.log(` ${bits.join(" · ")}`);
266
+ if (opts.desc !== false && u.description) console.log(` ${oneLine(u.description, 150)}`);
267
+ }
268
+ }
269
+ console.log("");
270
+ console.log("Full text, contacts, agents" + (opts.context ? ", chat context" : "") + " are in the JSON. Drill into one org with");
271
+ console.log("`flowiq au pull <org_id>` (chat context + attachments), then `flowiq au resolve <id> --note …`.");
272
+ }
273
+
161
274
  export async function list() {
162
275
  let files;
163
276
  try {
@@ -1791,7 +1791,21 @@ export async function scheduledList(orgId, opts = {}) {
1791
1791
  });
1792
1792
  } catch (e) { console.error(`Scheduled list failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1793
1793
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
1794
- if (!resp.scheduled?.length) { console.log(`No ${opts.all ? "" : "open "}scheduled broadcasts on ${resp.organization_name}.`); return; }
1794
+ // An empty list must say what it is HIDING. "No open scheduled broadcasts"
1795
+ // read as "this org has never scheduled anything" on an org with four fired
1796
+ // rows (Johnson Fitness, 22 Sep 2026) and sent that session to raw SQL.
1797
+ const counts = resp.status_counts ?? {};
1798
+ const shownFilter = opts.all ? "all" : (opts.status || "open");
1799
+ const hidden = Object.entries(counts)
1800
+ .filter(([st]) => shownFilter === "all" ? false : (shownFilter === "open" ? !["pending", "request"].includes(st) : st !== shownFilter))
1801
+ .map(([st, n]) => `${n} ${st}`);
1802
+ if (!resp.scheduled?.length) {
1803
+ console.log(`No ${opts.all ? "" : `${shownFilter} `}scheduled broadcasts on ${resp.organization_name}.`);
1804
+ if (hidden.length) {
1805
+ console.log(` ${hidden.join(" · ")} row(s) exist on this org — see them with: flowiq bc scheduled list ${orgId} --all`);
1806
+ }
1807
+ return;
1808
+ }
1795
1809
  console.log(`Scheduled broadcasts on ${resp.organization_name}:`);
1796
1810
  for (const s of resp.scheduled) {
1797
1811
  const flag = s.stalled ? " ⚠ AWAITING APPROVAL — will NOT fire" : "";
@@ -1806,6 +1820,62 @@ export async function scheduledList(orgId, opts = {}) {
1806
1820
  }
1807
1821
  console.log("");
1808
1822
  console.log(` ${resp.count} row(s). Approve: flowiq bc scheduled approve <org> <id> · Cancel: … cancel <org> <id>`);
1823
+ if (hidden.length) console.log(` not shown: ${hidden.join(" · ")} (add --all)`);
1824
+ }
1825
+
1826
+ /** List the org's SAVED SEND DRAFTS (broadcast_send_drafts). Read-only. */
1827
+ export async function draftsList(orgId, opts = {}) {
1828
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
1829
+ let resp;
1830
+ try {
1831
+ resp = await http.post("broadcast", { action: "drafts-list", organization_id: orgId, limit: opts.limit });
1832
+ } catch (e) { console.error(`Drafts list failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1833
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
1834
+ if (!resp.drafts?.length) { console.log(`No saved send drafts on ${resp.organization_name}.`); return; }
1835
+ console.log(`Saved send drafts on ${resp.organization_name} (newest first):`);
1836
+ for (const d of resp.drafts) {
1837
+ console.log("");
1838
+ console.log(` ${d.id}`);
1839
+ console.log(` ${d.name || "(unnamed)"} · template ${d.template_name ?? "?"}${d.mode ? ` · ${d.mode}` : ""}${d.engine ? ` · ${d.engine}` : ""}`);
1840
+ const aud = [];
1841
+ if (d.tags?.length) aud.push(`tags ${d.tags.join(", ")}`);
1842
+ if (d.segment) aud.push(`segment ${d.segment}`);
1843
+ if (d.recipient_count != null) aud.push(`${d.recipient_count} recipient(s)`);
1844
+ if (!aud.length) aud.push("no audience chosen yet");
1845
+ if (aud.length) console.log(` ${aud.join(" · ")}`);
1846
+ if (d.scheduled_for) console.log(` scheduled for ${fmtSast(d.scheduled_for)} SAST`);
1847
+ console.log(` saved ${fmtSast(d.updated_at)} SAST${d.created_by_name ? ` by ${d.created_by_name}` : ""}`);
1848
+ }
1849
+ console.log("");
1850
+ console.log(` ${resp.count} draft(s). Full config: flowiq bc drafts show ${orgId} <draft_id>`);
1851
+ console.log(" A draft is NOT scheduled and will never fire on its own — it is a saved form in the send dialog.");
1852
+ }
1853
+
1854
+ /** One saved send draft in full. Read-only. */
1855
+ export async function draftsShow(orgId, draftId, opts = {}) {
1856
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
1857
+ if (!UUID_RE.test(draftId || "")) { console.error(`Error: "${draftId}" is not a valid draft id (get it from: flowiq bc drafts <org>).`); process.exit(1); }
1858
+ let resp;
1859
+ try {
1860
+ resp = await http.post("broadcast", { action: "drafts-show", organization_id: orgId, draft_id: draftId });
1861
+ } catch (e) { console.error(`Draft show failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1862
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
1863
+ const d = resp.draft;
1864
+ console.log(`${d.name || "(unnamed)"} — ${resp.organization_name}`);
1865
+ console.log(` id: ${d.id}`);
1866
+ console.log(` template: ${d.template_name ?? "?"}`);
1867
+ if (d.mode) console.log(` mode: ${d.mode}`);
1868
+ if (d.tags?.length) console.log(` tags: ${d.tags.join(", ")}`);
1869
+ if (d.segment) console.log(` segment: ${d.segment}`);
1870
+ if (d.recipient_count != null) console.log(` audience: ${d.recipient_count} recipient(s)`);
1871
+ if (d.engine) console.log(` engine: ${d.engine}`);
1872
+ if (d.carousel_cards) console.log(` carousel: ${d.carousel_cards} card override(s)`);
1873
+ if (d.scheduled_for) console.log(` scheduled: ${fmtSast(d.scheduled_for)} SAST (in the draft only — nothing is queued)`);
1874
+ if (d.header_media) console.log(` header: ${d.header_media}`);
1875
+ console.log(` saved: ${fmtSast(d.updated_at)} SAST${d.created_by_name ? ` by ${d.created_by_name}` : ""} · created ${fmtSast(d.created_at)} SAST`);
1876
+ console.log("");
1877
+ console.log("Stored config:");
1878
+ console.log(JSON.stringify(d.config ?? {}, null, 2).split("\n").map((l) => ` ${l}`).join("\n"));
1809
1879
  }
1810
1880
 
1811
1881
  /** Approve (request → pending) or cancel a scheduled broadcast. */
@@ -11,6 +11,16 @@
11
11
  // FLOWMOD_DB_HOST / FLOWMOD_DB_PORT / FLOWMOD_DB_USER / FLOWMOD_DB_PASS / FLOWMOD_DB_NAME
12
12
  //
13
13
  // Read-only: there is no `push` — group history is a system of record.
14
+ //
15
+ // ⛔ DEPRECATED (Matt, 22 Sep 2026): Evolution is fully deprecated. The flowapt
16
+ // instance logged out of WhatsApp on 29 Aug 2026 (`Instance.connectionStatus`
17
+ // 'close', reason 401) and has stored NOTHING since, so this command answers
18
+ // from a frozen archive while looking current — on 22 Sep it reported the
19
+ // Johnson Fitness group's last message as 17 Aug when the group had traffic
20
+ // that morning. READ A GROUP CHAT WITH THE WHATSAPP MCP:
21
+ // mcp__whatsapp-flowapt__list_chats / list_messages (then whatsapp-flowmod)
22
+ // Both verbs now REFUSE unless --archive is passed, because a stale read that
23
+ // looks complete is worse than an error.
14
24
 
15
25
  import fs from "node:fs/promises";
16
26
  import path from "node:path";
@@ -64,6 +74,32 @@ async function connect() {
64
74
  return client;
65
75
  }
66
76
 
77
+ // ---------- deprecation guard ----------
78
+ // Evolution is deprecated; the store is frozen. Refuse rather than answer with
79
+ // a stale window that reads as complete.
80
+ const ARCHIVE_FROZEN_NOTE =
81
+ "Evolution stopped syncing on 29 Aug 2026 (the flowapt instance was logged out), so anything after that date is MISSING here.";
82
+
83
+ function refuseUnlessArchive(opts, what) {
84
+ if (opts.archive) {
85
+ console.error("⚠ ARCHIVE READ — Evolution is deprecated and this data is FROZEN.");
86
+ console.error(` ${ARCHIVE_FROZEN_NOTE}`);
87
+ console.error(" Anything recent must come from the WhatsApp MCP (whatsapp-flowapt, then whatsapp-flowmod).");
88
+ console.error("");
89
+ return;
90
+ }
91
+ console.error(`⛔ \`flowiq groups ${what}\` is DEPRECATED — Evolution is no longer in use.`);
92
+ console.error(` ${ARCHIVE_FROZEN_NOTE}`);
93
+ console.error("");
94
+ console.error(" Read a group chat with the WhatsApp MCP instead:");
95
+ console.error(" mcp__whatsapp-flowapt__list_chats { query: \"<group name>\" }");
96
+ console.error(" mcp__whatsapp-flowapt__list_messages { chat_jid: \"…@g.us\", limit: 50 }");
97
+ console.error(" …and mcp__whatsapp-flowmod__* if the group is not on the flowapt line.");
98
+ console.error("");
99
+ console.error(" To read the frozen pre-29-Aug archive anyway, pass --archive.");
100
+ process.exit(1);
101
+ }
102
+
67
103
  // ---------- helpers ----------
68
104
  function slugify(name, fallback) {
69
105
  const s = String(name || "")
@@ -132,7 +168,10 @@ function extractText(m) {
132
168
  }
133
169
 
134
170
  async function resolveInstanceId(client, instanceName) {
135
- const r = await client.query('SELECT id, "profileName" FROM "Instance" WHERE name = $1', [instanceName]);
171
+ const r = await client.query(
172
+ 'SELECT id, "profileName", "connectionStatus", "updatedAt" FROM "Instance" WHERE name = $1',
173
+ [instanceName]
174
+ );
136
175
  if (!r.rows.length) {
137
176
  const all = await client.query('SELECT name FROM "Instance" ORDER BY name');
138
177
  console.error(
@@ -145,6 +184,7 @@ async function resolveInstanceId(client, instanceName) {
145
184
 
146
185
  // ---------- groups list ----------
147
186
  export async function list(opts = {}) {
187
+ refuseUnlessArchive(opts, "list");
148
188
  const instanceName = opts.instance || DEFAULT_INSTANCE;
149
189
  const client = await connect();
150
190
  try {
@@ -161,7 +201,12 @@ export async function list(opts = {}) {
161
201
  ORDER BY last_ts DESC NULLS LAST`,
162
202
  [inst.id]
163
203
  );
164
- console.log(`Instance "${instanceName}" (${inst.profileName ?? "?"}) — ${r.rows.length} group chats\n`);
204
+ // The instance's own state explains every stale row below it.
205
+ const connLabel = inst.connectionStatus === "open" ? "connected" : `${inst.connectionStatus} (not syncing)`;
206
+ console.log(
207
+ `Instance "${instanceName}" (${inst.profileName ?? "?"}) — ${r.rows.length} group chats · ${connLabel}` +
208
+ (inst.updatedAt ? ` since ${new Date(inst.updatedAt).toISOString().slice(0, 10)}` : "") + "\n"
209
+ );
165
210
  const rows = r.rows.map((x) => ({
166
211
  group: x.name || "(no name)",
167
212
  msgs: Number(x.msgs || 0),
@@ -181,6 +226,7 @@ export async function list(opts = {}) {
181
226
 
182
227
  // ---------- groups pull ----------
183
228
  export async function pull(nameOrJid, opts = {}) {
229
+ refuseUnlessArchive(opts, "pull");
184
230
  const instanceName = opts.instance || DEFAULT_INSTANCE;
185
231
  const limit = Math.max(1, Math.min(Number(opts.limit) || DEFAULT_LIMIT, 5000));
186
232
 
@@ -1,7 +1,9 @@
1
1
  // `flowiq hours log <org> --minutes N|--hours H --desc "…"` / `list <org>` /
2
- // `summary`. The client-hours ledger: every org's package includes 10 hours of
3
- // Flowapt work per calendar month, and every piece of client work gets logged
4
- // against it (manually here, or automatically by Claude via the FlowIQ MCP).
2
+ // `summary` / `package show|set|clear`. The client-hours ledger: every piece
3
+ // of client work gets logged against the org's monthly hours PACKAGE. Since
4
+ // v0.9.2 each org has its own package (or shares one with sibling orgs, e.g.
5
+ // Barkyn PT/ES/IT), set with `hours package set` by Matt or Gidon only; an org
6
+ // with no package reads "no package on file", never an implied 10h.
5
7
  // All writes happen server-side (/cli/hours) with the staff token.
6
8
 
7
9
  import { http } from "../http.js";
@@ -14,6 +16,24 @@ function fmtHours(minutes) {
14
16
  return `${Number.isInteger(h) ? h : h.toFixed(1)}h`;
15
17
  }
16
18
 
19
+ /** "5h package" / "10h package shared with Barkyn - Spain, Barkyn - Italy" / "no package on file". */
20
+ function describePackage(pkg, orgName) {
21
+ if (!pkg) return "no package on file (set one with `flowiq hours package set`)";
22
+ const size = `${fmtHours(pkg.allowance_minutes)}/month`;
23
+ if (!pkg.shared) return `${size} package`;
24
+ const others = (pkg.organizations || []).filter((n) => n !== orgName);
25
+ return `${size} package "${pkg.name}", shared with ${others.join(", ")}`;
26
+ }
27
+
28
+ /** "3h of 5h used (2h left)" / "⚠ 1.5h OVER" against a package; "" with none. */
29
+ function standingLine(pkg) {
30
+ if (!pkg) return "";
31
+ const used = pkg.used_minutes;
32
+ const allowance = pkg.allowance_minutes;
33
+ const tail = used > allowance ? ` ⚠ ${fmtHours(used - allowance)} OVER` : ` (${fmtHours(allowance - used)} left)`;
34
+ return `${fmtHours(used)} of ${fmtHours(allowance)} used${pkg.shared ? " across the shared package" : ""}${tail}`;
35
+ }
36
+
17
37
  function requireOrg(orgId) {
18
38
  if (!UUID_RE.test(orgId || "")) {
19
39
  console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <search>\`).`);
@@ -88,11 +108,11 @@ export async function log(orgId, opts) {
88
108
  process.exit(1);
89
109
  }
90
110
 
91
- const used = resp.month_total_minutes;
92
- const allowance = resp.allowance_hours * 60;
93
111
  console.log(`Logged ${fmtHours(minutes)} for ${resp.organization.name}`);
94
- console.log(` entry: ${resp.id}`);
95
- console.log(` month: ${resp.month} — ${fmtHours(used)} of ${resp.allowance_hours}h package used${used > allowance ? ` ⚠ ${fmtHours(used - allowance)} OVER` : ` (${fmtHours(allowance - used)} left)`}`);
112
+ console.log(` entry: ${resp.id}`);
113
+ console.log(` month: ${resp.month}: ${fmtHours(resp.month_total_minutes)} logged for this org`);
114
+ console.log(` package: ${describePackage(resp.package, resp.organization.name)}`);
115
+ if (resp.package) console.log(` ${standingLine(resp.package)}`);
96
116
  }
97
117
 
98
118
  export async function list(orgId, opts) {
@@ -109,9 +129,10 @@ export async function list(orgId, opts) {
109
129
  console.log(JSON.stringify(resp, null, 2));
110
130
  return;
111
131
  }
112
- const over = resp.remaining_minutes < 0;
113
132
  console.log(`${resp.organization.name} — ${resp.month}`);
114
- console.log(` package: ${resp.allowance_hours}h/month · used ${fmtHours(resp.total_minutes)} · ${over ? `⚠ ${fmtHours(-resp.remaining_minutes)} OVER` : `${fmtHours(resp.remaining_minutes)} left`}`);
133
+ console.log(` logged: ${fmtHours(resp.total_minutes)} for this org`);
134
+ console.log(` package: ${describePackage(resp.package, resp.organization.name)}`);
135
+ if (resp.package) console.log(` ${standingLine(resp.package)}`);
115
136
  if (!resp.entries.length) {
116
137
  console.log(" (no entries this month)");
117
138
  return;
@@ -138,13 +159,107 @@ export async function summary(opts) {
138
159
  return;
139
160
  }
140
161
  if (!resp.organizations.length) {
141
- console.log(`(no hours logged for any org in ${resp.month})`);
162
+ console.log(`(no hours logged and no packages set for ${resp.month})`);
142
163
  return;
143
164
  }
144
- const allowance = resp.allowance_hours * 60;
145
- console.log(`Client hours — ${resp.month} (package: ${resp.allowance_hours}h/month per org)\n`);
165
+ console.log(`Client hours — ${resp.month}\n`);
146
166
  for (const o of resp.organizations) {
147
- const flag = o.total_minutes > allowance ? ` ⚠ ${fmtHours(o.total_minutes - allowance)} OVER` : "";
148
- console.log(` ${fmtHours(o.total_minutes).padStart(6)} (${String(o.entries).padStart(2)} entr${o.entries === 1 ? "y" : "ies"}) ${o.name || o.id}${flag}`);
167
+ const p = o.package;
168
+ let pkgCol;
169
+ if (!p) pkgCol = "no package on file";
170
+ else if (p.shared) {
171
+ const flag = p.used_minutes > p.allowance_minutes ? ` ⚠ ${fmtHours(p.used_minutes - p.allowance_minutes)} OVER` : "";
172
+ pkgCol = `shared "${p.name}": ${fmtHours(p.used_minutes)} / ${fmtHours(p.allowance_minutes)}${flag}`;
173
+ } else {
174
+ const flag = p.used_minutes > p.allowance_minutes ? ` ⚠ ${fmtHours(p.used_minutes - p.allowance_minutes)} OVER` : "";
175
+ pkgCol = `of ${fmtHours(p.allowance_minutes)}${flag}`;
176
+ }
177
+ console.log(` ${fmtHours(o.total_minutes).padStart(6)} (${String(o.entries).padStart(3)} entr${o.entries === 1 ? "y " : "ies"}) ${(o.name || o.id).padEnd(28)} ${pkgCol}`);
178
+ }
179
+ }
180
+
181
+ // ── packages ───────────────────────────────────────────────────────────────
182
+
183
+ function printPackage(p) {
184
+ const who = p.updated_by ? ` · set by ${p.updated_by} ${String(p.updated_at).slice(0, 10)}` : "";
185
+ console.log(` ${p.name} · ${fmtHours(p.monthly_minutes)}/month${p.organizations.length > 1 ? " · SHARED" : ""}${who}`);
186
+ for (const o of p.organizations) console.log(` ${o.name} ${o.id}`);
187
+ if (p.notes) console.log(` notes: ${p.notes}`);
188
+ }
189
+
190
+ export async function packageShow(orgId, opts) {
191
+ if (orgId) requireOrg(orgId);
192
+ let resp;
193
+ try {
194
+ resp = await http.get("hours", { packages: "1" });
195
+ } catch (e) {
196
+ console.error(`Package lookup failed: ${e.message}`);
197
+ process.exit(1);
198
+ }
199
+ let packages = resp.packages;
200
+ if (orgId) packages = packages.filter((p) => p.organizations.some((o) => o.id === orgId));
201
+ if (opts.json) {
202
+ console.log(JSON.stringify(orgId ? { packages } : resp, null, 2));
203
+ return;
204
+ }
205
+ if (!packages.length) {
206
+ console.log(orgId ? "No package on file for this org." : "No packages set yet.");
207
+ } else {
208
+ console.log(`Hours packages (${packages.length})\n`);
209
+ for (const p of packages) printPackage(p);
210
+ }
211
+ console.log(`\nChanges: ${resp.editors.join(", ")} only${resp.can_edit ? " (you can)" : " (not you)"}.`);
212
+ }
213
+
214
+ export async function packageSet(orgIds, opts) {
215
+ for (const id of orgIds) requireOrg(id);
216
+ const hours = Number(opts.hours);
217
+ if (!Number.isFinite(hours) || hours < 0 || hours > 1000) {
218
+ console.error(`Error: --hours must be a number between 0 and 1000 (got "${opts.hours}").`);
219
+ process.exit(1);
220
+ }
221
+ let resp;
222
+ try {
223
+ resp = await http.post("hours", {
224
+ action: "package_set",
225
+ organization_ids: orgIds,
226
+ hours,
227
+ name: opts.name || undefined,
228
+ notes: opts.notes || undefined,
229
+ dry_run: !!opts.dryRun,
230
+ });
231
+ } catch (e) {
232
+ console.error(`Package change failed: ${e.message}`);
233
+ process.exit(1);
234
+ }
235
+ const plan = resp.plan;
236
+ const who = plan.organizations.map((o) => o.name).join(", ");
237
+ console.log(`${resp.dry_run ? "DRY RUN — nothing written.\n" : ""}${plan.mode === "update" ? "Update" : "New"} package "${plan.name}": ${fmtHours(plan.monthly_minutes)}/month for ${who}`);
238
+ for (const b of plan.before) {
239
+ console.log(` before: "${b.name}" ${fmtHours(b.monthly_minutes)}/month (${b.organizations.map((o) => o.name).join(", ")})`);
240
+ }
241
+ if (!plan.before.length) console.log(" before: no package on file");
242
+ for (const l of plan.left_behind) {
243
+ console.log(` ⚠ ${l.remaining.join(", ")} stay${l.remaining.length === 1 ? "s" : ""} on "${l.package}" without ${plan.organizations.length === 1 ? "this org" : "these orgs"}`);
244
+ }
245
+ if (resp.dry_run) console.log("\nRun again without --dry-run to apply.");
246
+ }
247
+
248
+ export async function packageClear(orgId, opts) {
249
+ requireOrg(orgId);
250
+ let resp;
251
+ try {
252
+ resp = await http.post("hours", { action: "package_clear", organization_id: orgId, dry_run: !!opts.dryRun });
253
+ } catch (e) {
254
+ console.error(`Package change failed: ${e.message}`);
255
+ process.exit(1);
256
+ }
257
+ if (resp.changed === false) {
258
+ console.log(`${resp.organization.name}: no package on file, nothing to clear.`);
259
+ return;
149
260
  }
261
+ const plan = resp.plan;
262
+ console.log(`${resp.dry_run ? "DRY RUN — nothing written.\n" : ""}${plan.organization.name} ${resp.dry_run ? "would leave" : "left"} package "${plan.package.name}" (${fmtHours(plan.package.monthly_minutes)}/month)`);
263
+ if (plan.package_removed) console.log(" the package had no other orgs, so it is removed");
264
+ else console.log(` still on it: ${plan.remaining.join(", ")}`);
150
265
  }
@@ -65,6 +65,8 @@ export async function list(orgId, opts = {}) {
65
65
  if (orgId) query.organization_id = orgId;
66
66
  if (opts.status) query.status = opts.status;
67
67
  if (opts.since) query.since = opts.since;
68
+ if (opts.createdSince) query.created_since = opts.createdSince;
69
+ if (opts.touchedSince) query.touched_since = opts.touchedSince;
68
70
  if (opts.includeInactive) query.include_inactive = "1";
69
71
  if (opts.limit != null) query.limit = String(opts.limit);
70
72
 
@@ -83,7 +85,12 @@ export async function list(orgId, opts = {}) {
83
85
  ? resp.organization_name
84
86
  : `all ${resp.include_inactive ? "" : "active "}orgs`;
85
87
  const totals = STATUS_ORDER.filter((s) => resp.counts?.[s]).map((s) => `${s} ${resp.counts[s]}`).join(" · ");
86
- console.log(`Broadcast plans · ${scope} · status ${resp.status_filter}${resp.since ? ` · from ${resp.since}` : ""}`);
88
+ const windowBits = [
89
+ resp.since ? `send date from ${resp.since}` : null,
90
+ resp.created_since ? `created from ${resp.created_since}` : null,
91
+ resp.touched_since ? `touched from ${resp.touched_since}` : null,
92
+ ].filter(Boolean);
93
+ console.log(`Broadcast plans · ${scope} · status ${resp.status_filter}${windowBits.length ? ` · ${windowBits.join(" · ")}` : ""}`);
87
94
  console.log(` every status in this scope: ${totals || "none"}`);
88
95
 
89
96
  const plans = resp.plans || [];
package/src/index.js CHANGED
@@ -223,13 +223,15 @@ export function run(argv) {
223
223
  // groups (read-only: pull WhatsApp GROUP chat history from the FlowMod Evolution DB)
224
224
  const groups = program.command("groups")
225
225
  .alias("grp")
226
- .description("Read WhatsApp group chat history from the FlowMod Evolution DB (set FLOWMOD_EVO_DB_URL env). Read-only.");
226
+ .description("DEPRECATED — Evolution is retired and frozen (nothing since 29 Aug 2026). Read group chats with the whatsapp-flowapt / whatsapp-flowmod MCP instead.");
227
227
  groups.command("list")
228
- .description("List an Evolution instance's group chats with message counts + last activity")
228
+ .description("[archive only] List the frozen Evolution instance's group chats — needs --archive")
229
229
  .option("--instance <name>", "Evolution instance name", "flowapt")
230
+ .option("--archive", "read the FROZEN pre-29-Aug-2026 archive anyway")
230
231
  .action((opts) => groupsCmd.list(opts));
231
232
  groups.command("pull <name-or-jid>")
232
- .description("Pull a group's messages in a time window into a local JSON file + print a timeline")
233
+ .description("[archive only] Pull a group's FROZEN message history into a local JSON file — needs --archive")
234
+ .option("--archive", "read the FROZEN pre-29-Aug-2026 archive anyway")
233
235
  .option("--instance <name>", "Evolution instance name", "flowapt")
234
236
  .option("--last <window>", "rolling window back from now, e.g. 48h, 7d, 2w, 90m", "48h")
235
237
  .option("--since <date>", "window start (SAST), YYYY-MM-DD or \"YYYY-MM-DD HH:MM\" (overrides --last)")
@@ -255,9 +257,9 @@ export function run(argv) {
255
257
  .description("List every task in the DB (optionally filter: open | in_progress | done) to find an id")
256
258
  .action((status) => pinboardCmd.listRemote(status));
257
259
 
258
- // hours (client-hours ledger — every org's package = 10h Flowapt work/month)
260
+ // hours (client-hours ledger + each org's monthly hours package, v0.9.2)
259
261
  const hours = program.command("hours")
260
- .description("Client-hours ledger: log + report Flowapt work against each org's 10h/month package");
262
+ .description("Client-hours ledger: log + report Flowapt work against each org's monthly hours package");
261
263
  hours.command("log <organization_id>")
262
264
  .description("Log work done for a client org (exactly one of --minutes / --hours)")
263
265
  .option("--minutes <n>", "duration in whole minutes")
@@ -273,10 +275,27 @@ export function run(argv) {
273
275
  .option("--json", "raw JSON output")
274
276
  .action((orgId, opts) => hoursCmd.list(orgId, opts));
275
277
  hours.command("summary")
276
- .description("Cross-org month totals — every org with logged work, vs the 10h package")
278
+ .description("Cross-org month totals: every org with logged work or a package, vs its package")
277
279
  .option("--month <YYYY-MM>", "which month to report")
278
280
  .option("--json", "raw JSON output")
279
281
  .action((opts) => hoursCmd.summary(opts));
282
+ const hoursPkg = hours.command("package")
283
+ .description("Each org's monthly hours package (one org, or several sharing one). Changes: Matt + Gidon only");
284
+ hoursPkg.command("show [organization_id]")
285
+ .description("Every package, or the one an org is on")
286
+ .option("--json", "raw JSON output")
287
+ .action((orgId, opts) => hoursCmd.packageShow(orgId, opts));
288
+ hoursPkg.command("set <organization_ids...>")
289
+ .description("Put these orgs on ONE package of --hours a month (several orgs = a shared package)")
290
+ .requiredOption("--hours <h>", "hours included per month (0 = none included, e.g. a self-service plan)")
291
+ .option("--name <text>", "package name (default: the org's name, or the names joined)")
292
+ .option("--notes <text>", "where the figure comes from, e.g. \"Order Form 12 Aug 2026\"")
293
+ .option("--dry-run", "show what would change, write nothing")
294
+ .action((orgIds, opts) => hoursCmd.packageSet(orgIds, opts));
295
+ hoursPkg.command("clear <organization_id>")
296
+ .description("Take an org off its package (back to 'no package on file')")
297
+ .option("--dry-run", "show what would change, write nothing")
298
+ .action((orgId, opts) => hoursCmd.packageClear(orgId, opts));
280
299
 
281
300
  // report deck — the monthly client deck (build / copy / approve / send)
282
301
  const report = program.command("report").description("Client reporting: the monthly 10-slide client deck");
@@ -474,7 +493,7 @@ export function run(argv) {
474
493
  .option("--agent <id>", "target a specific agent instead of the org's active one")
475
494
  .option("--use-settings-prompt [bool]", "settings.use_settings_prompt (true if bare)")
476
495
  .option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
477
- .option("--reasoning-effort <level>", "settings.reasoning_effort — low|medium|high (\"\" clears it); pairs with reasoning models (e.g. gpt-5.6-luna + high)")
496
+ .option("--reasoning-effort <level>", "settings.reasoning_effort — low|medium|high|xhigh|max (\"\" clears it; max = Claude only); pairs with reasoning models (e.g. gpt-5.6-luna + high, claude-sonnet-5 + medium)")
478
497
  .option("--rename <name>", "rename the agent")
479
498
  .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/product_lookup/email_request_tool/collapse_product_variants", agentConfigCmd.collectTool, [])
480
499
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
@@ -487,7 +506,7 @@ export function run(argv) {
487
506
  // agent-updates (pending client change-requests + chat context; pull + resolve)
488
507
  const au = program.command("agent-updates")
489
508
  .alias("au")
490
- .description("Pull pending agent_updates + the chat context around each; resolve a ticket with a client-facing note");
509
+ .description("Agent updates: `all` = every org's queue in one call; `pull` = one org with chat context + attachments; `resolve` with a client-facing note");
491
510
  au.command("pull <organization_id>")
492
511
  .description("Fetch updates + linked contact + trigger-message window; downloads attachments locally")
493
512
  .option("--status <status>", "pending (default) | resolved | in_progress | all")
@@ -498,6 +517,29 @@ export function run(argv) {
498
517
  .option("--no-files", "do NOT download attachments (default: download is ON)")
499
518
  .option("--max-mb <n>", "skip attachments larger than N MB (default 25)")
500
519
  .action((orgId, opts) => agentUpdatesCmd.pull(orgId, opts));
520
+ au.command("all")
521
+ .alias("queue")
522
+ .alias("list-remote")
523
+ .description("EVERY org's agent updates in one call (default: open = pending + in_progress), grouped by org; → ./.flowiq/agent-updates/_all.<status>.json")
524
+ .option("--status <list>", "open (default) | pending | in_progress | resolved | declined | all — comma-separated allowed")
525
+ .option("--since <when>", "raised on/after: 14d, 12h, 2w or YYYY-MM-DD (SAST day start)")
526
+ .option("--until <when>", "raised on/before (same formats)")
527
+ .option("--priority <list>", "urgent,high,medium,low (comma-separated)")
528
+ .option("--org <uuid>", "limit to these org(s) (repeatable or comma-separated)", (v, prev) => prev.concat(v.split(",")), [])
529
+ .option("--titles <list>", "title contains any of these (comma-separated, ci substring)")
530
+ .option("--search <text>", "title OR description contains this text (ci)")
531
+ .option("--limit <n>", "max updates returned (default 1000, max 5000)")
532
+ .option("--context", "also fetch the chat window around each trigger message (slower)")
533
+ .option("--before <n>", "with --context: messages before the trigger (default 6)")
534
+ .option("--after <n>", "with --context: messages after the trigger (default 4)")
535
+ .option("--recent <n>", "with --context: fallback window when no trigger message (default 10)")
536
+ .option("--files", "download attachments too (off by default across orgs)")
537
+ .option("--max-mb <n>", "with --files: skip attachments larger than N MB (default 25)")
538
+ .option("--summary", "print only the per-org counts")
539
+ .option("--no-desc", "hide the one-line description under each update")
540
+ .option("--out <path>", "write the JSON here instead of ./.flowiq/agent-updates/_all.<status>.json")
541
+ .option("--json", "print the JSON to stdout (no file, no table) — pipe into jq")
542
+ .action((opts) => agentUpdatesCmd.all(opts));
501
543
  au.command("list")
502
544
  .description("List local agent-updates snapshots")
503
545
  .action(() => agentUpdatesCmd.list());
@@ -517,7 +559,9 @@ export function run(argv) {
517
559
  plans.command("list [organization_id]")
518
560
  .description("List plans, default open (draft, pending_review, approved, scheduled), across all active orgs or one org")
519
561
  .option("--status <status>", "open (default) | all | pending_review | draft | approved | rejected | scheduled | sent | cancelled (comma-separated allowed)")
520
- .option("--since <date>", "only plans scheduled on or after YYYY-MM-DD")
562
+ .option("--since <date>", "only plans whose SEND date is on or after YYYY-MM-DD")
563
+ .option("--created-since <date>", "only plans CREATED on or after YYYY-MM-DD (SAST) — what a client just submitted")
564
+ .option("--touched-since <date>", "only plans UPDATED on or after YYYY-MM-DD (SAST)")
521
565
  .option("--include-inactive", "include plans from inactive orgs (cross-org listing)")
522
566
  .option("--limit <n>", "max plans returned (default 100, max 500)")
523
567
  .option("--json", "print the raw response")
@@ -736,6 +780,18 @@ export function run(argv) {
736
780
  .option("--confirm", "actually cancel")
737
781
  .action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "cancel", opts));
738
782
 
783
+ const drafts = broadcast.command("drafts")
784
+ .description("Saved SEND DRAFTS (the send dialog's stored configurations) — read-only");
785
+ drafts.command("list <organization_id>", { isDefault: true })
786
+ .description("List the org's saved send drafts, newest first")
787
+ .option("--limit <n>", "max rows (default 25, max 200)")
788
+ .option("--json", "raw JSON")
789
+ .action((orgId, opts) => broadcastCmd.draftsList(orgId, opts));
790
+ drafts.command("show <organization_id> <draft_id>")
791
+ .description("One saved send draft in full (the whole stored config)")
792
+ .option("--json", "raw JSON")
793
+ .action((orgId, draftId, opts) => broadcastCmd.draftsShow(orgId, draftId, opts));
794
+
739
795
  broadcast.command("list-remote <organization_id>")
740
796
  .description("List the org's broadcasts newest-first (full broadcastId + template + status + recipients) — the discovery step for `bc status` / `bc retry`")
741
797
  .option("--limit <n>", "how many to show (default 25, max 200)")