@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.
Files changed (79) hide show
  1. package/README-zh-TW.md +28 -4
  2. package/README.md +93 -60
  3. package/bin/maggie.js +67 -12
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  12. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  13. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  14. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  15. package/bundled-references/provider-adapter-contract.md +16 -3
  16. package/bundled-skills/catalog.json +1 -1
  17. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  18. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  19. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  20. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  21. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  23. package/bundled-skills/maggie-booking/SKILL.md +66 -14
  24. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  25. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  26. package/bundled-skills/maggie-deployment/SKILL.md +48 -0
  27. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  28. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  29. package/bundled-skills/maggie-design/SKILL.md +98 -3
  30. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  31. package/bundled-skills/maggie-feedback/SKILL.md +27 -2
  32. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  33. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  34. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  35. package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
  36. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  38. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  39. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  40. package/bundled-skills/maggie-social-share/SKILL.md +56 -4
  41. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  42. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  43. package/bundled-tools/clis/maggie_booking.py +24 -12
  44. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  45. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  46. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  47. package/bundled-tools/clis/maggie_dash.py +173 -3
  48. package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
  49. package/bundled-tools/clis/maggie_design.py +15 -2
  50. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  51. package/bundled-tools/clis/maggie_design_lint.py +205 -3
  52. package/bundled-tools/clis/maggie_feedback.py +138 -28
  53. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  54. package/bundled-tools/clis/maggie_localization.py +3 -0
  55. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  56. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  57. package/bundled-tools/clis/maggie_mobile.py +219 -4
  58. package/bundled-tools/clis/maggie_product_release.py +49 -1
  59. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  60. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  61. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  62. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  63. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  64. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  65. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  66. package/bundled-tools/clis/maggie_social_share.py +174 -7
  67. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  68. package/bundled-tools/runtime/assistant_approval.py +98 -0
  69. package/package.json +1 -1
  70. package/references/provider-adapter-contract.md +16 -3
  71. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  72. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  73. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  74. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  75. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  76. package/bundled-references/maggiedash-booking/README.md +0 -288
  77. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  78. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  79. 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. Run
108
- `fingerprint:compare` against the target build before every update; if only
109
- `eas.json` differs, publish from the build-time `eas.json` (restore it, publish,
110
- restore the current file) and add `eas.json` to `.fingerprintignore` with the
111
- next native build so it stops affecting the runtime.
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. Readiness report
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 Connect event-destination signing secrets
498
- with a no-echo command (`read -rs … | <secret store>`); the agent never sees
499
- them;
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
- - Keep model structured-output enums small and validate large vocabularies
105
- server-side; see [provider adapter contract](../../references/provider-adapter-contract.md#structured-model-output).
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