@topy-ai/maggie 0.7.52 → 0.7.54
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-zh-TW.md +28 -4
- package/README.md +93 -60
- package/bin/maggie.js +67 -12
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-blog/SKILL.md +8 -0
- package/bundled-skills/maggie-booking/SKILL.md +66 -14
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +28 -2
- package/bundled-skills/maggie-deployment/SKILL.md +48 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +98 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +27 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +56 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +24 -12
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
- package/bundled-tools/clis/maggie_design.py +15 -2
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +205 -3
- package/bundled-tools/clis/maggie_feedback.py +138 -28
- package/bundled-tools/clis/maggie_live_urls.py +138 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +219 -4
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +174 -7
- package/bundled-tools/integrations/maggie-api-pull.md +22 -0
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- package/bundled-references/maggiedash-booking/TASKS.md +0 -178
|
@@ -175,6 +175,23 @@ maggie feedback session-summary --project . \
|
|
|
175
175
|
Evidence then reads `commit:<label>@<sha>`; unlabeled repositories become
|
|
176
176
|
`repo1`, `repo2`, so local folder names never leave the machine.
|
|
177
177
|
|
|
178
|
+
Each repository is read at `HEAD` unless you name a revision. Commits made in
|
|
179
|
+
a temporary worktree and pushed with `git push origin HEAD:main` are not on a
|
|
180
|
+
stale local `main`; the output then warns "local HEAD is N commit(s) behind
|
|
181
|
+
origin/main". Fetch, then read the remote branch with `--repo
|
|
182
|
+
api=../api@origin/main` (or `--rev origin/main` for every repository).
|
|
183
|
+
|
|
184
|
+
`Area: sentence` subjects ("Booking: waitlist offers expire after two hours")
|
|
185
|
+
are drafted as changes in that area, and the area or conventional-commit
|
|
186
|
+
scope is kept. A busy product repository is mostly project work, not skill
|
|
187
|
+
feedback: `--skill-relevant` keeps only commits that mention Maggie, a skill,
|
|
188
|
+
a CLI, MCP or the `--skill` name, and reports the rest as
|
|
189
|
+
`projectOnlyCommits`. A batch holds at most 50 items; when more commits
|
|
190
|
+
qualify, the draft groups them into one item per scope (with up to 20 commit
|
|
191
|
+
references each) and says so in `warnings`, instead of silently keeping the
|
|
192
|
+
first 50. The draft's `commits` block records how many were seen, drafted,
|
|
193
|
+
skipped and project-only.
|
|
194
|
+
|
|
178
195
|
Review the file: delete items that concern the project rather than a Maggie
|
|
179
196
|
skill or CLI, and replace every `REVIEW:` field (expected, actual, validation).
|
|
180
197
|
`maggie feedback batch` rejects a file that still contains `REVIEW:`
|
|
@@ -206,10 +223,18 @@ leaving contradictory claims current.
|
|
|
206
223
|
The CLI never submits automatically. After reviewing the draft:
|
|
207
224
|
|
|
208
225
|
```bash
|
|
209
|
-
maggie feedback submit .maggie/feedback/<feedback-id>.json
|
|
210
|
-
--endpoint https://feedback.noblox.app/api/feedback --confirm
|
|
226
|
+
maggie feedback submit .maggie/feedback/<feedback-id>.json --confirm
|
|
211
227
|
```
|
|
212
228
|
|
|
229
|
+
Without `--endpoint` the draft goes to `https://feedback.noblox.app/api/feedback`
|
|
230
|
+
and, only when that host fails before storing it (no connection, timeout, or a
|
|
231
|
+
5xx such as Cloudflare's 526), to `https://feedback.cmo.so/api/feedback`. Both
|
|
232
|
+
accept the same contract and de-duplicate on `feedbackId`, so a retry never
|
|
233
|
+
creates a second record. A 4xx is the host rejecting the draft and is not
|
|
234
|
+
retried elsewhere. The saved `submission` records the `endpoint` that accepted
|
|
235
|
+
the draft and any `failedEndpoints`. Repeat `--endpoint URL` to give your own
|
|
236
|
+
ordered list. Do not hand-edit drafts to point at a different host.
|
|
237
|
+
|
|
213
238
|
Only HTTPS endpoints are accepted. Project paths, file contents, secrets, and
|
|
214
239
|
logs are excluded by default. `--allow-project-context` is an explicit opt-in;
|
|
215
240
|
even then, send only a short public-safe context note. After submission, the
|
|
@@ -223,6 +223,18 @@ maggie skills smoke --out dist/marketplace --claude
|
|
|
223
223
|
Nothing is published; push the output folder to the marketplace repository
|
|
224
224
|
yourself after review.
|
|
225
225
|
|
|
226
|
+
## Directory towns and name search
|
|
227
|
+
|
|
228
|
+
A marketplace that lists businesses by town and lets people search names
|
|
229
|
+
uses one shared module, [`directory.mjs`](references/directory.mjs),
|
|
230
|
+
imported by the server and every node script (town pages, sitemaps, maps) —
|
|
231
|
+
never a copy. It parses UK post towns and US "City, ST", falls back to the
|
|
232
|
+
town field plus the state from the ZIP prefix when a US profile saved only
|
|
233
|
+
the street, keeps non-US places in a US market ("Kingston, Jamaica"), and
|
|
234
|
+
gives a normalised search key so "lakeview" finds "Lake View Spa", ranked
|
|
235
|
+
exact → prefix → word prefix → contains. See [directory towns and
|
|
236
|
+
search](references/directory-search.md) for the generated-column SQL.
|
|
237
|
+
|
|
226
238
|
## Rules
|
|
227
239
|
|
|
228
240
|
1. Extract the template detail page first and save title, description,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Marketplace directory: towns and name search
|
|
2
|
+
|
|
3
|
+
Use [`directory.mjs`](directory.mjs) as **one** module that the server and
|
|
4
|
+
every node script import (town pages, sitemaps, maps). It is plain
|
|
5
|
+
JavaScript so `node scripts/x.mjs` can import it without a build step; a
|
|
6
|
+
script that keeps its own copy drifts (a map script's copy had lost a rule
|
|
7
|
+
the site had fixed).
|
|
8
|
+
|
|
9
|
+
## Towns
|
|
10
|
+
|
|
11
|
+
`parseTown(address, market, locality, postcode)` returns
|
|
12
|
+
`{slug, name, label, region}` or `null`:
|
|
13
|
+
|
|
14
|
+
- UK: the post town before the postcode ("…, Leeds LS1 4AB" → Leeds),
|
|
15
|
+
skipping fragments such as "Unit 3", "Rear", "Farm" or "…Road"; falls back
|
|
16
|
+
to the separate town field.
|
|
17
|
+
- US: "City, ST 12345" → "City, ST". When the profile form saved only the
|
|
18
|
+
street, the separate town field plus the **state from the ZIP prefix**
|
|
19
|
+
("503 Maple Avenue West" + Vienna + 22180 → "Vienna, VA").
|
|
20
|
+
- Places listed in a US market but outside the US keep their country
|
|
21
|
+
("…, Kingston, Jamaica" → "Kingston, Jamaica").
|
|
22
|
+
|
|
23
|
+
Profile forms must store street, town and postcode/ZIP (and state when the
|
|
24
|
+
form has it); a form that saves only "street" drops the business from every
|
|
25
|
+
town page. Re-run the town build after changing the parser and compare
|
|
26
|
+
counts per town.
|
|
27
|
+
|
|
28
|
+
## Name search
|
|
29
|
+
|
|
30
|
+
People type "lakeview" for "Lake View Spa". Compare a normalised key —
|
|
31
|
+
lower case, accents removed, letters and digits only (`searchKey`) — as well
|
|
32
|
+
as the plain `ILIKE` match, and rank exact and prefix matches first
|
|
33
|
+
(`nameMatchRank`). In Postgres keep the key in a generated column with an
|
|
34
|
+
index:
|
|
35
|
+
|
|
36
|
+
```sql
|
|
37
|
+
ALTER TABLE businesses ADD COLUMN name_key text
|
|
38
|
+
GENERATED ALWAYS AS (regexp_replace(lower(unaccent_immutable(name)), '[^a-z0-9]+', '', 'g')) STORED;
|
|
39
|
+
CREATE INDEX businesses_name_key_trgm ON businesses USING gin (name_key gin_trgm_ops);
|
|
40
|
+
|
|
41
|
+
SELECT id, name,
|
|
42
|
+
CASE WHEN name_key = $2 THEN 0 WHEN name_key LIKE $2 || '%' THEN 1
|
|
43
|
+
WHEN name ILIKE $1 || '%' OR name ILIKE '% ' || $1 || '%' THEN 2 ELSE 3 END AS rank
|
|
44
|
+
FROM businesses
|
|
45
|
+
WHERE name ILIKE '%' || $1 || '%' OR name_key LIKE '%' || $2 || '%'
|
|
46
|
+
ORDER BY rank, name LIMIT 20;
|
|
47
|
+
-- $1 = the trimmed query, $2 = searchKey(query)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
(`unaccent` is not immutable; wrap it in an immutable SQL function, or drop
|
|
51
|
+
it when names are ASCII.) Suggestions use the same ranking.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maggie marketplace directory helpers: town parsing and name search.
|
|
3
|
+
*
|
|
4
|
+
* One plain-JavaScript module (no build step) imported by both the server and node scripts (sitemaps, maps,
|
|
5
|
+
* town pages), so the directory and every generated artefact count the same towns. Copies drift: a map script's
|
|
6
|
+
* private copy of the parser had already lost a rule when the site's was fixed.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {{ slug: string, name: string, label: string, region: string | null }} Town
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const UK_POSTCODE = /\s*\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b\s*$/i;
|
|
12
|
+
const COUNTRY_PART = /^(uk|united kingdom|usa|us|united states|england|scotland|wales|northern ireland|ireland)$/i;
|
|
13
|
+
// Address fragments that sometimes sit where the post town should be.
|
|
14
|
+
const NOT_A_TOWN = /^(above|below|behind|rear|inside|within|retail|retail park|unit|suite|floor|ground floor|first floor|farm)$|\s(rd|road|lane|ln|street|st|avenue|ave|drive|dr|close|way|parade|terrace)\.?$/i;
|
|
15
|
+
|
|
16
|
+
/** @param {string} value */
|
|
17
|
+
export const slugifyTown = (value) => value
|
|
18
|
+
.normalize("NFKD").replace(/[̀-ͯ]/g, "")
|
|
19
|
+
.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
20
|
+
|
|
21
|
+
// First three ZIP digits → state, for US addresses saved without one (forms that keep street, town and ZIP only).
|
|
22
|
+
/** @type {Array<[number, number, string]>} */
|
|
23
|
+
const ZIP3_STATES = [
|
|
24
|
+
[10, 27, "MA"], [28, 29, "RI"], [30, 38, "NH"], [39, 49, "ME"], [50, 59, "VT"], [60, 69, "CT"], [70, 89, "NJ"],
|
|
25
|
+
[100, 149, "NY"], [150, 196, "PA"], [197, 199, "DE"], [200, 200, "DC"], [201, 201, "VA"], [202, 205, "DC"],
|
|
26
|
+
[206, 219, "MD"], [220, 246, "VA"], [247, 268, "WV"], [270, 289, "NC"], [290, 299, "SC"], [300, 319, "GA"],
|
|
27
|
+
[320, 349, "FL"], [350, 369, "AL"], [370, 385, "TN"], [386, 397, "MS"], [398, 399, "GA"], [400, 427, "KY"],
|
|
28
|
+
[430, 459, "OH"], [460, 479, "IN"], [480, 499, "MI"], [500, 528, "IA"], [530, 549, "WI"], [550, 567, "MN"],
|
|
29
|
+
[569, 569, "DC"], [570, 577, "SD"], [580, 588, "ND"], [590, 599, "MT"], [600, 629, "IL"], [630, 658, "MO"],
|
|
30
|
+
[660, 679, "KS"], [680, 693, "NE"], [700, 714, "LA"], [716, 729, "AR"], [730, 732, "OK"], [733, 733, "TX"],
|
|
31
|
+
[734, 749, "OK"], [750, 799, "TX"], [800, 816, "CO"], [820, 831, "WY"], [832, 838, "ID"], [840, 847, "UT"],
|
|
32
|
+
[850, 865, "AZ"], [870, 884, "NM"], [885, 885, "TX"], [889, 898, "NV"], [900, 961, "CA"], [967, 968, "HI"],
|
|
33
|
+
[970, 979, "OR"], [980, 994, "WA"], [995, 999, "AK"],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** @param {string | null | undefined} zip @returns {string | null} */
|
|
37
|
+
export const stateFromZip = (zip) => {
|
|
38
|
+
const match = (zip || "").trim().match(/^(\d{3})\d{2}(-\d{4})?$/);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const prefix = Number(match[1]);
|
|
41
|
+
return ZIP3_STATES.find(([from, to]) => prefix >= from && prefix <= to)?.[2] || null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Places listed in a US market but outside the US ("…, Kingston, Jamaica"). Extend per market.
|
|
45
|
+
/** @type {Record<string, string>} */
|
|
46
|
+
export const OTHER_COUNTRIES = { jamaica: "JM", "puerto rico": "PR", bahamas: "BS", canada: "CA", mexico: "MX" };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The post town (UK: "…, Leeds LS1 4AB") or city + state (US: "…, Allen, TX 75013, United States") of a place.
|
|
50
|
+
* A US address saved as street only falls back to the place's town and ZIP fields
|
|
51
|
+
* ("503 Maple Avenue West" + "Vienna" + "22180" → "Vienna, VA"); a non-US place in a US market keeps its country.
|
|
52
|
+
*
|
|
53
|
+
* @param {string | null} address formatted address
|
|
54
|
+
* @param {"uk" | "us"} market
|
|
55
|
+
* @param {string | null} [locality] the separate town/city field, when the form has one
|
|
56
|
+
* @param {string | null} [postcode] the separate postcode/ZIP field
|
|
57
|
+
* @returns {Town | null}
|
|
58
|
+
*/
|
|
59
|
+
export function parseTown(address, market, locality, postcode) {
|
|
60
|
+
const parts = (address || "").split(",").map((part) => part.trim()).filter((part) => part && !COUNTRY_PART.test(part));
|
|
61
|
+
if (market === "us") {
|
|
62
|
+
const stateIndex = parts.findIndex((part) => /^[A-Z]{2}(\s+\d{5}(-\d{4})?)?$/.test(part));
|
|
63
|
+
if (stateIndex >= 1) {
|
|
64
|
+
const name = parts[stateIndex - 1].replace(/^\d+\s+/, "");
|
|
65
|
+
const region = parts[stateIndex].slice(0, 2);
|
|
66
|
+
return { slug: slugifyTown(`${name}-${region}`), name, label: `${name}, ${region}`, region };
|
|
67
|
+
}
|
|
68
|
+
const country = OTHER_COUNTRIES[(parts.at(-1) || "").toLowerCase()];
|
|
69
|
+
if (country && parts.length >= 2) {
|
|
70
|
+
const name = /** @type {string} */ (parts.at(-2));
|
|
71
|
+
return { slug: slugifyTown(`${name}-${country}`), name, label: `${name}, ${parts.at(-1)}`, region: country };
|
|
72
|
+
}
|
|
73
|
+
const region = stateFromZip(postcode || parts.join(" ").match(/\b\d{5}(?:-\d{4})?\b/)?.[0]);
|
|
74
|
+
const name = (locality || "").trim();
|
|
75
|
+
return region && name ? { slug: slugifyTown(`${name}-${region}`), name, label: `${name}, ${region}`, region } : null;
|
|
76
|
+
}
|
|
77
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
78
|
+
const name = parts[index].replace(UK_POSTCODE, "").replace(/^\d+[A-Z]?\s+/, "").trim();
|
|
79
|
+
if (!name || NOT_A_TOWN.test(name)) continue;
|
|
80
|
+
if (/\d/.test(name)) break;
|
|
81
|
+
return { slug: slugifyTown(name), name, label: name, region: null };
|
|
82
|
+
}
|
|
83
|
+
const name = (locality || "").trim();
|
|
84
|
+
return name ? { slug: slugifyTown(name), name, label: name, region: null } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Name search key: lower case, accents removed, letters and digits only, so "lakeview", "Lake-View" and
|
|
89
|
+
* "Lake View Spa" meet. Store it in a generated column (see directory-search.md) and compare both forms.
|
|
90
|
+
* @param {string | null | undefined} value
|
|
91
|
+
*/
|
|
92
|
+
export const searchKey = (value) => (value || "").normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rank names for a query: exact (0) → prefix (1) → word prefix (2) → contains (3); null when it does not match.
|
|
96
|
+
* Plain ILIKE on the display name and the normalised key are both tried.
|
|
97
|
+
* @param {string} name
|
|
98
|
+
* @param {string} query
|
|
99
|
+
* @returns {number | null}
|
|
100
|
+
*/
|
|
101
|
+
export function nameMatchRank(name, query) {
|
|
102
|
+
const key = searchKey(name);
|
|
103
|
+
const q = searchKey(query);
|
|
104
|
+
if (!q) return null;
|
|
105
|
+
const plain = (name || "").toLowerCase();
|
|
106
|
+
const raw = query.trim().toLowerCase();
|
|
107
|
+
if (key === q) return 0;
|
|
108
|
+
if (key.startsWith(q)) return 1;
|
|
109
|
+
if (plain.split(/[^a-z0-9]+/i).some((word) => word.startsWith(raw))) return 2;
|
|
110
|
+
if (key.includes(q) || plain.includes(raw)) return 3;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
@@ -25,6 +25,12 @@ auth, payments, or data; the host app does.
|
|
|
25
25
|
- Before browser QA, run `maggie qa app-preflight --project . --app-dir apps/app`.
|
|
26
26
|
It catches both mistakes and warns about rotating refresh tokens and
|
|
27
27
|
production-backed local servers (see `maggie-qa-workflow`).
|
|
28
|
+
- After an edit, prove the browser runs it: add `--base-url
|
|
29
|
+
http://localhost:8081 --expect-symbol <name from the edit>`. The preflight
|
|
30
|
+
fetches the served entry bundle and fails when the name is missing (stale
|
|
31
|
+
watcher, wrong folder, another session's server), and reports which process
|
|
32
|
+
holds the port and when it started — a server killed (exit 137) and
|
|
33
|
+
replaced by another session shows up as a different pid or start time.
|
|
28
34
|
- Design and validate signed-in routes with `maggie design app-init`,
|
|
29
35
|
`app-collect` and `app-validate` (see `maggie-design`, including the
|
|
30
36
|
[app UI patterns](../maggie-design/references/app-ui-patterns.md)).
|
|
@@ -104,11 +110,53 @@ prints the exact commands with the cost notice and runs nothing:
|
|
|
104
110
|
|
|
105
111
|
Editing `eas.json` after a build — even only adding `submit.ios.ascAppId` —
|
|
106
112
|
changes the local fingerprint. An update published from that tree targets a
|
|
107
|
-
runtime no installed build has, and EAS reports no error.
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
runtime no installed build has, and EAS reports no error. Add `eas.json` to
|
|
114
|
+
`.fingerprintignore` with the next native build so it stops affecting the
|
|
115
|
+
runtime; until then, publish with the build's own config files:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
maggie mobile plan --project . --app-dir apps/app --platform ios --build-ref <build commit>
|
|
119
|
+
maggie mobile ota --project . --app-dir apps/app --platform ios \
|
|
120
|
+
--build-ref <build commit> --build-id <installed EAS build id> --message "Fix copy" # dry run
|
|
121
|
+
# add --confirm to run it
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`plan --build-ref` lists which of `eas.json` / `package.json` differ from the
|
|
125
|
+
build commit. `ota --confirm` puts the build commit's copies in place, runs
|
|
126
|
+
`eas fingerprint:compare --json` and compares the two hashes (the command
|
|
127
|
+
exits 0 even when they differ), publishes the update only on a match, and
|
|
128
|
+
always restores the working copies — checked by hash — even when a step
|
|
129
|
+
fails. A mismatch with the build's own files means the change needs a new
|
|
130
|
+
native build. `MAGGIE_MOBILE_EAS` overrides the `npx eas-cli` invocation.
|
|
131
|
+
|
|
132
|
+
### Monorepos: publish only to a runtime an installed build has
|
|
133
|
+
|
|
134
|
+
In a pnpm monorepo the fingerprint also hashes pnpm's virtual-store paths
|
|
135
|
+
(`node_modules/.pnpm/<pkg>@<version>_<peer hash>`), so a dependency change in
|
|
136
|
+
**any** workspace package (React added to the website) can move the app's
|
|
137
|
+
runtime. `eas update` then publishes to a runtime no build has — silently.
|
|
138
|
+
`ignorePaths` and `fileHookTransform` cannot fix it: `@expo/fingerprint` adds
|
|
139
|
+
each native module folder by its real path. Keep the installed builds'
|
|
140
|
+
runtimes in a file and publish through the guard:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{"ios": [{"build": 7, "eas_build_id": "…", "runtime": "19d7a260e8e2…"}], "android": []}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
maggie mobile ota-guard --project . --app-dir apps/app --platform ios --runtimes scripts/ota-runtimes.json
|
|
148
|
+
maggie mobile ota-guard … --publish --message "Fix copy" --confirm
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
It computes the fingerprint (`expo-updates fingerprint:generate`), refuses
|
|
152
|
+
when no installed build has it — naming the two ways out (a native build, or
|
|
153
|
+
publishing from a worktree with the build's lockfile and workspace file after a
|
|
154
|
+
frozen install) — and with `--publish --confirm` runs `eas update` and checks
|
|
155
|
+
that the published runtime is the one it checked. Add a build's runtime to the
|
|
156
|
+
file when it ships; drop it when nobody runs it. At the next native build set
|
|
157
|
+
an explicit `runtimeVersion` with a native-change check, so unrelated
|
|
158
|
+
workspace changes cannot move it. `preflight` warns about a fingerprint
|
|
159
|
+
runtime in a pnpm workspace.
|
|
112
160
|
|
|
113
161
|
Show the plan's `costNotice` and wait for confirmation before step 1.
|
|
114
162
|
|
|
@@ -123,7 +171,18 @@ Show the plan's `costNotice` and wait for confirmation before step 1.
|
|
|
123
171
|
and every permission's purpose string.
|
|
124
172
|
- Account deletion must be reachable in the app when sign-up is.
|
|
125
173
|
|
|
126
|
-
## 6.
|
|
174
|
+
## 6. In-person payments and push
|
|
175
|
+
|
|
176
|
+
Tap to Pay and push notifications are server features with a native client;
|
|
177
|
+
the host's payment and notification code stays the source of truth. See
|
|
178
|
+
[payments and push](references/payments-and-push.md) for Stripe Terminal
|
|
179
|
+
(Tap to Pay on iPhone entitlement, Android NFC, one Terminal location per
|
|
180
|
+
branch, the connection-token endpoint on the business's connected account)
|
|
181
|
+
and Expo push as one more channel of the host's notification worker (device
|
|
182
|
+
table, tickets and receipts, pruning `DeviceNotRegistered` tokens, actionable
|
|
183
|
+
notifications).
|
|
184
|
+
|
|
185
|
+
## 7. Readiness report
|
|
127
186
|
|
|
128
187
|
Before a store submission, record: preflight passed, app-validate passed for
|
|
129
188
|
each signed-in route, QA run summary (`maggie qa summary`), the build and
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# In-person payments and push for an Expo business app
|
|
2
|
+
|
|
3
|
+
Both features need a development or store build (not Expo Go) and a server
|
|
4
|
+
that already owns payments and notifications. The app is a thin client.
|
|
5
|
+
|
|
6
|
+
## Tap to Pay and card readers (Stripe Terminal)
|
|
7
|
+
|
|
8
|
+
Server-driven readers (WisePOS E, S700) need no app at all: the server sends
|
|
9
|
+
the amount to the reader and records the payment when Stripe reports the
|
|
10
|
+
PaymentIntent succeeded. Tap to Pay turns the phone itself into the reader
|
|
11
|
+
and needs the native SDK.
|
|
12
|
+
|
|
13
|
+
**Account.** Payments go to the business's own connected account (direct
|
|
14
|
+
charges). Every Terminal call — locations, readers, connection tokens,
|
|
15
|
+
PaymentIntents — passes `stripeAccount: <connected account>`. Refuse reader
|
|
16
|
+
setup until the account can take charges ("Connect payments first").
|
|
17
|
+
|
|
18
|
+
**Locations.** One Terminal location per branch, created once from the
|
|
19
|
+
branch address and stored on the branch (`stripe_terminal_location_id`).
|
|
20
|
+
Readers and Tap to Pay sessions are registered to the branch the device is
|
|
21
|
+
used at; never share one location across branches (receipts, reporting and
|
|
22
|
+
reader discovery are per location).
|
|
23
|
+
|
|
24
|
+
**Connection token endpoint.** `POST /api/.../terminal/connection-token`,
|
|
25
|
+
signed-in staff only, returns `stripe.terminal.connectionTokens.create({location},
|
|
26
|
+
{stripeAccount})`. Scope it to the staff member's business and branch; never
|
|
27
|
+
cache a token server-side.
|
|
28
|
+
|
|
29
|
+
**App.** `@stripe/stripe-terminal-react-native` with its Expo config plugin:
|
|
30
|
+
|
|
31
|
+
- iOS: Tap to Pay on iPhone needs the
|
|
32
|
+
`com.apple.developer.proximity-reader.payment.acceptance` entitlement,
|
|
33
|
+
requested from Apple for the team and added to the provisioning profile,
|
|
34
|
+
iPhone XS or later on a current iOS, and Apple's merchant education screens
|
|
35
|
+
on first use. Location permission is required by the SDK.
|
|
36
|
+
- Android: NFC hardware, a supported Android version, Google Play services,
|
|
37
|
+
and no developer options / rooted device in live mode; the SDK refuses
|
|
38
|
+
otherwise — show a clear "this phone can't take cards" message.
|
|
39
|
+
- Discover with the Tap to Pay discovery method, connect to the branch's
|
|
40
|
+
location, collect and confirm the PaymentIntent created **by the server**
|
|
41
|
+
(amount and metadata come from the sale, never from the app).
|
|
42
|
+
|
|
43
|
+
**Testing.** Test mode with the simulated reader (`simulated-wpe` for
|
|
44
|
+
server-driven readers; the SDK's simulated Tap to Pay reader on device). Keep
|
|
45
|
+
a test case per outcome: approved, declined, cancelled on the device,
|
|
46
|
+
network lost after the tap (reconcile from the webhook, never charge twice).
|
|
47
|
+
|
|
48
|
+
**Store review.** Services consumed outside the app (a treatment at the
|
|
49
|
+
salon) must not use in-app purchase (App Store guideline 3.1.3(e), goods and
|
|
50
|
+
services outside of the app); do not sell digital goods through the reader.
|
|
51
|
+
|
|
52
|
+
## Push as a notification channel
|
|
53
|
+
|
|
54
|
+
Push is one more channel of the host's notification worker (beside e-mail and
|
|
55
|
+
SMS), not a separate system.
|
|
56
|
+
|
|
57
|
+
- **Device table**: `user_id`, `business_id`, `expo_push_token`, `platform`,
|
|
58
|
+
`app_version`, `last_seen_at`, `disabled_at`. Register after sign-in and
|
|
59
|
+
permission, re-register on every app start (tokens change), and delete on
|
|
60
|
+
sign-out. One user may have several devices.
|
|
61
|
+
- **Sending**: the worker batches up to 100 messages per request to the Expo
|
|
62
|
+
push API with the project's access token, and stores each **ticket id**
|
|
63
|
+
with the outbox row. Use the same once-only guard as e-mail (outbox key per
|
|
64
|
+
event + recipient + device).
|
|
65
|
+
- **Receipts**: about 15 minutes later, fetch receipts for the stored ticket
|
|
66
|
+
ids. `DeviceNotRegistered` → disable the token; `MessageTooBig`,
|
|
67
|
+
`MessageRateExceeded`, `InvalidCredentials` → alert, do not retry blindly.
|
|
68
|
+
- **Pruning**: disable tokens not seen for 90 days and tokens that failed
|
|
69
|
+
with `DeviceNotRegistered`; never keep sending to dead tokens.
|
|
70
|
+
- **Actionable notifications**: register categories in the app
|
|
71
|
+
(`setNotificationCategoryAsync`, e.g. Accept / Decline for a booking
|
|
72
|
+
request). The action calls the normal API with the signed-in session; a
|
|
73
|
+
notification action is never an approval by itself for anything risky.
|
|
74
|
+
- **Content**: no private details on the lock screen by default ("New booking
|
|
75
|
+
request" rather than the client's name and treatment); let the business
|
|
76
|
+
opt in to details.
|
|
77
|
+
- **Preferences**: per event type and channel, stored server-side, honoured
|
|
78
|
+
by the worker before queueing.
|
|
@@ -124,6 +124,66 @@ It warns, with environment variable names and host classes only, when:
|
|
|
124
124
|
|
|
125
125
|
Use `--rotating-refresh-tokens yes|no` when the code scan cannot tell.
|
|
126
126
|
|
|
127
|
+
After an edit, prove the browser is running it before judging the change:
|
|
128
|
+
`--base-url http://localhost:8081 --expect-symbol ComposerAutoGrow` fetches the
|
|
129
|
+
page's same-origin scripts and fails when the served bundle lacks the name (a
|
|
130
|
+
stale watcher, the wrong folder, or another session's server). For a local
|
|
131
|
+
`--base-url` the report also names the process listening on the port and its
|
|
132
|
+
start time; a server that was killed (exit 137) and replaced by another
|
|
133
|
+
session fails with "port … is held by pid …".
|
|
134
|
+
|
|
135
|
+
## Driving the browser during QA
|
|
136
|
+
|
|
137
|
+
These rules come from sessions that rediscovered each of them:
|
|
138
|
+
|
|
139
|
+
- **Find controls by role and accessible name, not by text.** Icon fonts put
|
|
140
|
+
private-use glyphs into `innerText` (a React Native Web tab reads
|
|
141
|
+
`"\nImage"` or `"Home"`), so exact-text matching fails. Match
|
|
142
|
+
`[role=tab]`, `[role=button]` with `aria-label`, or strip characters in
|
|
143
|
+
U+E000–U+F8FF before comparing.
|
|
144
|
+
- **Click through the DOM when the automated window is in the background.**
|
|
145
|
+
Synthetic mouse clicks wait for the window to be visible and time out;
|
|
146
|
+
`element.click()` (or dispatching the event) works in a background window.
|
|
147
|
+
Keep real input for checks that are about input itself (hover, drag).
|
|
148
|
+
- **Reuse the app tab.** Opening a new tab per check clutters the user's
|
|
149
|
+
browser and, with rotating refresh tokens, adds another app instance.
|
|
150
|
+
Navigate the existing tab, and close any tab you opened.
|
|
151
|
+
- **Phone width when the window cannot be resized.** Some browser extensions
|
|
152
|
+
ignore window resizing. A same-origin `<iframe>` 390 px wide reuses the
|
|
153
|
+
signed-in session and is fine for layout checks **one at a time**: create
|
|
154
|
+
it, measure, remove it, then the next. The "no parallel iframes" rule is
|
|
155
|
+
about several app instances refreshing a rotating token at once, not about
|
|
156
|
+
a single frame. `maggie design app-collect` and `maggie qa fit-check` set
|
|
157
|
+
the size through the DevTools protocol and need no iframe.
|
|
158
|
+
- **Long measurements run in the page, not in one call.** A loop over 30
|
|
159
|
+
routes inside one script call times out the browser tool. Start the loop in
|
|
160
|
+
the page (`window.__qa = {done: false, results: []}`; an async function that
|
|
161
|
+
pushes results), return immediately, then poll `window.__qa` with short
|
|
162
|
+
calls. Give each call a time budget of a few seconds.
|
|
163
|
+
|
|
164
|
+
## Check that dashboards fit the screen
|
|
165
|
+
|
|
166
|
+
When a dashboard must not scroll the page, check every route, tab and drawer
|
|
167
|
+
at a short laptop and a phone viewport:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
maggie qa fit-check --base-url http://localhost:4321 \
|
|
171
|
+
--route /merchant/bookings --route /merchant/settings \
|
|
172
|
+
--profile-dir .maggie/qa-profile --drawer-selector "[data-drawer-trigger]" \
|
|
173
|
+
--sidebar-selector "nav.sidebar" --output .maggie/qa-runs/fit.json
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Default viewports are 1536x687 (a laptop with browser chrome) and 390x844.
|
|
177
|
+
For each route it measures page overflow on both axes, then clicks every
|
|
178
|
+
visible `[role=tab]` (`--tab-selector`) and measures again, then opens each
|
|
179
|
+
drawer trigger on a fresh page and checks the dialog stays inside the
|
|
180
|
+
viewport. Hidden panels are measured only after their tab is active — a
|
|
181
|
+
panel measured while hidden reports zero, which also breaks screen-sized
|
|
182
|
+
pagers that do not re-measure on tab change. Labels ignore icon glyphs.
|
|
183
|
+
Writes are blocked unless `--allow-write <refresh path>` or `--allow-writes`;
|
|
184
|
+
use `--cdp-url` to attach to a signed-in Chrome. Exit 1 lists each failing
|
|
185
|
+
route, viewport and state, with the first element that is too wide.
|
|
186
|
+
|
|
127
187
|
## Check CORS for every method the client uses
|
|
128
188
|
|
|
129
189
|
Before a release that adds or changes API calls from a browser or webview,
|
|
@@ -205,6 +205,39 @@ does not claim keyboard accessibility, network asset correctness or semantic
|
|
|
205
205
|
content quality unless those checks run separately. Use the installed browser
|
|
206
206
|
path explicitly when Bun is not in PATH.
|
|
207
207
|
|
|
208
|
+
## Route checks: name the origin
|
|
209
|
+
|
|
210
|
+
Release checks that crawl routes (dynamic SEO, localization routes, sitemap
|
|
211
|
+
size) must take an explicit base URL — `--base-url` or a required
|
|
212
|
+
`SITE_ORIGIN` that fails when unset — and print the origin they checked. A
|
|
213
|
+
default such as `http://127.0.0.1:4321` silently hits another local project
|
|
214
|
+
on that port and produces false "foreign URL" and sitemap-size failures.
|
|
215
|
+
Prove which site answers first:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
maggie seo origin-check --base-url http://127.0.0.1:4321 --project .
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
It compares the page's canonical / `og:url` and the robots sitemap host with
|
|
222
|
+
`site` in `astro.config.*` (or `--expect-host`), fails when another site
|
|
223
|
+
answers, and prints `checked origin: …`.
|
|
224
|
+
|
|
225
|
+
## Personalised HTML fragments
|
|
226
|
+
|
|
227
|
+
Per-person partials (recently viewed, recommendations, "your bookings",
|
|
228
|
+
signed-in header fragments) are not public pages. Each must answer
|
|
229
|
+
`Cache-Control: private, no-store` and `X-Robots-Tag: noindex`, with no
|
|
230
|
+
`public`, `s-maxage` or CDN cache header. Global middleware that sets public
|
|
231
|
+
HTML caching for every `text/html` response must leave a route's private
|
|
232
|
+
header alone (check for an existing `Cache-Control` before setting one);
|
|
233
|
+
otherwise a CDN caches one person's fragment and serves it to everyone. The
|
|
234
|
+
public-session cache contract covers full pages; fragments follow this rule:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
maggie seo fragment-check --url http://127.0.0.1:4321/fragments/recently-viewed \
|
|
238
|
+
--url http://127.0.0.1:4321/fragments/recommendations --cookie-env QA_SESSION_COOKIE
|
|
239
|
+
```
|
|
240
|
+
|
|
208
241
|
## Automatic memory hook
|
|
209
242
|
|
|
210
243
|
Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
|
|
@@ -486,6 +486,29 @@ FAQ helper from the first category implementation; it is a known gap and must
|
|
|
486
486
|
remain marked incomplete until each FAQ record is independently AI-authored and
|
|
487
487
|
reviewed.
|
|
488
488
|
|
|
489
|
+
## Moving a business: clients, appointments, gift cards
|
|
490
|
+
|
|
491
|
+
Importing the provider catalogue is not enough to switch a real business.
|
|
492
|
+
Dry-run the exports first (nothing is written):
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
maggie service migrate-check --project . --timezone Europe/London --market uk \
|
|
496
|
+
--clients clients.csv --appointments appointments.csv --gift-cards gift-cards.csv \
|
|
497
|
+
--service "Lash lift" --service "Massage" --staff Jo --staff Sam
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
It recognises Fresha's (and other systems') column names by synonyms and
|
|
501
|
+
lists unmapped columns; de-duplicates clients by e-mail, then by phone in
|
|
502
|
+
E.164; keeps marketing consent only when the export says opted in; keeps
|
|
503
|
+
only future, not-cancelled appointments, converted with the venue's zone,
|
|
504
|
+
and reports clashes and unmatched services or staff instead of forcing them;
|
|
505
|
+
and keeps gift-card codes with their balance in an `IMPORT:` namespace. The
|
|
506
|
+
report holds counts, column names and row numbers only — no client values —
|
|
507
|
+
plus the undo key and plan. Every real import writes an import record first
|
|
508
|
+
so it can be undone (bookings and unused clients/staff deleted, untouched
|
|
509
|
+
imported codes voided), and an optional "we've moved" e-mail goes once, only
|
|
510
|
+
to imported clients with marketing consent, while online booking is live.
|
|
511
|
+
|
|
489
512
|
## Payments: Stripe live setup and gift vouchers
|
|
490
513
|
|
|
491
514
|
Service pages often lead to first-party payments. When the host takes money
|
|
@@ -494,9 +517,11 @@ read-only live setup runbook in `docs/maggiedash-booking/STRIPE-INTEGRATION.md`:
|
|
|
494
517
|
|
|
495
518
|
- verify live state with list/retrieve (`GET`) calls only; never use a create
|
|
496
519
|
call as a probe — a probe expected to fail can create a real live object;
|
|
497
|
-
- the owner installs webhook and
|
|
498
|
-
|
|
499
|
-
|
|
520
|
+
- the owner installs webhook signing secrets — and, on a platform that hosts
|
|
521
|
+
many businesses (Stripe Connect direct charges, see "Platform hosts" in
|
|
522
|
+
`STRIPE-INTEGRATION.md`), the Connect event-destination secret — with a
|
|
523
|
+
no-echo command (`read -rs … | <secret store>`); the agent never sees them.
|
|
524
|
+
A single business needs no Connect;
|
|
500
525
|
- thin-event destination signing secrets cannot be retrieved via the API;
|
|
501
526
|
roll them in the Dashboard if they were not captured;
|
|
502
527
|
- the Stripe Dashboard may be unusable under browser automation, so hand
|
|
@@ -22,7 +22,7 @@ and GEO. It is not a second content source of truth.
|
|
|
22
22
|
Create the reviewable channel contract before generating copy:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
maggie tool maggie_social_share.py plan content.json \
|
|
25
|
+
maggie tool maggie_social_share.py plan content.json --workspace-domain example.com \
|
|
26
26
|
--channels linkedin,facebook --output .maggie/social-share-plan.json
|
|
27
27
|
maggie tool maggie_social_share.py validate .maggie/social-share-plan.json
|
|
28
28
|
```
|
|
@@ -30,6 +30,50 @@ maggie tool maggie_social_share.py validate .maggie/social-share-plan.json
|
|
|
30
30
|
The CLI does not generate copy or publish. It validates identity, canonical
|
|
31
31
|
URL, channel limits, UTM links, approval state, and provider post state.
|
|
32
32
|
|
|
33
|
+
- **Own domain only.** A workspace shares articles published on its own
|
|
34
|
+
site. `plan` refuses a source whose canonical URL is not on
|
|
35
|
+
`--workspace-domain` (or a subdomain), and `validate` refuses an approved
|
|
36
|
+
draft without `workspace_domain`. Apply the same rule in source selection:
|
|
37
|
+
an account that manages several sites must not pick another site's
|
|
38
|
+
articles for this workspace's plan.
|
|
39
|
+
- **Live links only.** Before planning posts from articles, drop the ones
|
|
40
|
+
whose link is gone. A site that moved platforms leaves old URLs that answer
|
|
41
|
+
404; posts scheduled with them reach readers as dead links.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
maggie tool maggie_social_share.py link-check .maggie/candidate-articles.json
|
|
45
|
+
maggie tool maggie_social_share.py plan content.json --workspace-domain example.com --check-link
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Only a definite 404 or 410 (also after redirects) drops an article. Blocked
|
|
49
|
+
(403/429), failing (5xx) or unreachable pages are kept, so a firewall or an
|
|
50
|
+
outage cannot empty the list; verdicts are cached for 6 hours
|
|
51
|
+
(`.maggie/social-link-cache.json`, `--ttl-hours`). Prefer the URL the site
|
|
52
|
+
reports for a delivered post over an older stored one (maggie-blog, "Where a
|
|
53
|
+
delivered post went live").
|
|
54
|
+
- **One picture per idea.** Posts written together for several channels from
|
|
55
|
+
one idea share one picture on every channel, and a later picture change
|
|
56
|
+
(redraw, replace, rewrite of an idea that had one) is applied to every
|
|
57
|
+
channel of that idea, not only some. Write all channels of an idea through
|
|
58
|
+
the same path — an article-sourced channel (often LinkedIn) must join the
|
|
59
|
+
other channels' idea instead of being written separately. Check with:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
maggie tool maggie_social_share.py pictures .maggie/social-drafts.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It groups drafts by `idea_id` and fails a group whose channels carry
|
|
66
|
+
different pictures or one without; a channel whose picture a person chose
|
|
67
|
+
records `image_source: "user"`. `validate` applies the same rule to the
|
|
68
|
+
channels of one draft.
|
|
69
|
+
- **Image posts by default.** When a post has its own image (`image_url`),
|
|
70
|
+
each channel gets `publish_mode: "image"` with the link in the text;
|
|
71
|
+
`link_preview` is the default only without an image. A person may switch a
|
|
72
|
+
post to link preview (record `publish_mode_source: "user"`), and the
|
|
73
|
+
preview shows the card LinkedIn will show. `validate` fails an image post
|
|
74
|
+
whose copy lacks the link and an image silently dropped by link-preview
|
|
75
|
+
mode.
|
|
76
|
+
|
|
33
77
|
Before connecting providers or scheduling, check the adapter, connection,
|
|
34
78
|
campaign, and queue contracts (shapes and rules in
|
|
35
79
|
[queue and connections](references/queue-and-connections.md)):
|
|
@@ -68,7 +112,10 @@ maggie tool maggie_marketing_run.py validate .maggie/marketing-runs/<runId>.json
|
|
|
68
112
|
optional `not_before`, and local/remote status. Posting hours are slots;
|
|
69
113
|
do not persist a fixed `scheduled_at` per post (it goes stale and strands
|
|
70
114
|
overdue posts). Campaign ideas stay in the idea stage until written, and a
|
|
71
|
-
campaign starts only through the Start gate.
|
|
115
|
+
campaign starts only through the Start gate. A series or campaign created
|
|
116
|
+
only so it can be planned is deleted (or never committed) when the AI plan
|
|
117
|
+
fails, so a retry does not leave an empty draft per attempt; `campaigns`
|
|
118
|
+
fails a campaign with `plan_status: failed` and no ideas.
|
|
72
119
|
6. Publish only after the required approval and entitlement checks. Record the
|
|
73
120
|
publication attempt and provider ID; reconcile unknown outcomes against
|
|
74
121
|
readback and retry only with the original idempotency key.
|
|
@@ -101,8 +148,13 @@ idempotency, and delete/republish behavior.
|
|
|
101
148
|
decrypt check on every host that serves social routes.
|
|
102
149
|
- Provider avatar URLs are signed and expire; cache the bytes or refresh on
|
|
103
150
|
readback.
|
|
104
|
-
-
|
|
105
|
-
|
|
151
|
+
- Structured-output schemas fail on **complexity**, not only on large enums:
|
|
152
|
+
no enum inside repeated array items, small `maxItems`, and validate the
|
|
153
|
+
vocabulary server-side. Run `maggie tool maggie_social_share.py
|
|
154
|
+
schema-check plan-schema.json` before a release; see [provider adapter
|
|
155
|
+
contract](../../references/provider-adapter-contract.md#structured-model-output).
|
|
156
|
+
- Posted posts have a lifecycle (Post now, delete, edit and republish); see
|
|
157
|
+
[queue and connections](references/queue-and-connections.md#posted-post-lifecycle).
|
|
106
158
|
|
|
107
159
|
## Safety and commercial boundary
|
|
108
160
|
|