@flowapt/flowiq-cli 0.6.3 → 0.6.6

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
@@ -187,8 +187,8 @@ flowiq ct list
187
187
 
188
188
  - **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
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
- - Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, object-typed `parameters`/`headers`/`injected_parameters`, valid `auth_type`/`channels`. Recursive JSON schemas are supported, including arrays of objects and integer/min/max constraints. Bad payloads are rejected before anything writes.
191
- - **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime; known values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `supabase_anon_key`, `openai_api_key`).
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
+ - **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
192
  - **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.
193
193
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
194
194
 
@@ -485,6 +485,14 @@ flowiq seg untag <org_id> repeat-60d --commit --confirm # ROLLBACK (its own ta
485
485
  shuffle reproducible, `--ordered` keeps server order. `--from-attribute
486
486
  allow_broadcast_true --split 3` is the "whole broadcast list → 3 balanced
487
487
  tagged cohorts" one-liner — no id file, no database access.
488
+ - **`--start-index N` continues an existing batch series** (v0.6.4, 3 Sep 2026).
489
+ Batch tags are always numbered from `01`, so a campaign already holding
490
+ `f500-batch-01…12` could not get its 13th batch from the CLI — the plan would
491
+ have produced `f500-batch-01` again and, because apply is append-only, quietly
492
+ MERGED a new cohort into the oldest one. `--start-index 13` names the batch
493
+ `f500-batch-13`. `plan` now also **refuses outright** when any batch tag it
494
+ would create already holds contacts, printing the correct `--start-index` to
495
+ use; `--allow-existing-tag` overrides when the merge is deliberate.
488
496
  - Apply is **append-only** — it never touches a contact's other tags, names,
489
497
  or anything else, and never double-adds.
490
498
  - **Large cohorts are chunked server-side** (fixed 3 Aug 2026): the tagging RPC
@@ -943,6 +951,37 @@ flowiq hours summary # every org with logged work this
943
951
  - Corrections (delete/edit) live in the Changelog → Client hours page, not the
944
952
  CLI. Every `log` is audited.
945
953
 
954
+ ### Client deck — `flowiq report deck status|build|narrate|generate|inputs|approve|unapprove|send|print|pull` (v0.6.5)
955
+
956
+ The monthly 10-slide client deck (`/reporting/deck` in the app), driven from the
957
+ terminal. Everything runs server-side through the `flowiq-reporting-deck` edge
958
+ function: the metrics are built from the FROZEN month snapshot, the copy is
959
+ written by the model under the report rules and a numeric guard (every figure
960
+ in the copy must exist in the data), approval needs the required inputs, and
961
+ `send` renders the approved slides with headless Chrome and emails the PDF
962
+ from flowiq@flowapt.com.
963
+
964
+ ```bash
965
+ flowiq report deck status <org_id> 2026-08 # what exists, workflow status, what blocks approval
966
+ flowiq report deck generate <org_id> 2026-08 # build the metrics (if missing) + write the copy
967
+ flowiq report deck generate <org_id> 2026-08 --refresh --force # rebuild everything
968
+ flowiq report deck build <org_id> 2026-08 --refresh # metrics only
969
+ flowiq report deck narrate <org_id> 2026-08 # copy only (hand edits are kept)
970
+ flowiq report deck inputs <org_id> 2026-08 --file inputs.json # the super-admin input form
971
+ flowiq report deck approve <org_id> 2026-08 # refused until every required input is present
972
+ flowiq report deck send <org_id> 2026-08 --test-to me@flowapt.com # one test copy, status untouched
973
+ flowiq report deck send <org_id> 2026-08 --client # approved deck → configured client recipients
974
+ flowiq report deck print <org_id> 2026-08 # 15-minute print URL (what Chrome renders)
975
+ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/reports/<slug>-2026-08.json
976
+ ```
977
+
978
+ - Months freeze on the 1st (cron 82); a month with no snapshot cannot be built.
979
+ Comparisons need the prior month's deck, which `build` creates on the fly.
980
+ - `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true }`.
981
+ Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
982
+ - `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
983
+ - Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
984
+
946
985
  ### WhatsApp templates — `flowiq templates pull|list|create|status` (alias `tpl`)
947
986
 
948
987
  Read an org's live templates straight from Meta (read-only), and submit new
@@ -1188,6 +1227,8 @@ flowiq agent config <organization_id> --use-settings-prompt --model gpt-5.6-luna
1188
1227
  --rename Zara --tool woo_order_build=true --tool view_cart_tool=true --discount true
1189
1228
  flowiq agent config <organization_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"
1190
1229
  flowiq agent config <organization_id> --model gpt-5.6-luna --reasoning-effort high
1230
+ flowiq agent config <organization_id> --disable-base-tool get_product_info
1231
+ flowiq agent config <organization_id> --enable-base-tool get_product_info
1191
1232
  ```
1192
1233
 
1193
1234
  Settable: `settings.use_settings_prompt`, `settings.model`,
@@ -1201,6 +1242,10 @@ the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field
1201
1242
  (`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
1202
1243
  rejected; every change is reported before → after.
1203
1244
 
1245
+ `--disable-base-tool` / `--enable-base-tool` can be repeated. They manage only the
1246
+ allowlisted discovery/commerce built-ins; opt-out, human handover and channel-send
1247
+ tools cannot be disabled.
1248
+
1204
1249
  **`--tool product_lookup=true` (added 11 Aug 2026).** Turns on the `product_lookup`
1205
1250
  tool: a typo-tolerant **pg_trgm fuzzy match on `product_title`** (plus badge/tag
1206
1251
  search), as opposed to `get_product_info`, which is **semantic**. This matters far
@@ -1339,3 +1384,8 @@ interactive terminal. Silence it with `FLOWIQ_NO_UPDATE_CHECK=1`.
1339
1384
 
1340
1385
  UNLICENSED. Internal staff tool — install requires a valid `fiq_staff_…`
1341
1386
  key issued by a FlowIQ super-admin.
1387
+
1388
+ ChatCart rollout: Pick n Pay uses retailer `pnp` and the private connection link.
1389
+ Enable its gateway before adding it to the agent tools. Cart retries use
1390
+ `{{operation_idempotency_key}}`; configure `timeout_ms` up to 30000 and
1391
+ `max_response_bytes` up to 1048576 for bounded batch results.
package/TEAM-GUIDE.md CHANGED
@@ -98,6 +98,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
98
98
  | 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 |
99
99
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
100
100
  | Set the house model + reasoning tier | `flowiq agent config <org_id> --model gpt-5.6-luna --reasoning-effort high` |
101
+ | 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) |
101
102
  | 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 |
102
103
  | 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) |
103
104
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
@@ -114,6 +115,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
114
115
  | Split your whole broadcast list into N even cohorts (e.g. 3 for A/B/C or waves) | `flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3` → `flowiq seg apply <org_id> bcast --commit` (makes `bcast-batch-01/02/03`, ~even, shuffled) |
115
116
  | Combine existing tags → a batched send list (include some tags, drop others, split into batches of N) | `flowiq seg plan <org_id> --tag-prefix clearance-bc --from-tag "loyalty-list" --exclude "recent-campaign" --batch-size 1000` → `flowiq seg apply <org_id> clearance-bc --commit` (makes `clearance-bc-batch-01/02/…`) |
116
117
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
118
+ | 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) |
117
119
  | 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) |
118
120
  | 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. |
119
121
  | 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` |
@@ -141,6 +143,10 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
141
143
  | Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
142
144
  | 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 |
143
145
  | Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` |
146
+ | 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 |
147
+ | See whether a client deck can be approved, and what is still missing | `flowiq report deck status <org_id> 2026-08` |
148
+ | 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 |
149
+ | Approve a client deck / send it to the client | `flowiq report deck approve <org_id> 2026-08` then `… send … --client` (or let the 09:00 SAST schedule send it on the 5th) |
144
150
  | Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
145
151
 
146
152
  The flow is the same everywhere: **pull → edit the JSON → push**. Slugs are the
@@ -174,12 +180,15 @@ flowiq ct push <slug>
174
180
  ```
175
181
 
176
182
  Custom tools define real HTTP calls the agent can execute, so the server
177
- validates hard (names, URLs, methods, parameter shapes) and warns about typo'd
178
- keys or `{{placeholders}}` it doesn't recognise. Take the warnings seriously.
179
- Nested object/array schemas are supported. For ChatCart retailer tools, use
183
+ validates names, URLs, methods and parameter shapes. Unknown keys and unresolved
184
+ `{{placeholders}}` block the write; an
185
+ object with no required fields remains a warning because it can be intentional.
186
+ Nested object/array schemas are strict and supported. For ChatCart retailer tools, use
180
187
  `{{retailer_tools_internal_key}}` only as the `x-api-key` auth value on the
181
188
  trusted `express.chatcart.io/retailer-tools/*` gateway; org/contact identity is
182
- injected server-side and mutations use `{{whatsapp_message_id}}` for idempotency.
189
+ injected server-side and mutations use `{{operation_idempotency_key}}` for stable,
190
+ operation-scoped idempotency. Batch independent product requests in one
191
+ `search_retailer_products.queries` array (1–12) instead of repeated search calls.
183
192
 
184
193
  ### Example: tag a segment of contacts (Advanced Tagging)
185
194
 
@@ -320,3 +329,8 @@ Three things worth knowing:
320
329
  | "Organization has no active_whatsapp_agent" | Target the agent directly: `--agent <id>` (ids from `flowiq agent list <org_id>`) |
321
330
  | Login browser page says the code expired | Codes live 10 minutes — just re-run `flowiq auth login` |
322
331
  | Pushed the wrong thing | Everything is pull→push, so re-pull an older copy if you have one, or check with Matt — server logs record every push with who/what/when |
332
+
333
+ ChatCart rollout: Pick n Pay uses retailer `pnp` and the private connection link.
334
+ Enable its gateway before adding it to the agent tools. Cart retries use
335
+ `{{operation_idempotency_key}}`; configure `timeout_ms` up to 30000 and
336
+ `max_response_bytes` up to 1048576 for bounded batch results.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.6.3",
3
+ "version": "0.6.6",
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": {
@@ -23,6 +23,12 @@ export function collectTool(val, acc) {
23
23
  return acc;
24
24
  }
25
25
 
26
+ export function collectToolName(val, acc) {
27
+ acc = acc || [];
28
+ acc.push(val);
29
+ return acc;
30
+ }
31
+
26
32
  function printSnapshot(title, snap) {
27
33
  console.log(` ${title}:`);
28
34
  for (const [k, v] of Object.entries(snap)) {
@@ -47,6 +53,12 @@ export async function config(orgId, opts = {}) {
47
53
  if (Object.keys(settings).length) body.settings = settings;
48
54
  if (opts.rename !== undefined) body.name = opts.rename;
49
55
  if (opts.discount !== undefined) body.discount_enabled = parseBool(opts.discount, "--discount");
56
+ if (opts.disableBaseTool?.length || opts.enableBaseTool?.length) {
57
+ body.base_tool_changes = {
58
+ disable: opts.disableBaseTool || [],
59
+ enable: opts.enableBaseTool || [],
60
+ };
61
+ }
50
62
 
51
63
  if (opts.tool && opts.tool.length) {
52
64
  const flags = {};
@@ -63,7 +75,7 @@ export async function config(orgId, opts = {}) {
63
75
  }
64
76
 
65
77
  const hasWrite =
66
- body.settings || body.name !== undefined || body.tool_flags || body.discount_enabled !== undefined;
78
+ body.settings || body.name !== undefined || body.tool_flags || body.base_tool_changes || body.discount_enabled !== undefined;
67
79
 
68
80
  // No write flags → just show current config.
69
81
  if (!hasWrite) {
@@ -76,7 +88,7 @@ export async function config(orgId, opts = {}) {
76
88
  }
77
89
  console.log(`${resp.organization_name} → agent ${resp.agent_id}${resp.is_active === false ? " [NON-active]" : ""}`);
78
90
  printSnapshot("current", resp.current);
79
- console.log("\n(pass --use-settings-prompt / --model / --reasoning-effort / --rename / --tool / --discount / --test-contact-number / --test-contact-name to change)");
91
+ console.log("\n(pass --use-settings-prompt / --model / --reasoning-effort / --rename / --tool / --disable-base-tool / --enable-base-tool / --discount / --test-contact-number / --test-contact-name to change)");
80
92
  return;
81
93
  }
82
94
 
@@ -0,0 +1,163 @@
1
+ // `flowiq report deck <verb> <org_id> <YYYY-MM>` — the monthly client deck.
2
+ // status what exists for the month + workflow status + what blocks approval
3
+ // build rebuild the deck metrics from the frozen month (--refresh)
4
+ // narrate rewrite the copy (always forces a fresh write)
5
+ // generate build (if missing or --refresh) + write the copy
6
+ // approve / unapprove
7
+ // send --test-to me@x → one test copy, status untouched; without it the
8
+ // deck must be approved and goes to the org's configured recipients
9
+ // inputs push a JSON file of inputs (the §3.2 form) — see README
10
+ // print mint a 15-minute print URL (the page headless Chrome renders)
11
+ // pull write ./.flowiq/reports/<slug>-<month>.json (doc + deck + copy + inputs)
12
+ // All the work happens server-side (/cli/report → the flowiq-reporting-deck
13
+ // edge fn); every mutating verb is audited.
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { http } from "../http.js";
18
+
19
+ const REPORT_DIR = path.resolve(process.cwd(), ".flowiq", "reports");
20
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+ const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
22
+
23
+ function slugify(name, fallback) {
24
+ const s = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
25
+ return s || fallback;
26
+ }
27
+
28
+ function requireArgs(orgId, month) {
29
+ if (!UUID_RE.test(orgId || "")) {
30
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <search>\`).`);
31
+ process.exit(1);
32
+ }
33
+ if (!MONTH_RE.test(month || "")) {
34
+ console.error(`Error: month must be YYYY-MM (got "${month}").`);
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ function printStatus(r) {
40
+ const s = r.status || (r.deck_available ? "draft" : "no deck");
41
+ console.log(`${r.organization?.name ?? r.organization?.id} — ${r.month}`);
42
+ console.log(` snapshot: ${r.snapshot ? (r.frozen ? "frozen" : "not frozen") : "MISSING (months freeze on the 1st)"}`);
43
+ console.log(` metrics: ${r.deck_available ? `built ${r.deck_computed_at}` : "not built"}${r.deck_available && !r.prior_available ? " (no prior month deck, comparisons limited)" : ""}`);
44
+ console.log(` copy: ${r.narrative_available ? `written ${r.narrative_generated_at}` : "not written"}`);
45
+ console.log(` status: ${s}${r.approved_at ? ` (approved ${r.approved_at}${r.approved_by ? ` by ${r.approved_by}` : ""})` : ""}${r.sent_at ? ` · sent ${r.sent_at}` : ""}`);
46
+ if (Array.isArray(r.missing) && r.missing.length && s !== "sent") console.log(` needed: ${r.missing.join(" · ")}`);
47
+ console.log(` client recipients: ${Array.isArray(r.recipients) && r.recipients.length ? r.recipients.join(", ") : "none configured (Control center → Report delivery)"}`);
48
+ console.log(` from flowiq@flowapt.com · reply-to ${r.reply_to ?? "matt@flowapt.com"} · signed ${r.account_owner ?? "Matt Cronson"} · schedule ${r.deck_enabled ? "ON" : "off"}`);
49
+ }
50
+
51
+ export async function status(orgId, month, opts) {
52
+ requireArgs(orgId, month);
53
+ let r;
54
+ try { r = await http.get("report", { organization_id: orgId, month }); }
55
+ catch (e) { console.error(`Status failed: ${e.message}`); process.exit(1); }
56
+ if (opts?.json) { console.log(JSON.stringify(r, null, 2)); return; }
57
+ printStatus(r);
58
+ }
59
+
60
+ async function post(action, orgId, month, extra, label) {
61
+ let r;
62
+ try { r = await http.post("report", { organization_id: orgId, month, action, ...extra }); }
63
+ catch (e) { console.error(`${label} failed: ${e.message}`); process.exit(1); }
64
+ return r;
65
+ }
66
+
67
+ export async function build(orgId, month, opts) {
68
+ requireArgs(orgId, month);
69
+ const r = await post("build", orgId, month, { refresh: !!opts.refresh }, "Build");
70
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
71
+ console.log(r.built ? `Built the ${month} deck metrics for ${r.organization.name} (${r.broadcasts} broadcast${r.broadcasts === 1 ? "" : "s"} read${r.timings ? `, ${Object.values(r.timings).reduce((a, b) => a + b, 0)} ms` : ""})` : `Metrics already built for ${month}; pass --refresh to rebuild.`);
72
+ }
73
+
74
+ export async function narrate(orgId, month, opts) {
75
+ requireArgs(orgId, month);
76
+ const r = await post("narrate", orgId, month, {}, "Narrate");
77
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
78
+ reportCopy(r, month);
79
+ }
80
+
81
+ export async function generate(orgId, month, opts) {
82
+ requireArgs(orgId, month);
83
+ const r = await post("generate", orgId, month, { refresh: !!opts.refresh, force: !!opts.force }, "Generate");
84
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
85
+ console.log(`${r.organization.name} — ${month}: metrics ${r.built ? "built" : "kept"}, copy ${r.generated ? `written by ${r.model}` : "kept"}`);
86
+ reportCopy(r, month);
87
+ if (r.status) console.log(` status: ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : ""}`);
88
+ }
89
+
90
+ function reportCopy(r, month) {
91
+ if (r.guard && r.guard.passed === false) {
92
+ const n = Object.keys(r.guard.offenders || {}).length;
93
+ console.log(` ⚠ numeric guard: ${n} field${n === 1 ? "" : "s"} carry figures that are not in the data — flagged on the slides: ${Object.keys(r.guard.offenders).join(", ")}`);
94
+ } else if (r.guard) console.log(" numeric guard: passed (every figure in the copy exists in the data)");
95
+ if (Array.isArray(r.length_issues) && r.length_issues.length) console.log(` trimmed to limit: ${r.length_issues.map((i) => `${i.path} (${i.length}→${i.limit})`).join(", ")}`);
96
+ }
97
+
98
+ export async function approve(orgId, month, opts) {
99
+ requireArgs(orgId, month);
100
+ const r = await post("approve", orgId, month, {}, "Approve");
101
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
102
+ console.log(`Approved the ${month} deck for ${r.organization.name}. It goes to the client recipients at 09:00 SAST on the 5th (or the next morning if the 5th has passed).`);
103
+ }
104
+
105
+ export async function unapprove(orgId, month, opts) {
106
+ requireArgs(orgId, month);
107
+ const r = await post("unapprove", orgId, month, {}, "Unapprove");
108
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
109
+ console.log(`Unapproved. Status is now ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : ""}.`);
110
+ }
111
+
112
+ export async function send(orgId, month, opts) {
113
+ requireArgs(orgId, month);
114
+ if (!opts.testTo && !opts.client) {
115
+ console.error("Error: pass --test-to <email> for a test copy, or --client to send to the org's configured recipients (needs an approved deck).");
116
+ process.exit(1);
117
+ }
118
+ if (opts.testTo && opts.client) {
119
+ console.error("Error: --test-to and --client are alternatives.");
120
+ process.exit(1);
121
+ }
122
+ const r = await post("send", orgId, month, opts.testTo ? { test_to: opts.testTo } : {}, "Send");
123
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
124
+ const ok = (r.results || []).filter((x) => x.status === "sent").map((x) => x.to);
125
+ const failed = (r.results || []).filter((x) => x.status !== "sent");
126
+ console.log(`${r.test ? "Test copy" : "Deck"} ${ok.length ? `sent to ${ok.join(", ")}` : "not sent"} — ${r.filename} (${Math.round((r.pdf_bytes || 0) / 1024)} KB, rendered in ${((r.pdf_ms || 0) / 1000).toFixed(1)} s)${r.test ? " · status unchanged" : " · status: sent"}`);
127
+ for (const f of failed) console.log(` ✗ ${f.to}: ${f.error}`);
128
+ if (failed.length) process.exit(1);
129
+ }
130
+
131
+ export async function inputs(orgId, month, opts) {
132
+ requireArgs(orgId, month);
133
+ if (!opts.file) { console.error("Error: --file <inputs.json> is required."); process.exit(1); }
134
+ let parsed;
135
+ try { parsed = JSON.parse(await fs.readFile(path.resolve(process.cwd(), opts.file), "utf8")); }
136
+ catch (e) { console.error(`Error: could not read ${opts.file}: ${e.message}`); process.exit(1); }
137
+ const r = await post("save_inputs", orgId, month, { inputs: parsed }, "Save inputs");
138
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
139
+ console.log(`Inputs saved. Status: ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : " · ready to approve"}`);
140
+ }
141
+
142
+ export async function print(orgId, month, opts) {
143
+ requireArgs(orgId, month);
144
+ const r = await post("print_url", orgId, month, {}, "Print URL");
145
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
146
+ console.log(r.url);
147
+ console.error(`(valid ${Math.round((r.expires_in || 900) / 60)} min; this is the page headless Chrome prints, one 1600×900 page per slide)`);
148
+ }
149
+
150
+ export async function pull(orgId, month, opts) {
151
+ requireArgs(orgId, month);
152
+ let r;
153
+ try { r = await http.get("report", { organization_id: orgId, month, pull: 1 }); }
154
+ catch (e) { console.error(`Pull failed: ${e.message}`); process.exit(1); }
155
+ const slug = slugify(r.organization?.name, orgId.slice(0, 8));
156
+ const file = path.resolve(opts.out || path.join(REPORT_DIR, `${slug}-${month}.json`));
157
+ await fs.mkdir(path.dirname(file), { recursive: true });
158
+ await fs.writeFile(file, JSON.stringify(r, null, 2));
159
+ const d = r.doc || {};
160
+ console.log(`Wrote ${path.relative(process.cwd(), file)}`);
161
+ console.log(` ${r.organization?.name} — ${month} · status ${r.workflow?.status ?? "draft"} · deck ${d.deck ? "built" : "not built"} · copy ${r.narrative?.fields ? `written by ${r.narrative.model}` : "not written"}`);
162
+ if (d.revenue?.influenced) console.log(` influenced ${d.revenue.influenced.revenue} · conversations ${d.conversations?.conversations} · sends ${d.deck?.broadcasts?.totals?.sends ?? "—"}`);
163
+ }
@@ -122,6 +122,13 @@ export async function plan(orgId, opts = {}) {
122
122
  }
123
123
  const splitMode = opts.split !== undefined;
124
124
  const batchSize = Number(opts.batchSize ?? 75);
125
+ // --start-index continues an EXISTING batch series (f500-batch-13 after 12
126
+ // manual batches) instead of always restarting at 01 and colliding with it.
127
+ const startIndex = Number(opts.startIndex ?? 1);
128
+ if (!Number.isInteger(startIndex) || startIndex < 1 || startIndex > 999) {
129
+ console.error("Error: --start-index must be an integer 1..999.");
130
+ process.exit(1);
131
+ }
125
132
  if (!splitMode) {
126
133
  if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
127
134
  if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
@@ -252,18 +259,35 @@ export async function plan(orgId, opts = {}) {
252
259
  let idx = 0;
253
260
  for (let g = 0; g < splitN; g++) {
254
261
  const size = base + (g < rem ? 1 : 0);
255
- const nn = String(g + 1).padStart(2, "0");
262
+ const nn = String(startIndex + g).padStart(2, "0");
256
263
  batches.push({ tag: `${prefix}-batch-${nn}`, count: size, contact_ids: ids.slice(idx, idx + size) });
257
264
  idx += size;
258
265
  }
259
266
  if (batches[0].count > 500) console.log(`⚠ each cohort is ~${batches[0].count} contacts — a single-cohort send is large (Meta tier risk); pace the sends.`);
260
267
  } else {
261
268
  for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
262
- const nn = String(batches.length + 1).padStart(2, "0");
269
+ const nn = String(startIndex + batches.length).padStart(2, "0");
263
270
  batches.push({ tag: `${prefix}-batch-${nn}`, count: Math.min(batchSize, resp.safe_ids.length - i), contact_ids: resp.safe_ids.slice(i, i + batchSize) });
264
271
  }
265
272
  }
266
273
 
274
+ // A batch tag that ALREADY holds contacts means this plan would merge a new
275
+ // cohort into a past one (append-only apply = silent corruption of that
276
+ // batch's record). Refuse unless the caller says it is deliberate.
277
+ try {
278
+ const existing = await http.post("segments", { mode: "list", organization_id: orgId, prefix });
279
+ const held = new Map((existing?.tags || []).map((t) => [String(t.tag), Number(t.count) || 0]));
280
+ const clashes = batches.map((b) => b.tag).filter((t) => (held.get(t) || 0) > 0);
281
+ if (clashes.length && !opts.allowExistingTag) {
282
+ console.error(`Error: ${clashes.length} batch tag(s) already hold contacts on this org: ${clashes.slice(0, 5).join(", ")}${clashes.length > 5 ? ` …+${clashes.length - 5}` : ""}.`);
283
+ console.error(" apply is append-only, so this plan would MERGE a new cohort into an existing batch.");
284
+ console.error(` Use --start-index ${Math.max(...[...held.keys()].map((t) => Number(String(t).split("-batch-").pop())).filter(Number.isFinite), 0) + 1} to continue the series, or --allow-existing-tag if the merge is deliberate.`);
285
+ process.exit(1);
286
+ }
287
+ } catch (e) {
288
+ console.log(`⚠ could not check existing batch tags (${e.message}) — verify with \`seg list --prefix ${prefix}\` before apply.`);
289
+ }
290
+
267
291
  const slug = slugify(opts.segment || prefix, prefix);
268
292
  const planDoc = {
269
293
  schema_version: 1,
@@ -326,7 +350,10 @@ export async function apply(orgId, identifier, opts = {}) {
326
350
  // V-11: warn when the batch tags already carry members (tag reuse merges counts).
327
351
  try {
328
352
  const before = await http.post("segments", { mode: "list", organization_id: orgId, prefix: planDoc.tag_prefix });
329
- const existing = (before.tags || []).filter((t) => Number(t.count) > 0);
353
+ // Only THIS plan's batch tags matter — filtering on the prefix alone warned
354
+ // about every past batch of the campaign and prompted on a clean plan.
355
+ const planTags = new Set(planDoc.batches.map((b) => String(b.tag)));
356
+ const existing = (before.tags || []).filter((t) => Number(t.count) > 0 && planTags.has(String(t.tag)));
330
357
  if (existing.length) {
331
358
  console.log(`⚠ ${existing.length} of these tags already have members (counts will MERGE):`);
332
359
  for (const t of existing.slice(0, 5)) console.log(` ${t.tag}: ${t.count}`);
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ import * as flowmodCmd from "./commands/flowmod.js";
16
16
  import * as groupsCmd from "./commands/groups.js";
17
17
  import * as pinboardCmd from "./commands/pinboard.js";
18
18
  import * as hoursCmd from "./commands/hours.js";
19
+ import * as reportCmd from "./commands/report.js";
19
20
  import * as templatesCmd from "./commands/templates.js";
20
21
  import * as orgCmd from "./commands/org.js";
21
22
  import * as agentConfigCmd from "./commands/agent-config.js";
@@ -215,6 +216,56 @@ export function run(argv) {
215
216
  .option("--json", "raw JSON output")
216
217
  .action((opts) => hoursCmd.summary(opts));
217
218
 
219
+ // report deck — the monthly client deck (build / copy / approve / send)
220
+ const report = program.command("report").description("Client reporting: the monthly 10-slide client deck");
221
+ const deck = report.command("deck").description("The monthly client deck for one org and month (/reporting/deck)");
222
+ deck.command("status <organization_id> <month>")
223
+ .description("What exists for the month, the workflow status and what still blocks approval")
224
+ .option("--json", "raw JSON output")
225
+ .action((orgId, month, opts) => reportCmd.status(orgId, month, opts));
226
+ deck.command("build <organization_id> <month>")
227
+ .description("Build the deck metrics from the frozen month (leaderboard, timing, popup, carts, records, changes)")
228
+ .option("--refresh", "rebuild even if the metrics already exist")
229
+ .option("--json", "raw JSON output")
230
+ .action((orgId, month, opts) => reportCmd.build(orgId, month, opts));
231
+ deck.command("narrate <organization_id> <month>")
232
+ .description("Rewrite the ten slides' copy (LLM, under the report rules + numeric guard); hand edits are kept")
233
+ .option("--json", "raw JSON output")
234
+ .action((orgId, month, opts) => reportCmd.narrate(orgId, month, opts));
235
+ deck.command("generate <organization_id> <month>")
236
+ .description("Build the metrics (if missing, or --refresh) and write the copy")
237
+ .option("--refresh", "rebuild the metrics first")
238
+ .option("--force", "rewrite the copy even if it exists")
239
+ .option("--json", "raw JSON output")
240
+ .action((orgId, month, opts) => reportCmd.generate(orgId, month, opts));
241
+ deck.command("inputs <organization_id> <month>")
242
+ .description("Save the super-admin inputs from a JSON file (changes reviewed, action points, waiting-on-you, recipients confirmed)")
243
+ .option("--file <path>", "inputs JSON file")
244
+ .option("--json", "raw JSON output")
245
+ .action((orgId, month, opts) => reportCmd.inputs(orgId, month, opts));
246
+ deck.command("approve <organization_id> <month>")
247
+ .description("Approve the deck (refused until every required input is present)")
248
+ .option("--json", "raw JSON output")
249
+ .action((orgId, month, opts) => reportCmd.approve(orgId, month, opts));
250
+ deck.command("unapprove <organization_id> <month>")
251
+ .description("Take an approved deck back to needs input / ready")
252
+ .option("--json", "raw JSON output")
253
+ .action((orgId, month, opts) => reportCmd.unapprove(orgId, month, opts));
254
+ deck.command("send <organization_id> <month>")
255
+ .description("Email the PDF from flowiq@flowapt.com: --test-to for one test copy (status untouched), --client for the configured recipients (needs approval)")
256
+ .option("--test-to <email>", "send a test copy to this address only")
257
+ .option("--client", "send to the org's configured client recipients")
258
+ .option("--json", "raw JSON output")
259
+ .action((orgId, month, opts) => reportCmd.send(orgId, month, opts));
260
+ deck.command("print <organization_id> <month>")
261
+ .description("Mint a 15-minute print URL (the page headless Chrome renders to PDF)")
262
+ .option("--json", "raw JSON output")
263
+ .action((orgId, month, opts) => reportCmd.print(orgId, month, opts));
264
+ deck.command("pull <organization_id> <month>")
265
+ .description("Write the month's doc + deck metrics + copy + inputs to ./.flowiq/reports/<slug>-<month>.json")
266
+ .option("--out <path>", "write to this file instead")
267
+ .action((orgId, month, opts) => reportCmd.pull(orgId, month, opts));
268
+
218
269
  // templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
219
270
  const templates = program.command("templates")
220
271
  .alias("tpl")
@@ -277,6 +328,8 @@ export function run(argv) {
277
328
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
278
329
  .option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
279
330
  .option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
331
+ .option("--disable-base-tool <name>", "hide a disableable built-in tool from this agent (repeatable)", agentConfigCmd.collectToolName, [])
332
+ .option("--enable-base-tool <name>", "restore a previously disabled built-in tool (repeatable)", agentConfigCmd.collectToolName, [])
280
333
  .action((orgId, opts) => agentConfigCmd.config(orgId, opts));
281
334
 
282
335
  // agent-updates (pending client change-requests + chat context; pull + resolve)
@@ -544,6 +597,8 @@ export function run(argv) {
544
597
  .option("--split <n>", "split the safe cohort into exactly N even cohorts (alternative to --batch-size; shuffles by default for balanced groups)")
545
598
  .option("--seed <n>", "with --split: reproducible shuffle seed (omit = random each run)")
546
599
  .option("--ordered", "with --split: keep server order instead of shuffling (deterministic, but skews groups by contact age)")
600
+ .option("--start-index <n>", "number the first batch tag from N instead of 01 — continues an existing series (e.g. --start-index 13 → <prefix>-batch-13)", "1")
601
+ .option("--allow-existing-tag", "allow a batch tag that already holds contacts (apply is append-only, so this MERGES cohorts)")
547
602
  .option("--segment <name>", "plan file slug (default: the tag prefix)")
548
603
  .option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
549
604
  .action((orgId, opts) => segmentsCmd.plan(orgId, opts));