@wrangled/cli 0.3.2 → 0.3.4
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/dist/api.js +11 -1
- package/dist/files.js +1 -0
- package/dist/guide.js +20 -3
- package/dist/main.js +202 -22
- package/dist/playbooks.js +42 -6
- package/dist/pull.js +2 -2
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -146,6 +146,16 @@ export async function putSigned(signedUrl, body, contentType) {
|
|
|
146
146
|
* token, so no message ever includes it.
|
|
147
147
|
*/
|
|
148
148
|
export async function getSigned(signedUrl) {
|
|
149
|
+
return new Uint8Array(await (await fetchSigned(signedUrl)).arrayBuffer());
|
|
150
|
+
}
|
|
151
|
+
/** Like `getSigned`, but hands over the file as it arrives instead of holding it whole. */
|
|
152
|
+
export async function streamSigned(signedUrl) {
|
|
153
|
+
const response = await fetchSigned(signedUrl);
|
|
154
|
+
if (!response.body)
|
|
155
|
+
throw new CliError("File storage answered the download with no file. Try again.", exitCodes.error);
|
|
156
|
+
return response.body;
|
|
157
|
+
}
|
|
158
|
+
async function fetchSigned(signedUrl) {
|
|
149
159
|
const signed = new URL(signedUrl);
|
|
150
160
|
let response;
|
|
151
161
|
try {
|
|
@@ -159,7 +169,7 @@ export async function getSigned(signedUrl) {
|
|
|
159
169
|
await response.body?.cancel();
|
|
160
170
|
throw new CliError(`File storage answered HTTP ${response.status} to the download. Try again.`, exitCodes.error);
|
|
161
171
|
}
|
|
162
|
-
return
|
|
172
|
+
return response;
|
|
163
173
|
}
|
|
164
174
|
async function request(connection, path, init) {
|
|
165
175
|
let response;
|
package/dist/files.js
CHANGED
|
@@ -41,6 +41,7 @@ async function realLocation(path) {
|
|
|
41
41
|
return join(await realLocation(parent), basename(path));
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
/** `content` may arrive in pieces, which are written as they come; the file appears whole or not at all. */
|
|
44
45
|
export async function writeFileAtomic(path, content, mode) {
|
|
45
46
|
const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
46
47
|
try {
|
package/dist/guide.js
CHANGED
|
@@ -37,7 +37,7 @@ To read the whole library with your own tools, such as "every email we have" or
|
|
|
37
37
|
|
|
38
38
|
1. Run \`${cli} warehouse schema --summary\` before writing any SQL. It lists each table once: whether it is a table or a view, which source fills it, how many columns it has, and a one-line summary. Then run \`${cli} warehouse schema --table <name>\` (repeat for several) or \`--search <words>\` for the full detail of just the tables you need: every column with its Postgres type, nullability, and description. Plain \`${cli} warehouse schema\` prints every column of every table, which on a big warehouse is a lot. Read the descriptions: they say what null means and which metrics do not add up. Use only these names. Never guess a table or column name.
|
|
39
39
|
2. Write one SQL statement in the PostgreSQL dialect. Refer to tables unqualified (\`FROM orders\`). The workspace schema is already on the search path.
|
|
40
|
-
3. Run it: \`${cli} warehouse query "SELECT ..."\`. For long SQL, use \`--file query.sql\` or pipe it on stdin. To keep the rows out of your context, add \`--out results.csv\` (or \`.json\`): it saves every row the server returned and prints one line.
|
|
40
|
+
3. Run it: \`${cli} warehouse query "SELECT ..."\`. For long SQL, use \`--file query.sql\` or pipe it on stdin. To keep the rows out of your context, add \`--out results.csv\` (or \`.json\`): it saves every row the server returned and prints one line. When you need more rows than one answer holds, add \`--all\` as well: the server saves the whole answer to a file and the CLI downloads it (see Limits).
|
|
41
41
|
4. Aggregate in SQL (\`GROUP BY\`, \`SUM\`, \`date_trunc\`) and add \`LIMIT\`. Do not pull raw rows to aggregate them yourself.
|
|
42
42
|
5. Before trusting recent or old numbers, run \`${cli} sources list\`. It shows when each source last synced, its last error, and when it syncs next. \`${cli} sources show <id>\` adds each table's row count and date range, the history still to fetch, and the last sync runs.
|
|
43
43
|
|
|
@@ -62,6 +62,7 @@ A webhook address lets a tool such as a form or webinar platform push events int
|
|
|
62
62
|
- One statement per call, and it must return rows: \`SELECT\`, \`VALUES\`, or \`WITH ... SELECT\`. Anything else (\`INSERT\`, \`UPDATE\`, \`DELETE\`, DDL, \`SET\`, \`COPY\`) fails.
|
|
63
63
|
- 15-second statement timeout.
|
|
64
64
|
- Results stop at 1000 rows or about 5 MB. In JSON output, \`truncated\` is \`"row_limit"\` or \`"byte_limit"\` when a limit was hit, otherwise \`null\`.
|
|
65
|
+
- \`--out <file> --all\` saves the whole answer instead, up to 1000000 rows or about 50 MB, through a download link that works for 10 minutes; the server deletes its copy after 24 hours. A \`.ndjson\` or \`.jsonl\` file gets one JSON row per line. Over the REST API, \`POST /api/v1/warehouse/export\` with \`{"sql": "..."}\` answers \`{ url, format, columns, rowCount, bytes, truncated, expiresAt }\`; the file at \`url\` is NDJSON.
|
|
65
66
|
- In JSON output, \`columns\` lists each column's name and Postgres type. \`numeric\` and \`bigint\` values are JSON numbers with their exact digits, so parse them with a big-number-safe parser when precision matters. Dates are \`YYYY-MM-DD\` strings.
|
|
66
67
|
- Every output column needs a unique name. Alias duplicates (\`a.id AS order_id\`).
|
|
67
68
|
|
|
@@ -142,6 +143,8 @@ Each workspace keeps one map of what the client sells (the offer stack) and the
|
|
|
142
143
|
|
|
143
144
|
To change the map, write the whole map as JSON (\`{"offers": [...], "funnels": [...]}\`, the \`map\` of \`${cli} funnels show --format json\`) and run \`${cli} funnels draft --file map.json --note "<what changed and why>"\`. Add \`--evidence '<json>'\` or \`@file.json\` with what you looked at: counts and ids, never names, emails or phones. It is checked against synced data, and when every step's rule is filled in it goes live at once, confirmed by \`system:auto\`; a person can undo it in the app. A map with a rule not filled in stays on hold as the draft and counts nothing; saving replaces the draft on hold. Never invent an id or a number to fill a rule. Pass \`--based-on <n>\` with the version you read (the draft on hold, else the confirmed map): if someone saved since, the save is refused as \`draft_conflict\`, and you show again and redo your change on top. Leave out \`--based-on\` only for the first map, when there is neither a map nor a draft; once there is one, a save without it is refused as \`draft_conflict\`. A map that does not fit is refused with every problem by path, such as \`funnels[0].steps[1].offer_key: no offer has the key x\`. Saving prints whether the map went live, what is left to fill in (an asset not synced yet, a rule with no calendar chosen), and the parts synced data does not back up, such as an offer with fewer than 3 buyers in a year.
|
|
144
145
|
|
|
146
|
+
\`--counts-from fix\` (the default) makes the map count for every past date the current map counted, as a fix; \`--counts-from <YYYY-MM-DD>\` makes it a change in the business that counts from that day (today or earlier). Save a fix and a change as two drafts, the fix first. \`--questions @questions.json\` asks a person what only they can settle: an array of \`{question_id, text, kind, evidence, options: [{label, patch}], default_option}\`, where the map already holds the default option and each other option's patch is JSON Patch ops on the map, naming offers, funnels and steps by key (\`/offers/course/matches/-\`). A question whose options all count the same sales and people, or that was answered already, is left out; at most 7 stay open, biggest money at stake first, and the checks add a yes/no question for each weak part no question covers. People answer them in the app; an answer saves the map as a fix, and later drafts keep it. \`${cli} funnels show\` lists the open and answered questions and says whether the map counts as a fix or from a date.
|
|
147
|
+
|
|
145
148
|
\`${cli} funnels numbers\` counts each step of each funnel in the confirmed map over \`--from\` and \`--to\` (default the last 30 days; \`--funnel <key>\` for one funnel): how many reached it, and how many of those who reached the step before in those days moved on to it within the window, with the drop-off and the median days it took. A step whose rules name a person (webinar, GoHighLevel, payments) counts people and follows the same people from step to step; a step that cannot (ad clicks, page visitors) counts events, and its comparison is a rate of counts. A step whose data is not connected says unknown, never 0. Numbers are worked out after each sync by the rules of the map version confirmed at the time, so a renamed step keeps its numbers. For SQL, read \`saved_funnel_steps\`, \`funnel_step_daily\` and \`funnel_conversion\`.
|
|
146
149
|
|
|
147
150
|
\`${cli} funnels evidence\` gathers what the workspace's own data says it sells, to draft a map from: the prices people paid in the last 365 days on each platform with buyer counts, the most common orders in which one person paid (\`1500 → 497 → 3500\`, with the median days between), GoHighLevel funnels and their steps, calendars with bookings and shows, pipeline stages, the most used tags, webinars with sign-ups, viewers and offer clicks, the landing pages ads link to, and active Facebook campaigns with 30-day spend. A part whose source is not connected says so. Ids, counts and amounts only. The \`map-offers\` playbook (\`${cli} skill install map-offers\` installs it as the \`${MAP_OFFERS_SKILL_NAME}\` skill, with a worked example map beside it; \`${cli} docs map-offers\` prints it) walks an agent from the evidence to a draft.
|
|
@@ -156,6 +159,18 @@ Kinds are \`exclusion\`, \`gap\`, \`baseline\`, \`change\`, \`definition\`, \`ca
|
|
|
156
159
|
|
|
157
160
|
To suggest a fact, write it as JSON and run \`${cli} facts propose --file fact.json\` (or pipe it on stdin): \`{"kind": "gap", "subject_type": "table", "subject_ref": "meta_insights_daily", "key": "history_start", "value": {"table": "meta_insights_daily", "from": "2026-04-07"}, "summary": "Facebook ad numbers only go back to April 7, 2026.", "evidence": {"query": "SELECT min(date) FROM meta_insights_daily"}}\`. You only suggest: a person confirms, fixes or rejects it in the app. A fact with the same kind, subject and key already open is refused as \`fact_exists\`. Evidence is counts, queries and ids, never names, emails or phones.
|
|
158
161
|
|
|
162
|
+
## The weekly story
|
|
163
|
+
|
|
164
|
+
Each workspace keeps a weekly story: a one-screen read of what the data says this week, why, what is working, what the data cannot tell us, and what to do next. \`${cli} story\` prints the newest version, \`${cli} story list\` every version newest first, and \`${cli} story show <version>\` one of them (each takes \`--json\`). Every save is a new version; nothing is ever changed or deleted.
|
|
165
|
+
|
|
166
|
+
To save one, write it as JSON and run \`${cli} story submit --file story.json\` (or pipe it on stdin):
|
|
167
|
+
|
|
168
|
+
\`\`\`json
|
|
169
|
+
{"period_end": "2026-09-27", "windows": [{"key": "7d", "from": "2026-09-21", "until": "2026-09-27", "vs_from": "2026-09-14", "vs_until": "2026-09-20"}], "headline": "Leads held steady while spend fell 12%.", "sections": [{"key": "what_happened", "title": "What happened", "audience": "both", "claims": [{"text": "Spend fell 12% week over week.", "confidence": "high", "evidence": ["spend"]}]}], "evidence": {"spend": {"sql": "SELECT sum(spend) FROM meta_insights_daily WHERE date >= '2026-09-14'"}}}
|
|
170
|
+
\`\`\`
|
|
171
|
+
|
|
172
|
+
Each section's \`audience\` is \`staff\`, \`client\` or \`both\`; a person on staff decides in the app when the client part may be shown. Every claim lists its evidence: keys of \`evidence\`, or \`fact:<id>\` for a settled fact. Every evidence query must be used by a claim, and a story carries at most 30 queries. The server runs each query itself, read-only, and keeps the rows it got back, so the numbers people see are what the warehouse said; any rows you send are ignored. A query that fails, is not one SELECT or WITH statement, or was cut off at the warehouse's row or size limit refuses the whole story with its key named, and so do rows past 256 KiB across the story: keep evidence to the totals the sentences use. Cite only proposed or confirmed facts. To correct a story, submit a new one with \`"previous_id"\` set to the id of the one it fixes.
|
|
173
|
+
|
|
159
174
|
## The Facebook ads agent
|
|
160
175
|
|
|
161
176
|
The daily Facebook ads agent follows its own playbook: install it with \`${cli} skill install meta-ads\` (or \`--codex\` for an AGENTS.md in the current directory) and read it with \`${cli} docs meta-ads\`. It works in runs (\`${cli} agent start\`, \`${cli} agent finish\`), reads and suggests settled facts (\`${cli} facts\`), keeps a brand profile (\`${cli} brand\`), and makes proposals (\`${cli} proposals\`) that a person approves in the web app. Nothing it does is sent to Meta.
|
|
@@ -192,7 +207,7 @@ ${cli} whoami show the current workspace
|
|
|
192
207
|
${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
|
|
193
208
|
${cli} brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id>] [--top <n>] the offer, voice, best and worst ads, pages, results and audience: start here before writing ads
|
|
194
209
|
${cli} warehouse schema [--summary] [--table <name> ...] [--search <words>] list tables and columns; --summary for one row per table
|
|
195
|
-
${cli} warehouse query "<sql>" run one read-only query (or --file <path>, or stdin); --out <file.csv | file.json> saves the rows
|
|
210
|
+
${cli} warehouse query "<sql>" run one read-only query (or --file <path>, or stdin); --out <file.csv | file.ndjson | file.json> saves the rows; add --all for every row
|
|
196
211
|
${cli} funnel [--from <date>] [--to <date>] [--grain day|week|month] spend, leads, calls and sales per period, with totals
|
|
197
212
|
${cli} money [--from <date>] [--to <date>] [--grain day|week|month] money in, refunds, ad spend, payouts and what is left, per period, with totals
|
|
198
213
|
${cli} campaigns [--from <date>] [--to <date>] [--channel <channel>] [--limit <n>] each campaign's results, best first, with totals
|
|
@@ -240,9 +255,11 @@ ${cli} trail <plan-id | sku | item-id> how a plan's items were made, where the
|
|
|
240
255
|
${cli} funnels show [--version <n>] | funnels versions [--diff <a> <b>] the offers and funnels map, as text, and its versions
|
|
241
256
|
${cli} funnels numbers [--funnel <key>] [--from <date>] [--to <date>] how many reached each funnel step and moved on
|
|
242
257
|
${cli} funnels evidence prices paid, buyer paths, GoHighLevel funnels, calendars and tags, webinars, pages and campaigns, to draft the map from
|
|
243
|
-
${cli} funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>] save the whole map; it goes live when every step can be counted
|
|
258
|
+
${cli} funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>] [--counts-from fix|<date>] [--questions @file.json] save the whole map; it goes live when every step can be counted
|
|
244
259
|
${cli} facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--json] | facts show <id> settled facts about the data, to treat as true
|
|
245
260
|
${cli} facts propose --file <fact.json> suggest a fact for a person to confirm
|
|
261
|
+
${cli} story [--json] | story list | story show <version> the weekly story, its versions, and the rows behind each sentence
|
|
262
|
+
${cli} story submit --file <story.json> save the next version of the weekly story
|
|
246
263
|
${cli} worker list | worker runs <hand-id | skill> [--limit <n>] | worker run-now <hand-id | skill> this workspace's scheduled hands, their runs and costs, and Run now
|
|
247
264
|
${cli} skill install [${PLAYBOOK_CHOICES}] [--project] [--codex] install a Claude Code skill (or the playbook for Codex)
|
|
248
265
|
${cli} docs [${PLAYBOOK_CHOICES}] print this guide, or a hand's playbook
|
package/dist/main.js
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdir, readFile, stat } from "node:fs/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
|
-
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, IMPORT_NAME_PATTERN, putSigned, readCredentials, removeCredentials, resolveApiUrl, resolveConnection, UnreadableCredentialsError, writeCredentials, } from "./api.js";
|
|
7
|
+
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, IMPORT_NAME_PATTERN, putSigned, readCredentials, removeCredentials, resolveApiUrl, resolveConnection, UnreadableCredentialsError, writeCredentials, streamSigned, } from "./api.js";
|
|
8
8
|
import { brand, envPrefix } from "./brand.js";
|
|
9
9
|
import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
|
|
10
10
|
import { guide, skill } from "./guide.js";
|
|
@@ -102,10 +102,16 @@ const commands = {
|
|
|
102
102
|
},
|
|
103
103
|
},
|
|
104
104
|
"warehouse query": {
|
|
105
|
-
usage: 'warehouse query "<sql>" | --file <path> | < query.sql [--out <file.csv | file.json>]',
|
|
105
|
+
usage: 'warehouse query "<sql>" | --file <path> | < query.sql [--out <file.csv | file.ndjson | file.json> [--all]]',
|
|
106
106
|
async run(args, options) {
|
|
107
|
+
if (options.all && options.out === undefined) {
|
|
108
|
+
throw new CliError("--all saves to a file: add --out <file.csv | file.ndjson | file.json>.", exitCodes.usage);
|
|
109
|
+
}
|
|
107
110
|
const sql = await readSql(args, options.file);
|
|
108
|
-
const
|
|
111
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
112
|
+
if (options.all)
|
|
113
|
+
return saveWholeAnswer(connection, sql, options.out, options.format);
|
|
114
|
+
const text = await apiText(connection, "/api/v1/warehouse/query", { sql });
|
|
109
115
|
if (options.out !== undefined)
|
|
110
116
|
return saveQueryResult(text, options.out, options.format);
|
|
111
117
|
if (options.format === "json")
|
|
@@ -113,7 +119,7 @@ const commands = {
|
|
|
113
119
|
const result = queryResultWithExactNumbers(text);
|
|
114
120
|
console.log(renderTable(result.columns.map((c) => c.name), result.rows));
|
|
115
121
|
console.log(`(${rowCount(result.rows.length)})`);
|
|
116
|
-
warnIfTruncated(result);
|
|
122
|
+
warnIfTruncated(result.truncated);
|
|
117
123
|
},
|
|
118
124
|
},
|
|
119
125
|
funnel: {
|
|
@@ -983,24 +989,36 @@ const commands = {
|
|
|
983
989
|
},
|
|
984
990
|
},
|
|
985
991
|
"funnels draft": {
|
|
986
|
-
usage: "funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>] save a whole map; it counts at once when every step can be counted",
|
|
992
|
+
usage: "funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>] [--counts-from fix|<YYYY-MM-DD>] [--questions '<json>' | @file.json] save a whole map; it counts at once when every step can be counted",
|
|
987
993
|
async run(args, options) {
|
|
988
|
-
const usage = "Usage: funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>]";
|
|
994
|
+
const usage = "Usage: funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>] [--counts-from fix|<YYYY-MM-DD>] [--questions '<json>' | @file.json]";
|
|
989
995
|
if (args.length > 0 || !options.file)
|
|
990
996
|
throw new CliError(usage, exitCodes.usage);
|
|
997
|
+
if (options.countsFrom !== undefined && options.countsFrom !== "fix" && !/^\d{4}-\d{2}-\d{2}$/.test(options.countsFrom)) {
|
|
998
|
+
throw new CliError("--counts-from is fix or a date, YYYY-MM-DD", exitCodes.usage);
|
|
999
|
+
}
|
|
991
1000
|
const map = await parseJson(`@${options.file}`, "--file");
|
|
992
1001
|
const evidence = options.evidence === undefined ? undefined : await parseJson(options.evidence, "--evidence");
|
|
1002
|
+
const questions = options.questions === undefined ? undefined : await parseJson(options.questions, "--questions");
|
|
993
1003
|
const connection = await resolveConnection(options.apiUrl);
|
|
994
1004
|
const basedOn = options.basedOn === undefined ? null : wholeNumber(options.basedOn, "--based-on");
|
|
995
|
-
const saved = await api(connection, `${FUNNELS_PATH}/draft`, { map, note: options.note, evidence, based_on: basedOn }, "PUT");
|
|
1005
|
+
const saved = await api(connection, `${FUNNELS_PATH}/draft`, { map, note: options.note, evidence, based_on: basedOn, counts_from: options.countsFrom, questions }, "PUT");
|
|
996
1006
|
if (options.format === "json")
|
|
997
1007
|
return printJson(saved);
|
|
998
1008
|
const based = basedOn === null ? "" : `, based on version ${basedOn}`;
|
|
1009
|
+
const counts = options.countsFrom === undefined || options.countsFrom === "fix" ? "it counts for past dates too, as a fix" : `it counts from ${options.countsFrom}, as a change`;
|
|
999
1010
|
console.log(saved.applied
|
|
1000
|
-
? `Map version ${saved.version.version} is live${based}:
|
|
1011
|
+
? `Map version ${saved.version.version} is live${based}: ${counts}. A person can undo it in the app.`
|
|
1001
1012
|
: `Saved version ${saved.version.version}${based}. It is on hold: it counts nothing until every step can be counted.`);
|
|
1002
|
-
if (saved.
|
|
1003
|
-
console.log(`\
|
|
1013
|
+
if (saved.reapplied.length > 0)
|
|
1014
|
+
console.log(`\nAnswers put back into the map: ${saved.reapplied.join(", ")}`);
|
|
1015
|
+
if (saved.asked.length > 0)
|
|
1016
|
+
console.log(`\nQuestions open for a person, biggest money at stake first: ${saved.asked.join(", ")}`);
|
|
1017
|
+
const [left, warnings] = [saved.warnings.filter((w) => w.message.startsWith("question ")), saved.warnings.filter((w) => !w.message.startsWith("question "))];
|
|
1018
|
+
if (warnings.length > 0)
|
|
1019
|
+
console.log(`\n${saved.applied ? "Not in synced data yet" : "To fill in"}:\n${warnings.map((w) => `- ${w.path}: ${w.message}`).join("\n")}`);
|
|
1020
|
+
if (left.length > 0)
|
|
1021
|
+
console.log(`\nQuestions left out:\n${left.map((w) => `- ${w.path}: ${w.message}`).join("\n")}`);
|
|
1004
1022
|
if (saved.weak.length > 0)
|
|
1005
1023
|
console.log(`\nNot backed up by synced data:\n${saved.weak.map((w) => `- ${w.path}: ${w.reason}`).join("\n")}`);
|
|
1006
1024
|
},
|
|
@@ -1034,8 +1052,9 @@ const commands = {
|
|
|
1034
1052
|
"confirmed by": v.confirmedBy,
|
|
1035
1053
|
"confirmed at": v.confirmedAt,
|
|
1036
1054
|
note: v.note && excerpt(v.note),
|
|
1055
|
+
counts: v.counts == null ? null : v.counts.kind === "fix" ? "for past dates too (fix)" : `from ${v.counts.from} (change)`,
|
|
1037
1056
|
}));
|
|
1038
|
-
console.log(renderTable(["version", "status", "created by", "created at", "confirmed by", "confirmed at", "note"], rows));
|
|
1057
|
+
console.log(renderTable(["version", "status", "created by", "created at", "confirmed by", "confirmed at", "note", "counts"], rows));
|
|
1039
1058
|
},
|
|
1040
1059
|
},
|
|
1041
1060
|
facts: {
|
|
@@ -1084,6 +1103,66 @@ const commands = {
|
|
|
1084
1103
|
console.log(`Proposed fact ${fact.id}. A person confirms or rejects it in the app.`);
|
|
1085
1104
|
},
|
|
1086
1105
|
},
|
|
1106
|
+
story: {
|
|
1107
|
+
usage: "story the newest weekly story, with the rows behind each sentence",
|
|
1108
|
+
async run(args, options) {
|
|
1109
|
+
if (args.length > 0)
|
|
1110
|
+
throw new CliError("Usage: story | story list | story show <version> | story submit --file <story.json>", exitCodes.usage);
|
|
1111
|
+
const { story } = await api(await resolveConnection(options.apiUrl), `${STORIES_PATH}/latest`);
|
|
1112
|
+
if (options.format === "json")
|
|
1113
|
+
return printJson({ story });
|
|
1114
|
+
if (!story)
|
|
1115
|
+
return console.log("No story yet.");
|
|
1116
|
+
printStory(story);
|
|
1117
|
+
},
|
|
1118
|
+
},
|
|
1119
|
+
"story list": {
|
|
1120
|
+
usage: "story list every version of the weekly story, newest first",
|
|
1121
|
+
async run(args, options) {
|
|
1122
|
+
if (args.length > 0)
|
|
1123
|
+
throw new CliError("Usage: story list", exitCodes.usage);
|
|
1124
|
+
const { stories } = await api(await resolveConnection(options.apiUrl), STORIES_PATH);
|
|
1125
|
+
if (options.format === "json")
|
|
1126
|
+
return printJson({ stories });
|
|
1127
|
+
if (stories.length === 0)
|
|
1128
|
+
return console.log("No story yet.");
|
|
1129
|
+
const versions = new Map(stories.map((s) => [s.id, s.version]));
|
|
1130
|
+
const rows = stories.map((s) => ({
|
|
1131
|
+
version: s.version,
|
|
1132
|
+
"period end": s.periodEnd,
|
|
1133
|
+
corrects: s.previousId && versions.get(s.previousId),
|
|
1134
|
+
author: s.author,
|
|
1135
|
+
saved: s.createdAt,
|
|
1136
|
+
headline: excerpt(s.headline, 80),
|
|
1137
|
+
}));
|
|
1138
|
+
console.log(renderTable(["version", "period end", "corrects", "author", "saved", "headline"], rows));
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
"story show": {
|
|
1142
|
+
usage: "story show <version> one version of the weekly story",
|
|
1143
|
+
async run(args, options) {
|
|
1144
|
+
if (args.length !== 1)
|
|
1145
|
+
throw new CliError("Usage: story show <version>", exitCodes.usage);
|
|
1146
|
+
// `show latest` reaches the newest-story route, which answers null before the first story.
|
|
1147
|
+
const { story } = await api(await resolveConnection(options.apiUrl), `${STORIES_PATH}/${encodeURIComponent(args[0])}`);
|
|
1148
|
+
if (options.format === "json")
|
|
1149
|
+
return printJson({ story });
|
|
1150
|
+
if (!story)
|
|
1151
|
+
return console.log("No story yet.");
|
|
1152
|
+
printStory(story);
|
|
1153
|
+
},
|
|
1154
|
+
},
|
|
1155
|
+
"story submit": {
|
|
1156
|
+
usage: "story submit --file <story.json> | < story.json save the next version; the server runs each query and keeps its rows",
|
|
1157
|
+
async run(args, options) {
|
|
1158
|
+
if (args.length > 0)
|
|
1159
|
+
throw new CliError("Usage: story submit --file <story.json> | < story.json", exitCodes.usage);
|
|
1160
|
+
const { story } = await api(await resolveConnection(options.apiUrl), STORIES_PATH, await readToolInput(options));
|
|
1161
|
+
if (options.format === "json")
|
|
1162
|
+
return printJson({ story });
|
|
1163
|
+
console.log(`Saved story version ${story.version}.`);
|
|
1164
|
+
},
|
|
1165
|
+
},
|
|
1087
1166
|
"plans create": {
|
|
1088
1167
|
usage: "plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>' | @file.json] [--landing <sku | item-id> | --landing-url <url>] [--funnel <key> --step <key>] [--run <agent-run-id>]",
|
|
1089
1168
|
async run(args, options) {
|
|
@@ -1471,6 +1550,20 @@ const FACT_COLUMNS = ["id", "kind", "subject", "key", "status", "summary"];
|
|
|
1471
1550
|
function factRow(f) {
|
|
1472
1551
|
return { id: f.id, kind: f.kind, subject: `${f.subjectType}:${f.subjectRef}`, key: f.key, status: f.status, summary: excerpt(f.summary, 80) };
|
|
1473
1552
|
}
|
|
1553
|
+
const STORIES_PATH = "/api/v1/stories";
|
|
1554
|
+
function printStory(story) {
|
|
1555
|
+
const corrects = story.previousId ? `, correcting ${story.previousId}` : "";
|
|
1556
|
+
console.log(`Story version ${story.version}, up to ${story.periodEnd}, saved ${story.createdAt} by ${story.author}${corrects}.`);
|
|
1557
|
+
console.log(`\n${story.headline}`);
|
|
1558
|
+
for (const section of story.sections) {
|
|
1559
|
+
console.log(`\n${section.title} (${section.audience})`);
|
|
1560
|
+
for (const claim of section.claims)
|
|
1561
|
+
console.log(`- ${claim.text} [${claim.confidence}: ${claim.evidence.join(", ")}]`);
|
|
1562
|
+
}
|
|
1563
|
+
const evidence = Object.entries(story.evidence).map(([key, e]) => ({ key, rows: e.rows.length, sql: excerpt(e.sql, 80) }));
|
|
1564
|
+
if (evidence.length > 0)
|
|
1565
|
+
console.log(`\nEvidence, as the server read it on saving:\n${renderTable(["key", "rows", "sql"], evidence)}`);
|
|
1566
|
+
}
|
|
1474
1567
|
const PROPOSAL_COLUMNS = ["id", "kind", "status", "target", "change", "reason"];
|
|
1475
1568
|
function proposalChange(p) {
|
|
1476
1569
|
if (p.kind === "creative")
|
|
@@ -1523,6 +1616,8 @@ Options:
|
|
|
1523
1616
|
--search <words> warehouse schema: describe in full the tables whose name, description or columns mention a word
|
|
1524
1617
|
--out <path> the file, or existing folder, creatives download and warehouse query save to; warehouse query
|
|
1525
1618
|
writes CSV for a .csv path, otherwise JSON
|
|
1619
|
+
--all with warehouse query --out, save the whole answer (up to 1,000,000 rows or 50 MiB) instead of
|
|
1620
|
+
the first 1,000 rows or 5 MiB; .ndjson or .jsonl saves one JSON row per line
|
|
1526
1621
|
--open open the file creatives download saved
|
|
1527
1622
|
--media with content pull, also save each item's files and HTML under media/ in the folder
|
|
1528
1623
|
--full with content pull, fetch every item, not only what changed since the last pull (brings new numbers)
|
|
@@ -1550,6 +1645,8 @@ Options:
|
|
|
1550
1645
|
--based-on <n> the map version funnels draft was written from; refused when someone saved since
|
|
1551
1646
|
(leave it out only for the first map, when there is neither a map nor a draft)
|
|
1552
1647
|
--evidence <json | @file.json> what funnels draft was written from: counts and ids, never names, emails or phones
|
|
1648
|
+
--counts-from fix|<YYYY-MM-DD> how funnels draft counts: fix (default) for past dates too, or a change from that day
|
|
1649
|
+
--questions <json | @file.json> the questions funnels draft asks a person, as an array
|
|
1553
1650
|
--diff <a> <b> with funnels versions, what changed from version a to version b
|
|
1554
1651
|
--funnel <key> only this funnel's steps in funnels numbers (default: every funnel in the confirmed map);
|
|
1555
1652
|
with plans create and --step, the funnel whose step the push feeds
|
|
@@ -1604,6 +1701,7 @@ function parseOptions(argv) {
|
|
|
1604
1701
|
summary: { type: "boolean", default: false },
|
|
1605
1702
|
search: { type: "string" },
|
|
1606
1703
|
out: { type: "string" },
|
|
1704
|
+
all: { type: "boolean", default: false },
|
|
1607
1705
|
open: { type: "boolean", default: false },
|
|
1608
1706
|
media: { type: "boolean", default: false },
|
|
1609
1707
|
full: { type: "boolean", default: false },
|
|
@@ -1647,6 +1745,8 @@ function parseOptions(argv) {
|
|
|
1647
1745
|
version: { type: "string" },
|
|
1648
1746
|
"based-on": { type: "string" },
|
|
1649
1747
|
evidence: { type: "string" },
|
|
1748
|
+
"counts-from": { type: "string" },
|
|
1749
|
+
questions: { type: "string" },
|
|
1650
1750
|
diff: { type: "string" },
|
|
1651
1751
|
funnel: { type: "string" },
|
|
1652
1752
|
step: { type: "string" },
|
|
@@ -1692,6 +1792,7 @@ function parseOptions(argv) {
|
|
|
1692
1792
|
summary: values.summary,
|
|
1693
1793
|
search: values.search,
|
|
1694
1794
|
out: values.out,
|
|
1795
|
+
all: values.all,
|
|
1695
1796
|
open: values.open,
|
|
1696
1797
|
media: values.media,
|
|
1697
1798
|
full: values.full,
|
|
@@ -1735,6 +1836,8 @@ function parseOptions(argv) {
|
|
|
1735
1836
|
version: values.version,
|
|
1736
1837
|
basedOn: values["based-on"],
|
|
1737
1838
|
evidence: values.evidence,
|
|
1839
|
+
countsFrom: values["counts-from"],
|
|
1840
|
+
questions: values.questions,
|
|
1738
1841
|
diff: values.diff,
|
|
1739
1842
|
funnel: values.funnel,
|
|
1740
1843
|
step: values.step,
|
|
@@ -2313,11 +2416,78 @@ async function saveQueryResult(text, out, format) {
|
|
|
2313
2416
|
const path = await savePath(out, "results.json");
|
|
2314
2417
|
const result = queryResultWithExactNumbers(text);
|
|
2315
2418
|
await saveFile(path, extname(path).toLowerCase() === ".csv" ? toCsv(text) : text);
|
|
2419
|
+
reportSaved(path, result.rows.length, result.truncated, format);
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Has the server save the whole answer to storage, then streams it from there into the file: CSV for a `.csv` path,
|
|
2423
|
+
* one JSON row per line for `.ndjson` or `.jsonl`, otherwise the same JSON `saveQueryResult` writes.
|
|
2424
|
+
*/
|
|
2425
|
+
async function saveWholeAnswer(connection, sql, out, format) {
|
|
2426
|
+
const path = await savePath(out, "results.json");
|
|
2427
|
+
const saved = await api(connection, "/api/v1/warehouse/export", { sql });
|
|
2428
|
+
const rows = exportRows(await streamSigned(saved.url), saved.rowCount);
|
|
2429
|
+
const extension = extname(path).toLowerCase();
|
|
2430
|
+
const content = extension === ".csv"
|
|
2431
|
+
? csvLines(saved.columns, rows)
|
|
2432
|
+
: extension === ".ndjson" || extension === ".jsonl"
|
|
2433
|
+
? suffixed(rows, "\n")
|
|
2434
|
+
: queryJson(saved, rows);
|
|
2435
|
+
await saveFile(path, content);
|
|
2436
|
+
reportSaved(path, saved.rowCount, saved.truncated, format, true);
|
|
2437
|
+
}
|
|
2438
|
+
/** Each row of the export's NDJSON as it arrives; throws if the download ends with rows missing. */
|
|
2439
|
+
async function* exportRows(body, expected) {
|
|
2440
|
+
const decoder = new TextDecoder();
|
|
2441
|
+
let received = 0;
|
|
2442
|
+
let pending = "";
|
|
2443
|
+
const stopped = (why) => new CliError(`The download ${why} after ${rowCount(received)} of ${expected.toLocaleString("en-US")}. Try again.`, exitCodes.error);
|
|
2444
|
+
const reader = body[Symbol.asyncIterator]();
|
|
2445
|
+
for (;;) {
|
|
2446
|
+
let next;
|
|
2447
|
+
try {
|
|
2448
|
+
next = await reader.next();
|
|
2449
|
+
}
|
|
2450
|
+
catch (error) {
|
|
2451
|
+
throw stopped(`broke off (${error.message})`);
|
|
2452
|
+
}
|
|
2453
|
+
if (next.done)
|
|
2454
|
+
break;
|
|
2455
|
+
const lines = (pending + decoder.decode(next.value, { stream: true })).split("\n");
|
|
2456
|
+
pending = lines.pop();
|
|
2457
|
+
for (const line of lines) {
|
|
2458
|
+
received++;
|
|
2459
|
+
yield line;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
if (pending + decoder.decode() !== "" || received !== expected)
|
|
2463
|
+
throw stopped("stopped");
|
|
2464
|
+
}
|
|
2465
|
+
async function* suffixed(lines, suffix) {
|
|
2466
|
+
for await (const line of lines)
|
|
2467
|
+
yield line + suffix;
|
|
2468
|
+
}
|
|
2469
|
+
async function* csvLines(columns, rows) {
|
|
2470
|
+
const csv = csvWriter(columns);
|
|
2471
|
+
yield csv.header;
|
|
2472
|
+
for await (const row of rows)
|
|
2473
|
+
yield csv.row(JSON.parse(row, keepRawNumbers));
|
|
2474
|
+
}
|
|
2475
|
+
/** The query route's JSON, with the rows as the server wrote them. */
|
|
2476
|
+
async function* queryJson(saved, rows) {
|
|
2477
|
+
yield `{"columns":${JSON.stringify(saved.columns)},"rows":[`;
|
|
2478
|
+
let first = true;
|
|
2479
|
+
for await (const row of rows) {
|
|
2480
|
+
yield first ? row : `,${row}`;
|
|
2481
|
+
first = false;
|
|
2482
|
+
}
|
|
2483
|
+
yield `],"rowCount":${saved.rowCount},"truncated":${JSON.stringify(saved.truncated)}}`;
|
|
2484
|
+
}
|
|
2485
|
+
function reportSaved(path, rows, truncated, format, whole = false) {
|
|
2316
2486
|
if (format === "json")
|
|
2317
|
-
printJson({ path: resolve(path), rowCount:
|
|
2487
|
+
printJson({ path: resolve(path), rowCount: rows, truncated });
|
|
2318
2488
|
else
|
|
2319
|
-
console.log(`Saved ${rowCount(
|
|
2320
|
-
warnIfTruncated(
|
|
2489
|
+
console.log(`Saved ${rowCount(rows)} to ${path}`);
|
|
2490
|
+
warnIfTruncated(truncated, whole);
|
|
2321
2491
|
}
|
|
2322
2492
|
/** Top-level numbers keep the digits the server sent, so a numeric column is never rounded. */
|
|
2323
2493
|
function queryResultWithExactNumbers(text) {
|
|
@@ -2329,18 +2499,28 @@ function queryResultWithExactNumbers(text) {
|
|
|
2329
2499
|
function rowCount(n) {
|
|
2330
2500
|
return `${n.toLocaleString("en-US")} ${n === 1 ? "row" : "rows"}`;
|
|
2331
2501
|
}
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2502
|
+
/** `whole` is true when the answer came from --all, which has no larger limit to point to. */
|
|
2503
|
+
function warnIfTruncated(truncated, whole = false) {
|
|
2504
|
+
if (!truncated)
|
|
2505
|
+
return;
|
|
2506
|
+
const more = whole ? "" : ", or save every row with --out <file> --all";
|
|
2507
|
+
console.error(`Results were cut off (${truncated}). Aggregate in SQL or add LIMIT${more}.`);
|
|
2335
2508
|
}
|
|
2336
|
-
/** Every number keeps the digits the server sent, nested ones too, and json columns are written as JSON text. */
|
|
2337
2509
|
function toCsv(text) {
|
|
2338
2510
|
const result = JSON.parse(text, keepRawNumbers);
|
|
2339
|
-
const
|
|
2511
|
+
const csv = csvWriter(result.columns);
|
|
2512
|
+
return [csv.header, ...result.rows.map(csv.row)].join("");
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* CSV lines, each ending in a newline. Rows must be parsed with `keepRawNumbers`, so every number keeps the digits the
|
|
2516
|
+
* server sent, nested ones too; json columns are written as JSON text.
|
|
2517
|
+
*/
|
|
2518
|
+
function csvWriter(columns) {
|
|
2519
|
+
const json = new Set(columns.filter((c) => c.type === "json" || c.type === "jsonb").map((c) => c.name));
|
|
2340
2520
|
const field = (name, value) => (value != null && json.has(name) ? JSON.stringify(value) : cell(value));
|
|
2341
|
-
const names =
|
|
2342
|
-
const line = (values) => values.map(csvField).join(",")
|
|
2343
|
-
return
|
|
2521
|
+
const names = columns.map((c) => c.name);
|
|
2522
|
+
const line = (values) => `${values.map(csvField).join(",")}\n`;
|
|
2523
|
+
return { header: line(names), row: (row) => line(names.map((name) => field(name, row[name]))) };
|
|
2344
2524
|
}
|
|
2345
2525
|
function keepRawNumbers(_key, value, context) {
|
|
2346
2526
|
return typeof value === "number" && context?.source !== undefined ? JSON.rawJSON(context.source) : value;
|
package/dist/playbooks.js
CHANGED
|
@@ -583,7 +583,7 @@ ${cli} funnels show --format json > current.json
|
|
|
583
583
|
- **campaigns**: active Facebook campaigns with their last 30 days' spend.
|
|
584
584
|
- **pastCampaigns**: every Facebook campaign that ever spent, all time rather than the evidence window, biggest spend first: its first and last day, spend, results by type with each type's average value per result, and the addresses of the landing pages its ads link to where known (\`landingPageUrls\`). A name here can belong to a campaign no longer listed in \`campaigns\`.
|
|
585
585
|
|
|
586
|
-
\`current.json\` is the map now: \`map\` (the confirmed one) and \`draft\` (on hold until a rule is filled in), each null when there is none. Note the version you start from: the draft's when one is on hold, else the confirmed map's, else none.
|
|
586
|
+
\`current.json\` is the map now: \`map\` (the confirmed one) and \`draft\` (on hold until a rule is filled in), each null when there is none. Note the version you start from: the draft's when one is on hold, else the confirmed map's, else none. \`questions.answered\` lists the questions a person already answered: keep each answer in your map, and never ask them again.
|
|
587
587
|
|
|
588
588
|
## 2. Check past campaigns for offers the map is missing
|
|
589
589
|
|
|
@@ -628,17 +628,51 @@ Write \`map.json\` as \`{"offers": [...], "funnels": [...]}\`, in this order.
|
|
|
628
628
|
3. **Assets** on each step: \`{"asset_type": ..., "ref": ...}\` with refs from the evidence: \`meta_campaign\` (campaign id), \`url\` (page address), \`content_item\` (content item id), \`ghl_funnel_step\` (step id), \`ghl_calendar\`, \`ghl_pipeline_stage\`, \`ghl_tag\`, \`webinar\` (webinar id). Give the step after Traffic its page, the funnel's first page, as a \`url\`, a \`content_item\` from \`pages\` or a \`ghl_funnel_step\`: the Traffic step finds its ads from it. Only that step's pages count, so a checkout page that two funnels share is never mistaken for a first page.
|
|
629
629
|
4. **Rules**, what counts as reaching each step, each \`{"kind": "...", "params": {...}}\`, such as \`{"kind": "purchase", "params": {"offer_key": "course"}}\`. The kinds and their params: \`ad_traffic\` for the Traffic step, with no params: it counts every ad whose link goes to the funnel's first page, or to a short link that forwards there, and finds new ones on every sync, so never list campaign ids. Its one param, \`exclude_campaign_ids\`, leaves a campaign out; use it only when a campaign's ads point at the page for another reason, and say why in the report. \`meta_ads\` is an older kind kept so old maps still read; never write it. The others: \`page_visit\` (\`host\`, \`path\`), \`webinar\` (\`event_types\` and \`webinar_ids\`), \`ghl_tag\` (\`tags\`, exact tag names), \`ghl_appointment\` (\`calendar_ids\`, \`outcome\` booked or showed), \`ghl_stage\` (\`pipeline_id\`, \`stage_ids\`), \`ghl_workflow_event\` (\`endpoint_id\` of a GoHighLevel workflow's intake endpoint, and an optional \`match\` on \`event_type\`, \`workflow_id\` or \`workflow_name\`), \`purchase\` (\`offer_key\`), or \`none\` for a step that counts nothing. An empty \`webinar_ids\` counts every webinar: fill it in with the one webinar id when the evidence lists one, and with this step's ids when it lists more.
|
|
630
630
|
|
|
631
|
-
## 6.
|
|
631
|
+
## 6. Write your questions
|
|
632
|
+
|
|
633
|
+
Some things the evidence can't tell you, such as whether the $497 flash-sale buyers are course buyers. Make your best guess in the map, then ask a person with a question card. Write \`questions.json\`, an array of questions. Each is \`{"question_id", "text", "kind", "evidence", "options", "default_option"}\`:
|
|
634
|
+
|
|
635
|
+
- **Ask only when both are true:** the evidence can't tell, and the answer changes which sales or people count. A question whose answers would only rename something, or change a step's type or a label, is never asked: pick one yourself. ${brand.name} drops any question whose options all count the same sales and people.
|
|
636
|
+
- **Your best guess is the default.** The map you save already holds it. Its option's \`patch\` is \`[]\`, and \`default_option\` is its index.
|
|
637
|
+
- **One card per decision, at most 7.** ${brand.name} keeps the 7 that move the most money, and adds a yes/no card of its own for each weak part no card of yours covers.
|
|
638
|
+
- \`question_id\`: lowercase letters, digits, \`-\` and \`_\`, such as \`flash_sale\`. Keep the same id for the same question on every run.
|
|
639
|
+
- \`text\`: the question in plain words, at most 200 characters.
|
|
640
|
+
- \`kind\`: \`yes_no\` (exactly two options, labelled \`Yes\` then \`No\`) or \`pick_one\` (two to four options).
|
|
641
|
+
- \`evidence\`: one to four lines from \`evidence.json\`, counts and amounts only, such as "728 people paid $497 last year". Never a name, email or phone number.
|
|
642
|
+
- \`options\`: each \`{"label", "patch"}\`. A patch is JSON Patch ops, \`add\`, \`remove\` or \`replace\`, on the map you save. Name offers, funnels and steps by key, not by index, so the patch still fits after the map changes: \`/offers/course/matches/-\` adds a match to the course offer, \`/funnels/webinar_to_course/steps/upsell_1/offer_key\` is that step's offer.
|
|
643
|
+
- **Keep the answered ones.** Read \`questions.answered\` in \`current.json\`: keep each answer in your map, and never ask it again. ${brand.name} puts an answer back if your map drops it.
|
|
644
|
+
|
|
645
|
+
A worked example, from DLC's first map. These were questions, one card each: whether the flash-sale buyers count as course buyers; whether VIP and guest tickets count as event tickets; whether "Financing - Protégé Mastery" payments are coaching sales; whether the strategy call also sells Protégé; whether other products such as 10K Club belong on the map. These were not: whether Sales Mastery is an order bump or an upsell page (only a step type), and how the webinar tags are named (only a label). The flash-sale card, with the guess that they are course buyers already in the map:
|
|
646
|
+
|
|
647
|
+
\`\`\`json
|
|
648
|
+
{
|
|
649
|
+
"question_id": "flash_sale",
|
|
650
|
+
"text": "Do the $497 flash-sale buyers count as course buyers?",
|
|
651
|
+
"kind": "yes_no",
|
|
652
|
+
"evidence": ["728 people paid $497 for Overages Blueprint 2.0 flash sale last year", "1,703 people paid $1,497 for the course"],
|
|
653
|
+
"options": [
|
|
654
|
+
{"label": "Yes", "patch": []},
|
|
655
|
+
{"label": "No", "patch": [{"op": "remove", "path": "/offers/course/matches/1"}]}
|
|
656
|
+
],
|
|
657
|
+
"default_option": 0
|
|
658
|
+
}
|
|
659
|
+
\`\`\`
|
|
660
|
+
|
|
661
|
+
A question whose patch does not apply, or gives a map that does not fit, is refused with its path, such as \`questions[2].options[1].patch\`. Fix it and save again.
|
|
662
|
+
|
|
663
|
+
## 7. Save the map
|
|
632
664
|
|
|
633
665
|
Write \`evidence-summary.json\` with what you read, in counts and ids only, such as \`{"buyer_paths": [{"purchases": [1500, 497, 3500], "people": 41}], "pages_read": ["<content item id>"], "webinars": ["<id>"]}\`. The note is one or two plain sentences, with no quotes or apostrophes: where the map came from and what a person should check. Then:
|
|
634
666
|
|
|
635
667
|
\`\`\`sh
|
|
636
|
-
${cli} funnels draft --file map.json --note "<the note>" --evidence @evidence-summary.json --based-on <version from step 1>
|
|
668
|
+
${cli} funnels draft --file map.json --note "<the note>" --evidence @evidence-summary.json --questions @questions.json --counts-from fix --based-on <version from step 1>
|
|
637
669
|
\`\`\`
|
|
638
670
|
|
|
639
|
-
|
|
671
|
+
\`--counts-from\` says from when the map counts. Use \`fix\` when the data before today already shows the business working this way: the map then counts that way for every past date too. Use a date, \`--counts-from 2026-09-01\`, when the evidence for the change only starts recently, such as a new offer whose first sale was on 1 September: give the first date of that evidence, and the old map keeps counting the dates before it. When unsure, use \`fix\`. A draft that holds both a fix and a change is saved as two drafts: first the fix with \`--counts-from fix\`, then the change on top with its date, based on the version the first save made.
|
|
672
|
+
|
|
673
|
+
Leave out \`--based-on\` only when \`current.json\` has neither a map nor a draft. A map that does not fit is refused with every problem by path: fix each and save again. A \`draft_conflict\` means someone saved since you read: run \`${cli} funnels show --format json\` again and redo your change on top. Saving prints whether the map went live, the answers it put back, the questions now open and those it left out, what is left to fill in (such as a page rule with no address), and the weak parts: what the data does not back up, such as an offer with fewer than 3 buyers in the last year. A rule you cannot fill in from the evidence stays unfilled, and the map waits on hold; that is better than a guess.
|
|
640
674
|
|
|
641
|
-
##
|
|
675
|
+
## 8. Report
|
|
642
676
|
|
|
643
677
|
Finish with a short report in plain words for the owner, who is not technical:
|
|
644
678
|
|
|
@@ -646,6 +680,8 @@ Finish with a short report in plain words for the owner, who is not technical:
|
|
|
646
680
|
- what you mapped: the offer stack in one line each, and each funnel's steps;
|
|
647
681
|
- offers found only in past campaigns, each with its campaign family and spend;
|
|
648
682
|
- what you guessed, one line each: "Two payments of $497 and $297 follow the $1,500 one, usually within minutes; I named them Upsell 1 and Upsell 2.";
|
|
683
|
+
- the questions now open, one line each, and any the save left out and why;
|
|
684
|
+
- whether the map counts as a fix or from a date, and why;
|
|
649
685
|
- what you could not tell, and why: a source not connected, a price nobody paid in the last year, a page with no ads;
|
|
650
686
|
- the weak parts the save listed, one line each, in plain words;
|
|
651
687
|
- that a person can undo the map in the app.
|
|
@@ -658,7 +694,7 @@ ${cli} funnels show [--format json] the map now and the draft on ho
|
|
|
658
694
|
${cli} content show <sku | item-id> a page's words or a video's transcript
|
|
659
695
|
${cli} content search --kind <kind> --text <words>
|
|
660
696
|
${cli} content add-page <url> [--format json] read a landing page at a full address now
|
|
661
|
-
${cli} funnels draft --file map.json [--note <why>] [--evidence @file.json] [--based-on <n>]
|
|
697
|
+
${cli} funnels draft --file map.json [--note <why>] [--evidence @file.json] [--questions @file.json] [--counts-from fix|<YYYY-MM-DD>] [--based-on <n>]
|
|
662
698
|
\`\`\`
|
|
663
699
|
`;
|
|
664
700
|
export const mapOffersSkill = `---
|
package/dist/pull.js
CHANGED
|
@@ -103,9 +103,9 @@ export function renderItem(item) {
|
|
|
103
103
|
["Subject", item.subject],
|
|
104
104
|
["Preview text", item.previewText],
|
|
105
105
|
["Headline", item.headline],
|
|
106
|
-
["Body", item.body || null],
|
|
106
|
+
[item.transcripts.some((t) => t.mediaPosition === null) ? "Transcript" : "Body", item.body || null],
|
|
107
107
|
["Fields", Object.keys(item.fields).length === 0 ? null : `\`\`\`json\n${sortedJson(item.fields)}\n\`\`\``],
|
|
108
|
-
...item.transcripts.
|
|
108
|
+
...item.transcripts.flatMap((t) => (t.mediaPosition === null ? [] : [[`Transcript (file ${t.mediaPosition})`, t.text]])),
|
|
109
109
|
];
|
|
110
110
|
const body = sections.flatMap(([title, text]) => (text === null ? [] : [`## ${title}\n\n${text.trimEnd()}\n`]));
|
|
111
111
|
return ["---", ...Object.entries(front).flatMap(([k, v]) => yamlLines(k, v, "")), "---", "", ...body.join("\n").split("\n")].join("\n").trimEnd() + "\n";
|