diff --git a/manifest.json b/manifest.json index 0ddf129..e2de8e9 100644 --- a/manifest.json +++ b/manifest.json @@ -473,6 +473,15 @@ "icon": "rocket", "path": "onboarding/launch-setup/SKILL.md", "updated_at": "2026-07-30T23:10:00Z" + }, + { + "slug": "onboarding/catalog-hygiene-audit", + "name": "Catalog Hygiene Audit", + "description": "Sweep a product catalog for data-integrity defects — duplicate SKUs, defective live slugs, inconsistent family naming, reused images, unpriced purchasable products — rank them by customer-visible impact, and apply only the fixes the user approves one at a time.", + "category": "onboarding", + "icon": "list-checks", + "path": "onboarding/catalog-hygiene-audit.md", + "updated_at": "2026-07-31T22:30:00Z" } ], "workflows": [ diff --git a/onboarding/catalog-hygiene-audit.md b/onboarding/catalog-hygiene-audit.md new file mode 100644 index 0000000..a0698da --- /dev/null +++ b/onboarding/catalog-hygiene-audit.md @@ -0,0 +1,217 @@ +--- +name: Catalog Hygiene Audit +description: Sweep a product catalog for data-integrity defects — duplicate SKUs, defective live slugs, inconsistent family naming, reused images, unpriced purchasable products — rank them by customer-visible impact, and apply only the fixes the user approves one at a time. +icon: list-checks +--- + +# Goal + +Sweep the company's product catalog for data-integrity defects, rank them by +customer-visible impact, and apply ONLY the fixes the user approves. + +This skill is **read-only until approval**. It never deletes a product, never +bulk-writes, and never changes a live public URL without an explicit Approve +click on that specific product. + +# Scope note before you start + +- Use `fluid_api` (live) for all catalog data. Before trusting ANY `db_query` + result, confirm the saved reporting connection actually contains the active + company — several companies' reporting copies hold a different tenant, or hold + the right tenant with transactional tables not yet synced. A `0` from the wrong + environment is not evidence of absence. If in doubt, answer from `fluid_api`. +- Prices come back as strings like `"$179.95 (USD)"` in display fields and + `"0.0"` in raw fields. Strip the currency suffix; treat `0` as *unpriced*, + never as *free*. + +# Step 1 — Enumerate the catalog (read-only) + +Prefer `fluid_catalog_index` — it follows pagination internally, re-reads +first/middle/last through the detail endpoint, and writes +`fluid-catalog-index.json` as evidence. + +Fallback: `GET /api/v202604/company/products?page[limit]=100`, following +`meta.pagination.next_cursor` until null. Note that large pages come back as a +`compact_projection` (id, title, default_variant.sku, canonical_url, +image_url) — that projection is enough for rules R1–R4, but R5 needs variant +detail, so fetch `GET /api/v202604/company/products/{id}` for any product whose +price is not present in the projection. + +Record the total product count. Every later claim must reconcile against it. + +# Step 2 — Run the five detection rules + +## R1 — Duplicate SKU across distinct products · CRITICAL +Normalize each default SKU (trim, uppercase). Flag any normalized SKU held by +**2 or more distinct product ids**. + +Why critical: a shared SKU breaks inventory decrementing and merges the two +products in every revenue report. + +Proposed fix: assign a distinguishing suffix to the *non-canonical* product +(the clone / gated / secondary listing), never to the primary retail SKU. +Ask which is canonical if it is not obvious from the titles. + +## R2 — Defective slug shipped live · HIGH +Flag a slug that matches any of: +- `-copy` / `-copy-\d+` / `copy-of-` anywhere in the slug +- a bare trailing counter: `-\d+$` +- `^untitled` +- the slug shares **no** normalized word with the product title + +Why high: the slug is the public URL. `…/products/widget-2-copy` is a +customer-visible artifact of an admin duplicate. + +**Suppress the bare-counter match when the digit is a model number that appears +in the title** (`widget-2` for "Widget 2" is correct, not an admin counter). +Report the suppressions in one line so the user can see what you chose not to +flag. + +Proposed fix: a clean slug derived from the title. +**Blast radius — always state this on the card:** changing a slug changes the +live public URL, and the old URL will 404. Before proposing, check whether the +slug is referenced by a mobile widget (`GET /api/company/mobile_widgets` — read +each `embed_url`), a theme, a post, or an active campaign, and name what you +found either way. + +## R3 — Inconsistent naming convention inside a product family · MEDIUM +Group products by family (the model name in the title). Within a family, compare +the slug pattern of every product sharing a qualifier such as `(Refurbished)` or +a membership/tier tag. + +Flag the family when **2 or more distinct patterns** express the same +qualifier. Pick the majority pattern in the catalog as the target convention; +if there is no majority, propose `-` (qualifier last) and say +it is a judgment call. + +Offer the cheap fix and the complete fix separately: normalizing the single +outlier changes one URL, normalizing the whole family changes several. Let the +user choose the scope. + +## R4 — Image reused verbatim across distinct products · MEDIUM (review, not auto-fix) +Flag an identical `image_url` held by 2 or more distinct product ids. + +Judgment required — do NOT propose a mechanical fix. A refurbished SKU +legitimately reuses its retail parent's hero image. A *differently-branded* +listing (a co-branded or member-exclusive edition) reusing the standard +image is a real merchandising gap. Report both, labelled, and let the user +decide which need their own photography. + +## R5 — Zero or missing price on a purchasable product · CRITICAL +Flag a default variant whose price is `0`, `"0.0"`, null, or absent **while the +product is active/public**. + +Distinguish two causes before reporting: +- genuinely free/promo item → not a defect, note and move on +- price simply never entered → CRITICAL, it is purchasable at $0 right now + +Never invent a price. Ask the user for the number, or offer deactivation as the +interim fix. + +# Step 3 — Report, ranked + +Present one table ordered CRITICAL → HIGH → MEDIUM, then within severity by +whether the defect is customer-visible: + +| # | Severity | Rule | Product (id) | Evidence | Proposed fix | Changes a live URL? | + +Rules: +- Evidence is quoted verbatim from the API response (the actual SKU, the actual + slug, the actual price string) — never paraphrased. +- Every URL you show is copied verbatim from `canonical_url` in a response. + Never compose a URL from a slug you chose yourself. +- State the total scanned count and the clean count, so the user knows the + denominator. +- If a rule found nothing, say so in one line. Silence reads as an omission. + +Then stop and let the user react to the report before proposing anything. + +# Step 4 — Approval-gated fixes, one issue at a time + +For each fix the user wants, call `human_in_the_loop` with a **deterministic** +suggestion id so the same defect never gets re-prompted once decided: + + catalog-hygiene:: e.g. catalog-hygiene:r1-dup-sku:87907 + +Include `current_score` only where a real number exists, `before_payload` with +the exact current field value, and name the blast radius in the description. +End the turn after proposing. A dismissal is final — do not re-propose it. + +If the user says "remove" or "delete" a product, do NOT delete. Propose +**archive + unpublish** instead, state plainly that it is reversible and that +deletion is not, and make them say "delete permanently" explicitly before you +treat destruction as the request. + +## Write calls (only after Approve) + +**Slug repair** — `PATCH /api/v202604/company/products/{id}` + + { "product": { "slug": "widget-2-member", "custom_slug": true } } + +`custom_slug: true` is mandatory. Without it the API **silently ignores** your +slug and regenerates one from the title, and you will report a success that did +not happen. After the PATCH, re-`GET` the product and quote `canonical_url` +from the response to prove the new URL. + +**SKU repair** — same endpoint, `{ "product": { "sku": "…" } }`. Re-GET to +verify. Confirm the new SKU is not itself a duplicate before writing. + +**Price repair** — `PUT /api/company/v1/variants/{variant_id}` + + { "variant": { "price": "24.95" } } + +Note: `PUT`, not `PATCH`, and hit the variant directly rather than nesting +through the product. Re-GET the variant to verify the value persisted. + +**Archive / unpublish** — `PATCH /api/v202604/company/products/{id}` with +`{ "product": { "status": "archived", "public": false } }`. `status` accepts +`draft` | `scheduled` | `published` | `archived`. + +**Never** delete a product to resolve a duplicate. Archiving is the reversible +alternative — and still needs approval. + +# Step 5 — Verify and record + +1. Re-run the failing rule against live data and show it now passes. +2. Call `human_in_the_loop` with `mode: "record_outcome"`, the same + `suggestion_id`, and `after_payload` holding the verified new value. +3. Report any **residual** the fix did not address — archiving a $0 product + hides it but leaves `variant_countries.*.price: "0.0"` and + `buyable: true`, so republishing without a price reintroduces the defect. + Say so rather than implying the underlying data is now correct. +4. Summarize: fixed / deferred / dismissed counts, and anything still needing a + human decision (a missing price, a canonical-product choice). + +Report a fix as complete only after the re-read confirmed it. A 200 response is +not proof — the slug trap above returns 200 while ignoring your change. + +# Gotchas + +- **The `custom_slug` trap is the single most dangerous behaviour here.** A slug + PATCH without it returns `200` with the OLD slug in the response body. Read + the response, don't assume. +- **`sku` at product level is usually `null`**; the real SKU is + `default_variant.sku`. Deduplicating on the product field finds nothing on a + catalog full of duplicates. +- **The public storefront endpoint is not a verification path.** + `GET /api/v202604/products/{slug}` can return `404 "Storefront not found"` + because the shop host doesn't resolve for the API token — that says nothing + about the product. The admin re-read is the proof. +- **A digit at the end of a slug is usually a model number, not a duplicate + counter.** Check the title before flagging. +- **Refurbished/secondary listings sharing a parent's hero image are normal.** + Only flag image reuse across *differently branded* listings as a real gap. + +# Known exceptions + +Some collisions and $0 prices are deliberate — member-gated merch whose pricing +lives behind login, or intentional secondary listings of the same hardware. +These are still worth reporting (a shared SKU is a real reporting defect however +deliberate the duplicate listing is), but they must be labelled *awaiting a +decision* rather than *admin error*, and the duplicate listing itself must never +be proposed for merge or deletion. + +At the end of a run, record the exceptions this company confirmed via +`update_memory` so the next run reports them as known rather than re-litigating +them. A test-mode payment gateway or an inactive legal entity on a demo company +is seed data — never surface it as a launch blocker.