@flowapt/flowiq-cli 0.9.5 → 0.9.7

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
@@ -1273,10 +1273,13 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
1273
1273
  predates the current build logic, unless it is approved, sent or held).
1274
1274
  - Send fees are estimated at the category each template had on its send day. A send whose category
1275
1275
  cannot be known shows no fee and is left out of the broadcast return (`totals.unpriced_sends`).
1276
- - `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 }`.
1276
+ - `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, "hidden": { "tile:tail": true, "pill:new_contacts": true } }`.
1277
+ `hidden` (29 Sep 2026) takes any tile, pill, card or block off the slides and the PDF for that month only: the keys are what the deck page's rail lists (`tile:…`, `pill:<metric>`, `card:…`, `block:…`, `box:…`, `strip:…`); a metric's pill hides everywhere that metric shows. The copy is not rewritten when something is hidden.
1277
1278
  Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
1278
1279
  - `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.
1279
1280
  - 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.
1281
+ - A month with a Customer Insights report gets the "What your customers asked" slide after slide 7 (29 Sep 2026): `build` takes the one `org_insights` export-insights row whose window covers the most of the month (top questions, trending topics, complaints or product mentions, sentiment, each as "N customers"), stores it under `deck.insights`, and the copy carries `insights_title` + `insights_read`. `status --json` shows the window under `insights` (null = no report, no slide). The timing slide's next-step card is now written by the narrator (`next_step_title` / `next_step_body`) against next month's South African retail calendar (public holidays, Mother's / Father's Day, Black Friday, payday, month-end) and edited in place like every other block.
1282
+ - Cart funnel and buckets (29 Sep 2026): "messaged" counts reminders WhatsApp reports delivered (the old send-request count rides along as `messaged_requested`); a Woo cart reminder now matches its order, so Woo recoveries are no longer zero; our own cart link is a recovery, another tool's abandoned-cart email is not; on Woo an agent-built order is a conversation, not a campaign; chat-to-order counts buyers who chatted, any bucket. `status` refuses approval when the agent replied to nobody in the month (no live channel).
1280
1283
  - Store orgs get slide 8, "Store health": cart abandonment (online checkouts started, bought, abandoned), first-time vs returning buyers and opt-in growth, each with a twelve-month strip and measured on the online store checkout only (till and other channels are named in the footer). `status --json` carries the headline figures under `store`; `config.deck.channel_labels {"<source_name>": "Label"}` renames a sales channel on the slide.
1281
1284
 
1282
1285
  ### Insights — `flowiq insights status|enable|disable|run` (v0.7.0)
@@ -1845,9 +1848,13 @@ flowiq plans status <plan_id> --to sent --commit
1845
1848
  timestamps instead of the send date (SAST day start) — "what did a client
1846
1849
  submit today", "what has moved since Friday", which `--since` cannot answer.
1847
1850
  - **`show`** prints the plan the way the dialog does: message copy, the
1848
- second message sent when a button is tapped, buttons with their URLs, the
1849
- creative (file name + link), audience, template link, the response to the
1850
- client, internal notes and the notes thread.
1851
+ second message sent when a button is tapped (named by the button's text),
1852
+ buttons with their targets, the creative (file name + link), audience,
1853
+ template link, the response to the client, internal notes and the notes
1854
+ thread. A marketing plan carries 0-10 buttons in any mix WhatsApp allows:
1855
+ `quick_reply`, `url` (up to 2, `→ link`), `phone` (1, `→ call +27…`) and
1856
+ `copy_code` (1, `→ code SPRING20`); each quick reply may have its own
1857
+ follow-up message (`slide2a`, `slide2b`, `slide2c` … by button position).
1851
1858
  - **`status`** is a dry run until `--commit`. `--comment` is the response the
1852
1859
  **client reads**; `--internal` is staff-only. `approved` and `rejected`
1853
1860
  record you as the reviewer; `rejected` requires `--comment`. Every commit is
@@ -1855,6 +1862,53 @@ flowiq plans status <plan_id> --to sent --commit
1855
1862
  - Editing a plan's copy, buttons or links is not in the CLI yet; do that in
1856
1863
  the app.
1857
1864
 
1865
+ ### CLI gaps + requests — `flowiq gaps report|list|show|hit|note|status` (alias `requests`) (v0.9.7)
1866
+
1867
+ Found something the CLI cannot do, does wrong, or should do? File it here, from
1868
+ the terminal, the moment you hit it. This is the shared list of what the CLI
1869
+ is missing: anyone with a staff key can add to it, and so can a Claude session
1870
+ driving the CLI for you (it is marked `claude` automatically).
1871
+
1872
+ ```bash
1873
+ flowiq gaps report "agent config cannot set tool_instructions" --command "agent config" \
1874
+ --error "error: unknown option '--tool-instruction'" --workaround "SQL on agents.settings" \
1875
+ --detail "Needed to add a per-tool instruction for Clara's get_product_info" --org <uuid>
1876
+ flowiq gaps report "links shorten should take a readable --slug" --kind idea --command "links shorten"
1877
+ flowiq gaps report "…" --dry-run # only check whether it is already on the list
1878
+ flowiq gaps list # open requests, most-hit first
1879
+ flowiq gaps list --command "bc" --status all --search slug
1880
+ flowiq gaps list --mine
1881
+ flowiq gaps show 12 # detail, verbatim error, every hit / note / status change
1882
+ flowiq gaps hit 12 --note "same on Barkyn IT while copying the escalation config"
1883
+ flowiq gaps note 12 "also needs --agent"
1884
+ flowiq gaps status 12 --to done --version 0.9.8 --note "agent config --tool-instruction name=@file.txt" # maintainers
1885
+ flowiq gaps status 14 --to duplicate --of 12 # hits move to #12
1886
+ ```
1887
+
1888
+ - **`report`** takes a one-line title plus whatever context you have:
1889
+ `--command` (the command it concerns, or the one you expected to exist),
1890
+ `--kind gap|bug|idea` (default gap), `--error` (paste it verbatim),
1891
+ `--workaround` (what you did instead), `--detail` / `--detail-file`, `--org`,
1892
+ `--priority low|normal|high` (high = it blocked the job).
1893
+ - **Duplicates are caught before they are filed.** A report that closely
1894
+ matches an OPEN request is refused and names it; add yourself to that one
1895
+ with `hit` instead, or pass `--new` if yours is genuinely different.
1896
+ `--dry-run` shows the similar requests and files nothing.
1897
+ - **`hit`** is "this bit me too": it adds your case and counts it. `list` is
1898
+ ordered by hits, so the requests that cost the team most get built first.
1899
+ `note` adds context without counting.
1900
+ - **`status`** (open / planned / in_progress / done / declined / duplicate) is
1901
+ for the CLI maintainers (Matt and Gidon). You can withdraw your OWN request
1902
+ with `--to declined` or `--to duplicate --of <n>`. `done` and `declined` need
1903
+ `--note`: closing a request **emails everyone who reported or hit it** (from
1904
+ FlowIQ (CLI), reply-to the person who closed it), with the version to update
1905
+ to. `--no-notify` closes quietly.
1906
+ - **A command or flag that does not exist** now ends its error with the exact
1907
+ `flowiq gaps report …` line to file it, `--command` and `--error` filled in.
1908
+ - Every report / hit / note / status change is audited
1909
+ (`flowiq audit --endpoint gaps`). A report or hit from anyone but Matt also
1910
+ emails him through the standing CLI-write alert.
1911
+
1858
1912
  ### Chat export — `flowiq export chats <organization_id> [--out <path>]`
1859
1913
 
1860
1914
  Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
@@ -1894,6 +1948,12 @@ flowiq test cleanup <organization_id> --confirm # delete the stress contact
1894
1948
  `--agent <id>` targets a specific (non-active) agent. `test stress cleanup` is
1895
1949
  destructive and requires `--confirm`.
1896
1950
 
1951
+ `test clear` (and `--clear` on `send`, and the clear between scenario turns) hides the
1952
+ messages (`memory_cutoff`) AND sets aside the agent's remembered tool results
1953
+ (`more_data.tool_usage_history`, which the agent reads as TOOL USAGE HISTORY) under
1954
+ `more_data.tool_usage_history_cleared`, so a cleared test really starts fresh. Since 0.9.6;
1955
+ before that earlier tool results and escalation reasons leaked into "cleared" tests.
1956
+
1897
1957
  ### Guide — `flowiq guide`
1898
1958
 
1899
1959
  Read the bundled docs in the terminal — no digging through node_modules.
package/TEAM-GUIDE.md CHANGED
@@ -70,6 +70,12 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
70
70
  goes back to the agent you pulled — never "whatever is active now".
71
71
  5. Run the CLI from the repo root when you can — `./.flowiq/` is gitignored
72
72
  there.
73
+ 6. **Hit a wall? File it.** Whenever the CLI cannot do something and you (or
74
+ your Claude session) fell back to the dashboard, SQL or a script, file it:
75
+ `flowiq gaps report "<what you needed>" --command "<topic>" --error "<the
76
+ exact error>"`. Already on the list? `flowiq gaps hit <number> --note "…"`
77
+ instead. The most-hit requests get built first, and you are emailed when
78
+ yours ships. Tell your Claude session to do this too.
73
79
 
74
80
  ## Everyday tasks
75
81
 
@@ -116,10 +122,14 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
116
122
  | 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) |
117
123
  | Client wants leads/requests emailed to their team instead of a human escalation | `flowiq agent config <org_id> --tool email_request_tool=true`, then set the recipient list in Agents → Tool Library → Email Request to Team (no CLI path for the addresses yet) |
118
124
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
125
+ | Start a test chat from scratch (agent forgets the earlier test messages AND its earlier tool results) | `flowiq test clear <org_id>` (or `flowiq test send <org_id> "…" --clear`) |
119
126
  | **Tell the team what shipped** (the FlowIQ team update email) | `flowiq updates draft` → enrich the JSON (screenshots via `flowiq updates asset`, a *For the team* note per headline) → `flowiq updates preview <file> --open` → `flowiq updates send <file> --test` → `flowiq updates send <file> --yes` |
120
127
  | Can I use `flowiq updates`? | Yes: `status`, `uncovered`, `draft`, `preview` and `send --test` (a copy to yourself) work for everyone. Only the real `send --yes` to the whole team is Matt's; anyone else gets a 403 there |
121
128
  | Which changelog rows has nobody emailed the team about yet? | `flowiq updates status` / `flowiq updates uncovered` — anything left 20h+ goes out automatically at 08:30 SAST as a plain digest |
122
129
  | Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
130
+ | **The CLI can't do something I need** (or does it wrong, or should do more) | `flowiq gaps report "agent config cannot set tool_instructions" --command "agent config" --error "<exact error>" --workaround "<what you did instead>"`. If it is already on the list you are told its number: `flowiq gaps hit <number> --note "…"` |
131
+ | See what the team has asked the CLI to do | `flowiq gaps list` (open, most-hit first) · `flowiq gaps list --mine` · `flowiq gaps show <number>` |
132
+ | Close a request (Matt / Gidon) | `flowiq gaps status <number> --to done --version 0.9.8 --note "what shipped"` — emails everyone who reported or hit it |
123
133
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
124
134
  > from your working tree — `npm publish` packs whatever is on disk. A
125
135
  > `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
@@ -220,6 +230,7 @@ several.
220
230
  | 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 |
221
231
  | See whether a client deck can be approved, and what is still missing | `flowiq report deck status <org_id> 2026-08` |
222
232
  | 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 |
233
+ | Take a tile, pill or card off a client deck (and its PDF) for one month | On `/reporting/deck` open **Inputs** (the rail beside the slides) → Show or hide; or `flowiq report deck inputs <org_id> 2026-08 --file inputs.json` with `{ "hidden": { "tile:tail": true } }` |
223
234
  | 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) |
224
235
  | Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
225
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
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": {
@@ -146,7 +146,8 @@ export async function clear(orgId, opts = {}) {
146
146
  let resp;
147
147
  try { resp = await clearConv(orgId, opts.agent); }
148
148
  catch (e) { console.error(`Clear failed: ${e.message}`); process.exit(1); }
149
- console.log(`Cleared web conversation for contact ${resp.contact_id} (history hidden via memory_cutoff; onboarding/disclaimer state NOT reset).`);
149
+ const tools = Number(resp.tools_cleared || 0);
150
+ console.log(`Cleared web conversation for contact ${resp.contact_id} (history hidden via memory_cutoff; ${tools} remembered tool result(s) set aside in more_data.tool_usage_history_cleared; onboarding/disclaimer state NOT reset).`);
150
151
  }
151
152
 
152
153
  // ---------- phase 2: stress + extract ----------
@@ -0,0 +1,346 @@
1
+ // `flowiq gaps report|list|show|hit|note|status` — CLI gap reports and feature
2
+ // requests (alias `flowiq requests`), via /cli/gaps.
3
+ // report file one: something the CLI cannot do, does wrong, or should do
4
+ // list open requests, most-hit first (every status with --status all)
5
+ // show one request in full, with every hit, note and status change
6
+ // hit "this bit me too": +1 with what you were doing (the ranking signal)
7
+ // note add context without counting a hit
8
+ // status planned / in_progress / done / declined / duplicate (maintainers;
9
+ // closing emails everyone who reported or hit it)
10
+ // Anyone with a staff key can report, hit and note — including a Claude
11
+ // session driving the CLI, which is marked `claude` automatically.
12
+
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { http } from "../http.js";
16
+
17
+ const KINDS = ["gap", "idea", "bug"];
18
+ const STATUSES = ["open", "planned", "in_progress", "done", "declined", "duplicate"];
19
+ const STATUS_ORDER = ["in_progress", "planned", "open", "done", "declined", "duplicate"];
20
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
21
+
22
+ /** Who is typing: a Claude session, a script/pipe, or a person at a terminal. */
23
+ export function filedVia(env = process.env, stdin = process.stdin, stdout = process.stdout) {
24
+ if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
25
+ if (!stdin?.isTTY || !stdout?.isTTY) return "script";
26
+ return "human";
27
+ }
28
+
29
+ /** "42", "#42" or a UUID; anything else is refused before a request is made. */
30
+ export function normaliseRef(ref) {
31
+ const raw = String(ref ?? "").trim().replace(/^#/, "");
32
+ if (/^\d{1,12}$/.test(raw)) return raw;
33
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) return raw;
34
+ return null;
35
+ }
36
+
37
+ function sastParts(ts) {
38
+ const parts = new Intl.DateTimeFormat("en-CA", {
39
+ timeZone: "Africa/Johannesburg",
40
+ year: "numeric", month: "2-digit", day: "2-digit",
41
+ hour: "2-digit", minute: "2-digit", hour12: false,
42
+ }).formatToParts(new Date(ts));
43
+ return Object.fromEntries(parts.map((p) => [p.type, p.value]));
44
+ }
45
+
46
+ function fmtTs(ts) {
47
+ if (!ts) return "-";
48
+ const p = sastParts(ts);
49
+ const hour = p.hour === "24" ? "00" : p.hour;
50
+ return `${Number(p.day)} ${MONTHS[Number(p.month) - 1]} ${p.year} ${hour}:${p.minute}`;
51
+ }
52
+
53
+ export function ago(ts, now = Date.now()) {
54
+ if (!ts) return "-";
55
+ const mins = Math.max(0, Math.round((now - new Date(ts).getTime()) / 60000));
56
+ if (mins < 60) return `${mins}m ago`;
57
+ const hrs = Math.round(mins / 60);
58
+ if (hrs < 48) return `${hrs}h ago`;
59
+ return `${Math.round(hrs / 24)}d ago`;
60
+ }
61
+
62
+ function who(email) {
63
+ if (!email) return "?";
64
+ return String(email).split("@")[0];
65
+ }
66
+
67
+ function indent(text, pad = " ") {
68
+ return String(text ?? "").trim().split("\n").map((l) => `${pad}${l}`).join("\n");
69
+ }
70
+
71
+ function fail(prefix, e) {
72
+ console.error(`${prefix}: ${e.message}`);
73
+ if (e.body?.error && !String(e.message).includes(e.body.error)) console.error(` ${e.body.error}`);
74
+ process.exit(1);
75
+ }
76
+
77
+ function requireRef(ref) {
78
+ const r = normaliseRef(ref);
79
+ if (!r) {
80
+ console.error(`Error: "${ref}" is not a request number (e.g. 42 or #42). Find it with \`flowiq gaps list\`.`);
81
+ process.exit(1);
82
+ }
83
+ return r;
84
+ }
85
+
86
+ function printSimilar(similar, heading) {
87
+ if (!similar?.length) return;
88
+ console.log(heading);
89
+ for (const s of similar) {
90
+ const shipped = s.status === "done" && s.resolved_version ? ` in ${s.resolved_version}` : "";
91
+ console.log(` #${s.number} ${s.title} [${s.status}${shipped} · ${s.hit_count} hit(s) · match ${s.score}]`);
92
+ }
93
+ }
94
+
95
+ // ── report ──────────────────────────────────────────────────────────────────
96
+ export async function report(titleWords, opts = {}) {
97
+ const title = (Array.isArray(titleWords) ? titleWords.join(" ") : String(titleWords || "")).trim();
98
+ if (title.length < 3) {
99
+ console.error('Error: give a one-line title, e.g. flowiq gaps report "agent config cannot set tool_instructions" --command "agent config"');
100
+ process.exit(1);
101
+ }
102
+ const kind = String(opts.kind || "gap").toLowerCase();
103
+ if (!KINDS.includes(kind)) {
104
+ console.error(`Error: --kind must be ${KINDS.join(" | ")} (gap = the CLI cannot do it, bug = it does it wrong, idea = it should).`);
105
+ process.exit(1);
106
+ }
107
+ let detail = opts.detail ?? null;
108
+ if (opts.detailFile) {
109
+ try {
110
+ detail = await fs.readFile(path.resolve(process.cwd(), opts.detailFile), "utf8");
111
+ } catch (e) {
112
+ console.error(`Error: cannot read --detail-file ${opts.detailFile}: ${e.message}`);
113
+ process.exit(1);
114
+ }
115
+ }
116
+
117
+ let resp;
118
+ try {
119
+ resp = await http.post("gaps", {
120
+ action: "report",
121
+ title,
122
+ kind,
123
+ command: opts.command ?? null,
124
+ detail,
125
+ error_text: opts.error ?? null,
126
+ workaround: opts.workaround ?? null,
127
+ priority: opts.priority ?? "normal",
128
+ organization_id: opts.org ?? null,
129
+ filed_via: filedVia(),
130
+ force_new: Boolean(opts.new),
131
+ dry_run: Boolean(opts.dryRun),
132
+ });
133
+ } catch (e) {
134
+ if (e.status === 409) {
135
+ console.error(e.body?.error || e.message);
136
+ printSimilar(e.body?.similar, "\nAlready on the list:");
137
+ process.exit(1);
138
+ }
139
+ fail("Report failed", e);
140
+ }
141
+ if (opts.json) {
142
+ console.log(JSON.stringify(resp, null, 2));
143
+ return;
144
+ }
145
+ if (resp.dry_run) {
146
+ console.log(`DRY RUN · nothing filed · "${title}"`);
147
+ if (!resp.similar?.length) console.log(" No similar requests on the list. File it by re-running without --dry-run.");
148
+ printSimilar(resp.similar, "Similar requests:");
149
+ if (resp.would_block) console.log("\nA real report would be held as a duplicate: hit the matching one, or pass --new if it is different.");
150
+ return;
151
+ }
152
+ const r = resp.request;
153
+ console.log(`Filed #${r.number} (${r.kind}${r.command ? ` · flowiq ${r.command}` : ""}): ${r.title}`);
154
+ console.log(` by ${r.reporter_email ?? "?"} via ${r.filed_via}${r.organization_name ? ` · org ${r.organization_name}` : ""}`);
155
+ printSimilar(resp.similar, "\nPossibly related:");
156
+ console.log(`\nTrack it: flowiq gaps show ${r.number} · hit it again next time: flowiq gaps hit ${r.number} --note "…"`);
157
+ }
158
+
159
+ // ── list ────────────────────────────────────────────────────────────────────
160
+ export async function list(opts = {}) {
161
+ const query = {};
162
+ if (opts.status) query.status = opts.status;
163
+ if (opts.command) query.command = opts.command;
164
+ if (opts.kind) query.kind = opts.kind;
165
+ if (opts.mine) query.mine = "1";
166
+ if (opts.search) query.search = opts.search;
167
+ if (opts.sort) query.sort = opts.sort;
168
+ if (opts.limit != null) query.limit = String(opts.limit);
169
+
170
+ let resp;
171
+ try {
172
+ resp = await http.get("gaps", query);
173
+ } catch (e) {
174
+ fail("List failed", e);
175
+ }
176
+ if (opts.json) {
177
+ console.log(JSON.stringify(resp, null, 2));
178
+ return;
179
+ }
180
+
181
+ const totals = STATUS_ORDER.filter((s) => resp.counts?.[s]).map((s) => `${s} ${resp.counts[s]}`).join(" · ");
182
+ console.log(`CLI requests · status ${resp.status_filter} · ${resp.total} shown · every status: ${totals || "none yet"}`);
183
+ const rows = resp.requests || [];
184
+ if (!rows.length) {
185
+ console.log(" (nothing matches)");
186
+ console.log('\nFile one: flowiq gaps report "<what the CLI cannot do>" --command "<topic>"');
187
+ return;
188
+ }
189
+ for (const r of rows) {
190
+ const cmd = r.command ? `[${r.command}] ` : "";
191
+ const shipped = r.status === "done" && r.resolved_version ? ` ${r.resolved_version}` : "";
192
+ console.log(`\n#${r.number} ${r.kind.padEnd(4)} ×${r.hit_count} ${cmd}${r.title}`);
193
+ const bits = [
194
+ `${r.status}${shipped}`,
195
+ r.priority !== "normal" ? `${r.priority} priority` : null,
196
+ `by ${who(r.reporter_email)}${r.filed_via === "claude" ? " (Claude)" : ""} ${ago(r.created_at)}`,
197
+ r.hit_count > 1 ? `last hit ${ago(r.last_hit_at)}` : null,
198
+ r.organization_name ? `org ${r.organization_name}` : null,
199
+ ].filter(Boolean);
200
+ console.log(` ${bits.join(" · ")}`);
201
+ }
202
+ if (resp.truncated) console.log(`\n⚠ the limit (${resp.limit}) was reached: pass --limit, or narrow with --status / --command / --search.`);
203
+ console.log("\nOne in full: flowiq gaps show <number> · same problem? flowiq gaps hit <number> --note \"…\"");
204
+ }
205
+
206
+ // ── show ────────────────────────────────────────────────────────────────────
207
+ export async function show(ref, opts = {}) {
208
+ const r0 = requireRef(ref);
209
+ let resp;
210
+ try {
211
+ resp = await http.get("gaps", { ref: r0 });
212
+ } catch (e) {
213
+ fail("Show failed", e);
214
+ }
215
+ if (opts.json) {
216
+ console.log(JSON.stringify(resp, null, 2));
217
+ return;
218
+ }
219
+ const r = resp.request;
220
+ console.log(`#${r.number} ${r.title}`);
221
+ console.log(` kind: ${r.kind}${r.command ? ` · flowiq ${r.command}` : ""} · priority ${r.priority}`);
222
+ console.log(` status: ${r.status}${r.resolved_version ? ` (${r.resolved_version})` : ""}${resp.duplicate_of ? ` of #${resp.duplicate_of.number} "${resp.duplicate_of.title}"` : ""}`);
223
+ console.log(` hits: ${r.hit_count} · last ${fmtTs(r.last_hit_at)} SAST`);
224
+ console.log(` filed: ${fmtTs(r.created_at)} SAST by ${r.reporter_email ?? "?"} via ${r.filed_via ?? "?"}${r.cli_version ? ` (CLI ${r.cli_version})` : ""}`);
225
+ if (r.organization_name) console.log(` org: ${r.organization_name}`);
226
+ if (r.resolved_at) console.log(` closed: ${fmtTs(r.resolved_at)} SAST by ${r.resolved_by_email ?? "?"}`);
227
+ console.log(` id: ${r.id}`);
228
+ if (r.detail) console.log(`\nDetail\n${indent(r.detail, " ")}`);
229
+ if (r.error_text) console.log(`\nError (verbatim)\n${indent(r.error_text, " ")}`);
230
+ if (r.workaround) console.log(`\nWorkaround used\n${indent(r.workaround, " ")}`);
231
+ if (r.resolution_note) console.log(`\nResolution\n${indent(r.resolution_note, " ")}`);
232
+
233
+ const events = resp.events || [];
234
+ if (events.length) {
235
+ console.log(`\nHistory (${events.length})`);
236
+ for (const e of events) {
237
+ const head = e.kind === "status"
238
+ ? `status ${e.from_status} → ${e.to_status}`
239
+ : e.kind === "hit" ? "hit again" : "note";
240
+ const org = e.organization_name ? ` · ${e.organization_name}` : "";
241
+ console.log(` ${fmtTs(e.created_at)} ${who(e.user_email)}${e.filed_via === "claude" ? " (Claude)" : ""}: ${head}${org}`);
242
+ if (e.body) console.log(indent(e.body, " "));
243
+ if (e.error_text) console.log(indent(`error: ${e.error_text}`, " "));
244
+ }
245
+ }
246
+ console.log(`\nSame problem? flowiq gaps hit ${r.number} --note "what you were doing"`);
247
+ console.log(`Maintainers (${(resp.editors || []).join(", ")}): flowiq gaps status ${r.number} --to done --version <x.y.z> --note "…"`);
248
+ }
249
+
250
+ // ── hit ─────────────────────────────────────────────────────────────────────
251
+ export async function hit(ref, opts = {}) {
252
+ const r0 = requireRef(ref);
253
+ let resp;
254
+ try {
255
+ resp = await http.post("gaps", {
256
+ action: "hit",
257
+ ref: r0,
258
+ note: opts.note ?? null,
259
+ error_text: opts.error ?? null,
260
+ organization_id: opts.org ?? null,
261
+ filed_via: filedVia(),
262
+ });
263
+ } catch (e) {
264
+ fail("Hit failed", e);
265
+ }
266
+ if (opts.json) {
267
+ console.log(JSON.stringify(resp, null, 2));
268
+ return;
269
+ }
270
+ const r = resp.request;
271
+ console.log(`+1 on #${r.number} (${resp.hit_count} hits): ${r.title}`);
272
+ if (r.status === "done") {
273
+ console.log(` ⚠ this is marked DONE${r.resolved_version ? ` in ${r.resolved_version}` : ""}. Update first (npm i -g @flowapt/flowiq-cli@latest); if it still fails, say so with: flowiq gaps note ${r.number} "still fails on <version>: …"`);
274
+ } else if (r.status !== "open") {
275
+ console.log(` status: ${r.status}`);
276
+ }
277
+ }
278
+
279
+ // ── note ────────────────────────────────────────────────────────────────────
280
+ export async function note(ref, textWords, opts = {}) {
281
+ const r0 = requireRef(ref);
282
+ const text = (Array.isArray(textWords) ? textWords.join(" ") : String(textWords || "")).trim();
283
+ if (!text) {
284
+ console.error("Error: give the note text, e.g. flowiq gaps note 42 \"also happens with --agent\"");
285
+ process.exit(1);
286
+ }
287
+ let resp;
288
+ try {
289
+ resp = await http.post("gaps", { action: "note", ref: r0, body: text });
290
+ } catch (e) {
291
+ fail("Note failed", e);
292
+ }
293
+ if (opts.json) {
294
+ console.log(JSON.stringify(resp, null, 2));
295
+ return;
296
+ }
297
+ console.log(`Noted on #${resp.request.number}: ${resp.request.title}`);
298
+ }
299
+
300
+ // ── status ──────────────────────────────────────────────────────────────────
301
+ export async function status(ref, opts = {}) {
302
+ const r0 = requireRef(ref);
303
+ const to = String(opts.to || "").toLowerCase();
304
+ if (!STATUSES.includes(to)) {
305
+ console.error(`Error: --to must be one of ${STATUSES.join(" | ")} (got "${opts.to ?? ""}").`);
306
+ process.exit(1);
307
+ }
308
+ if (["done", "declined"].includes(to) && !(opts.note && opts.note.trim())) {
309
+ console.error(`Error: --note is required for ${to}. The people who asked get it by email: what shipped, or why not.`);
310
+ process.exit(1);
311
+ }
312
+ let dupRef = null;
313
+ if (to === "duplicate") {
314
+ dupRef = normaliseRef(opts.of);
315
+ if (!dupRef) {
316
+ console.error("Error: --to duplicate needs --of <number>: the request this one duplicates.");
317
+ process.exit(1);
318
+ }
319
+ }
320
+ let resp;
321
+ try {
322
+ resp = await http.post("gaps", {
323
+ action: "status",
324
+ ref: r0,
325
+ status: to,
326
+ note: opts.note ?? null,
327
+ version: opts.version ?? null,
328
+ duplicate_of: dupRef,
329
+ notify: opts.notify !== false,
330
+ });
331
+ } catch (e) {
332
+ fail("Status change failed", e);
333
+ }
334
+ if (opts.json) {
335
+ console.log(JSON.stringify(resp, null, 2));
336
+ return;
337
+ }
338
+ const r = resp.request;
339
+ console.log(`#${r.number} ${resp.previous_status} → ${r.status}${r.resolved_version ? ` (${r.resolved_version})` : ""}: ${r.title}`);
340
+ if (resp.duplicate_of) console.log(` merged into #${resp.duplicate_of.number} "${resp.duplicate_of.title}" (hits carried over)`);
341
+ const n = resp.notified || {};
342
+ if (n.skipped) return;
343
+ if (n.sent?.length) console.log(` emailed: ${n.sent.join(", ")}`);
344
+ else console.log(" nobody else to email (you were the only reporter)");
345
+ for (const f of n.failed || []) console.log(` ⚠ email to ${f.to} failed: ${f.error}`);
346
+ }
@@ -182,7 +182,9 @@ export async function show(planId, opts = {}) {
182
182
  if (more.slides && typeof more.slides === "object") {
183
183
  for (const [key, slide] of Object.entries(more.slides)) {
184
184
  if (!slide || typeof slide !== "object") continue;
185
- console.log(`\n${key}${slide.triggered_by ? ` (after ${slide.triggered_by})` : ""}`);
185
+ const idx = /^button_(\d+)$/.exec(slide.triggered_by ?? "")?.[1];
186
+ const tapped = idx !== undefined && Array.isArray(more.buttons) ? more.buttons[Number(idx)]?.text : null;
187
+ console.log(`\n${key}${tapped ? ` (sent when "${tapped}" is tapped)` : slide.triggered_by ? ` (after ${slide.triggered_by})` : ""}`);
186
188
  if (slide.message) console.log(indent(slide.message));
187
189
  if (slide.media_url) console.log(` media: ${slide.media_url}`);
188
190
  }
@@ -191,7 +193,11 @@ export async function show(planId, opts = {}) {
191
193
  const buttons = Array.isArray(more.buttons) ? more.buttons : [];
192
194
  if (buttons.length) {
193
195
  console.log("\nButtons");
194
- for (const b of buttons) console.log(` [${b?.type ?? "?"}] ${b?.text ?? ""}${b?.url ? ` → ${b.url}` : ""}`);
196
+ // Marketing plans carry 0-10 buttons: quick_reply, url, phone, copy_code (app src/lib/broadcastPlanButtons.ts).
197
+ for (const b of buttons) {
198
+ const target = b?.url ? ` → ${b.url}` : b?.phone_number ? ` → call ${b.phone_number}` : b?.code ? ` → code ${b.code}` : "";
199
+ console.log(` [${b?.type ?? "?"}] ${b?.text ?? ""}${target}`);
200
+ }
195
201
  }
196
202
 
197
203
  console.log("\nCreative");
@@ -0,0 +1,48 @@
1
+ // node --test src/gaps.test.mjs
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { filedVia, normaliseRef, ago } from "./commands/gaps.js";
5
+ import { gapHint } from "./index.js";
6
+
7
+ test("filedVia: a Claude Code session is marked claude, even on a TTY", () => {
8
+ const tty = { isTTY: true };
9
+ assert.equal(filedVia({ CLAUDECODE: "1" }, tty, tty), "claude");
10
+ assert.equal(filedVia({ CLAUDE_CODE_ENTRYPOINT: "cli" }, tty, tty), "claude");
11
+ });
12
+
13
+ test("filedVia: a person at a terminal vs a pipe or script", () => {
14
+ assert.equal(filedVia({}, { isTTY: true }, { isTTY: true }), "human");
15
+ assert.equal(filedVia({}, { isTTY: undefined }, { isTTY: true }), "script");
16
+ assert.equal(filedVia({}, { isTTY: true }, { isTTY: false }), "script");
17
+ });
18
+
19
+ test("normaliseRef: number, #number and uuid pass; anything else is refused", () => {
20
+ assert.equal(normaliseRef("42"), "42");
21
+ assert.equal(normaliseRef("#42"), "42");
22
+ assert.equal(normaliseRef(" 7 "), "7");
23
+ assert.equal(normaliseRef("0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b"), "0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b");
24
+ assert.equal(normaliseRef("abc"), null);
25
+ assert.equal(normaliseRef("42a"), null);
26
+ assert.equal(normaliseRef(""), null);
27
+ assert.equal(normaliseRef(undefined), null);
28
+ });
29
+
30
+ test("ago: minutes, hours, days", () => {
31
+ const now = Date.parse("2026-09-30T12:00:00Z");
32
+ assert.equal(ago("2026-09-30T11:55:00Z", now), "5m ago");
33
+ assert.equal(ago("2026-09-30T09:00:00Z", now), "3h ago");
34
+ assert.equal(ago("2026-09-26T12:00:00Z", now), "4d ago");
35
+ assert.equal(ago(null, now), "-");
36
+ });
37
+
38
+ test("gapHint: only for a missing command / flag, with the topic filled in", () => {
39
+ const argv = ["node", "flowiq", "agent", "config", "0689d0be-ba76-43d9-a0d5-f4d59c1db537", "--tool-instructions", "x"];
40
+ const hint = gapHint("error: unknown option '--tool-instructions'\n", argv);
41
+ assert.match(hint, /flowiq gaps report/);
42
+ assert.match(hint, /--command "agent config"/);
43
+ assert.match(hint, /--error "unknown option '--tool-instructions'"/);
44
+ assert.equal(gapHint("error: missing required argument 'organization_id'\n", argv), "");
45
+ assert.match(gapHint("error: unknown command 'widgets'\n", ["node", "flowiq", "widgets", "list"]), /--command "widgets list"/);
46
+ // an id in second place is not part of the topic
47
+ assert.match(gapHint("error: too many arguments\n", ["node", "flowiq", "messages", "0689d0be-ba76-43d9-a0d5-f4d59c1db537"]), /--command "messages"/);
48
+ });
package/src/index.js CHANGED
@@ -27,6 +27,7 @@ import * as agentConfigCmd from "./commands/agent-config.js";
27
27
  import * as agentsCmd from "./commands/agents.js";
28
28
  import * as agentUpdatesCmd from "./commands/agent-updates.js";
29
29
  import * as plansCmd from "./commands/plans.js";
30
+ import * as gapsCmd from "./commands/gaps.js";
30
31
  import * as exportCmd from "./commands/export.js";
31
32
  import * as testCmd from "./commands/agent-test.js";
32
33
  import * as knowledgeCmd from "./commands/knowledge.js";
@@ -48,6 +49,19 @@ import * as doctorCmd from "./commands/doctor.js";
48
49
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
49
50
  const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
50
51
 
52
+ /** The one-line "file it" hint appended to a missing-command / missing-flag error. */
53
+ export function gapHint(errorText, argv = []) {
54
+ if (!/unknown option|unknown command|too many arguments/i.test(String(errorText))) return "";
55
+ const args = argv.slice(2).filter((a) => !a.startsWith("-"));
56
+ // "agent config", "bc send", "org flags": a verb-shaped second word belongs to the topic.
57
+ const topic = (args[1] && /^[a-z][a-z-]*$/.test(args[1]) ? args.slice(0, 2) : args.slice(0, 1)).join(" ") || "<topic>";
58
+ const firstLine = String(errorText).split("\n")[0].replace(/^error:\s*/i, "").replace(/"/g, "'").trim();
59
+ return (
60
+ ` → If the CLI should be able to do this, file it so it gets built:\n` +
61
+ ` flowiq gaps report "<what you needed>" --command "${topic}" --error "${firstLine}"\n`
62
+ );
63
+ }
64
+
51
65
  export function run(argv) {
52
66
  // Non-blocking "you're behind" hint (stderr; cached; detached refresh).
53
67
  maybeNotifyUpdate(pkg.version);
@@ -56,7 +70,12 @@ export function run(argv) {
56
70
  program
57
71
  .name("flowiq")
58
72
  .description("FlowIQ staff CLI — round-trip agent prompts, webhooks, and more, without service-role credentials.")
59
- .version(pkg.version);
73
+ .version(pkg.version)
74
+ // Set before any subcommand exists so every subcommand inherits it. When a
75
+ // command or flag does not exist, that is usually a gap: say how to file it,
76
+ // so a teammate (or their Claude session) records it instead of quietly
77
+ // falling back to SQL.
78
+ .configureOutput({ outputError: (str, write) => write(str + gapHint(str, argv)) });
60
79
 
61
80
  // auth
62
81
  const auth = program.command("auth").description("Manage CLI authentication");
@@ -579,6 +598,60 @@ export function run(argv) {
579
598
  .option("--commit", "apply the change (default is a dry run)")
580
599
  .action((planId, opts) => plansCmd.status(planId, opts));
581
600
 
601
+ // gaps (CLI gap reports + feature requests, filed by anyone incl. a Claude session)
602
+ const gaps = program.command("gaps")
603
+ .alias("requests")
604
+ .description("Found something the CLI can't do (or does wrong)? `report` it; `list` what is asked for; `hit` one that bit you too");
605
+ gaps.command("report <title...>")
606
+ .description("File a gap / bug / idea (one-line title). Refused with the matching number if it is already on the list")
607
+ .option("--command <topic>", "the flowiq command it concerns, e.g. \"agent config\" or \"bc send\" (none yet? name the one you expected)")
608
+ .option("--kind <kind>", "gap (default: the CLI cannot do it) | bug (it does it wrong) | idea (it should)")
609
+ .option("--detail <text>", "what you were trying to do and what should happen")
610
+ .option("--detail-file <path>", "read --detail from a file")
611
+ .option("--error <text>", "the exact error the CLI printed, verbatim")
612
+ .option("--workaround <text>", "what you did instead (SQL, the dashboard, a script…)")
613
+ .option("--org <uuid>", "the org you were working on, for context")
614
+ .option("--priority <p>", "low | normal (default) | high — high = it blocked the job")
615
+ .option("--new", "file it even though a similar open request exists")
616
+ .option("--dry-run", "only show similar requests; file nothing")
617
+ .option("--json", "print the raw response")
618
+ .action((titleWords, opts) => gapsCmd.report(titleWords, opts));
619
+ gaps.command("list")
620
+ .description("Requests, most-hit first (default: open = open, planned, in_progress)")
621
+ .option("--status <list>", "open (default) | all | open | planned | in_progress | done | declined | duplicate (comma-separated allowed)")
622
+ .option("--command <topic>", "only requests about this command (contains)")
623
+ .option("--kind <kind>", "gap | bug | idea")
624
+ .option("--search <text>", "title / detail / error contains this text")
625
+ .option("--mine", "only requests you filed")
626
+ .option("--sort <how>", "hits (default) | new | old | touched")
627
+ .option("--limit <n>", "max rows (default 100, max 500)")
628
+ .option("--json", "print the raw response")
629
+ .action((opts) => gapsCmd.list(opts));
630
+ gaps.command("show <number>")
631
+ .description("One request in full: detail, verbatim error, every hit, note and status change")
632
+ .option("--json", "print the raw response")
633
+ .action((ref, opts) => gapsCmd.show(ref, opts));
634
+ gaps.command("hit <number>")
635
+ .description("It bit you too: +1 with what you were doing (the most-hit requests get built first)")
636
+ .option("--note <text>", "what you were doing when you hit it")
637
+ .option("--error <text>", "the exact error, if it differs")
638
+ .option("--org <uuid>", "the org you were working on")
639
+ .option("--json", "print the raw response")
640
+ .action((ref, opts) => gapsCmd.hit(ref, opts));
641
+ gaps.command("note <number> <text...>")
642
+ .description("Add context to a request without counting a hit")
643
+ .option("--json", "print the raw response")
644
+ .action((ref, textWords, opts) => gapsCmd.note(ref, textWords, opts));
645
+ gaps.command("status <number>")
646
+ .description("Maintainers: planned | in_progress | done | declined | duplicate. Closing emails everyone who reported or hit it")
647
+ .requiredOption("--to <status>", "open | planned | in_progress | done | declined | duplicate")
648
+ .option("--note <text>", "what shipped / why not (required for done + declined; the people who asked read it)")
649
+ .option("--version <x.y.z>", "the CLI version the fix shipped in (done)")
650
+ .option("--of <number>", "with --to duplicate: the request this one duplicates (hits move across)")
651
+ .option("--no-notify", "close without emailing the people who asked")
652
+ .option("--json", "print the raw response")
653
+ .action((ref, opts) => gapsCmd.status(ref, opts));
654
+
582
655
  // audit (who did what, when — with the full before/after content)
583
656
  const audit = program.command("audit")
584
657
  .description("Staff-CLI audit trail: who changed what, when — with full before/after content")