@flowapt/flowiq-cli 0.9.6 → 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 +51 -1
- package/TEAM-GUIDE.md +10 -0
- package/package.json +1 -1
- package/src/commands/gaps.js +346 -0
- package/src/gaps.test.mjs +48 -0
- package/src/index.js +74 -1
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)
|
|
@@ -1859,6 +1862,53 @@ flowiq plans status <plan_id> --to sent --commit
|
|
|
1859
1862
|
- Editing a plan's copy, buttons or links is not in the CLI yet; do that in
|
|
1860
1863
|
the app.
|
|
1861
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
|
+
|
|
1862
1912
|
### Chat export — `flowiq export chats <organization_id> [--out <path>]`
|
|
1863
1913
|
|
|
1864
1914
|
Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
|
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
|
|
|
@@ -121,6 +127,9 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
121
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 |
|
|
122
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 |
|
|
123
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 |
|
|
124
133
|
> **Publishing the CLI (maintainers only):** publish from a clean clone, never
|
|
125
134
|
> from your working tree — `npm publish` packs whatever is on disk. A
|
|
126
135
|
> `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
|
|
@@ -221,6 +230,7 @@ several.
|
|
|
221
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 |
|
|
222
231
|
| See whether a client deck can be approved, and what is still missing | `flowiq report deck status <org_id> 2026-08` |
|
|
223
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 } }` |
|
|
224
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) |
|
|
225
235
|
| Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
|
|
226
236
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.9.
|
|
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": {
|
|
@@ -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
|
+
}
|
|
@@ -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")
|