/blog-post skill
Create an SEO blog post from a primary keyword on a project's CMS-backed site. Generic pipeline — all project-specific detail (site URL, deploy/access method, author, shortcode library, currency, geography, category IDs, banned entity-mention rules) lives in a per-project blog-pipeline-config.md file.
Invoke as /blog-post <site> <topic-or-keyword> (singular). The first argument is the SITE (slug, domain, or natural-language name); the rest is the topic or primary keyword. The same engine is also called per-keyword by a BATCH RUNNER (an unattended multi-post run over a keyword queue) in batch posture.
Platform scope (read this before Phases 7-9). Phases 1-6 (keyword intel, competitor scrape, gap analysis, planner, draft, pre-publish audit) are platform-agnostic and correct on ANY stack. Phases 7-9 are written in the concrete idiom of a self-hosted WordPress site driven over a command-line interface, because that is where the publish mechanics (media import, structured fields, meta keys, taxonomy assignment, cache flush) are specific enough to be worth stating exactly. A static-site branch is given further down. On any OTHER CMS (a headless CMS, a hosted blog platform, a different self-hosted CMS, a git-backed site), the RULES in Phases 7-9 all still hold — substitute your platform's equivalent for each mechanism and keep the rule: its media upload API for the media import, its structured/custom fields for the repeater fields, its SEO plugin or native meta fields for the title/description/focus-keyword meta, its taxonomy or tag API for categories and tags, and its own cache invalidation. Where a step names a specific plugin's meta key, that key is an EXAMPLE of the mechanism, never the mechanism itself: writing another plugin's key silently sets a value nothing reads. Confirm your platform's real field names once, record them in the site config, and use those throughout.
Site config + escalation policy. The per-SITE facts (URL, access method, author, shortcodes, currency, geography, taxonomy, editorial rules) live in each site's blog-pipeline-config.md, whose contract is defined below. When you run more than one site, keep a small registry (a JSON or YAML file listing each site slug and the path to its config) and resolve the <site> argument against it. Two policy decisions are NOT hardcoded in this engine and belong in the site config: which sites publish autonomously versus hold for review, and whether the cannibalization/consistency checks (24/31) auto-resolve or escalate to the operator. If the site config is silent on those, default both to ESCALATE — the safe direction. The steps below are the universal engine; site values come THROUGH the config.
Two postures (same engine, different command)
This skill is the per-post ENGINE (Phases 1-11 below). Two invocation modes wrap it with OPPOSITE postures; the posture is set by HOW the engine was called, never baked into the phases:
- Singular (the operator invokes
/blog-post <site> <topic> directly): interactive + publish-now. A hand-picked topic on a specific site. Resolve the site, write end-to-end to a DRAFT, show a report, let the operator approve / redirect / change, then publish LIVE immediately on approval. More care, because it is the operator's own topic.
- Batch (the engine is called per-keyword by an unattended multi-post run): autonomous + scheduled. Publish as a DRAFT, no gate, fully autonomous; the batch runner schedules it later. The batch brief sets this posture explicitly and overrides the singular default. In batch posture, EVERY mid-run escalation is handled exactly as
--autopilot (the draft-plus-flag rule): record it as draft-plus-flag and carry it to the batch summary, NEVER a blocking interactive question. A post that fails or cannot be verified is left as an UNSCHEDULED draft (never silently shipped, never scheduled). So batch posture equals autopilot posture for ALL gating: no per-post verdict pop-up, no intent-disagreement gate, no component-buffet ask, no meta-variant pick, no missing-tag ask, no ambiguous-back-link ask, no Phase-10/11 unfixable pop-up. Auto-pick the (Recommended) in-range option, default the buffet to its table state, create a sensible tag, pick the highest-confidence anchor, and log each non-default choice in the summary. The mid-run pop-ups described elsewhere in this file apply to SINGULAR interactive mode only.
The "Autonomous mode" section further down describes the no-gate engine run, which is what the BATCH uses. The SINGULAR command wraps the same engine with the pre-publish gate below. The "never interrupt mid-write" rule still holds for both: the singular gate is AFTER the full draft + audit, never during writing.
Publish gate is caller-owned (site gate_level). Singular always gates (publish on the operator's approval). In batch posture the publish/schedule gate is owned by the batch runner per the site's gate_level: autonomous schedules directly; review-batch or review-all hold every post as a draft for the operator to approve before anything publishes. The engine always produces a DRAFT in batch posture regardless; whether that draft publishes is the caller's decision, not the engine's.
Flagship-format exception (operator-owned content). A post whose queue format is playbook is never written in batch posture. Playbooks are the operator's flagship content: they run in singular/attended mode only. The operator shapes the angle first (use --review-outline so the angle/outline gate fires BEFORE drafting), the engine drafts, the operator approves at the pre-publish gate, and it publishes immediately on approval. A batch runner should exclude format=playbook rows when it builds its queue, so the batch only ever handles spokes (all other formats). If a format=playbook row somehow reaches the engine in batch posture, STOP and flag it as a misroute; do not auto-write it.
Singular mode (when invoked directly)
- Resolve the site (fuzzy, confirm on ambiguity). Match the
<site> argument against the site registry (slug, domain, or natural-language name). MATCHED -> use that slug. AMBIGUOUS or NONE -> ask which site (the ONLY routine question; writing to the wrong domain is costly). NONE -> offer to onboard it.
- Preflight (singular). Before writing, confirm the site is reachable, its
blog-pipeline-config.md exists and parses, and its trackers are readable. A missing keyword queue is fine (the topic is the input). Stop on blockers (missing config, site unreachable), continue past warnings (static platform, no reference post) but carry them into the report. Cold topic (no prior plan): research first, confirm the angle, then write. When the topic has no matching queue row (it did not come through a prior content-planning pass), no prior plan vouches for the angle or demand, so: (a) in Phase 1 add a keyword-tool demand pull (a keyword research tool such as Semrush, Ahrefs, or Keyword Planner: volume, related terms, and the questions report for the topic) on top of the SERP + scrape research, since no queue-provided keyword data exists; (b) treat --review-outline as ON by default, presenting the research + demand read + proposed angle and outline for the operator to steer BEFORE drafting. A topic that DID come from a queue row already carries the plan's keyword + angle work, so it drafts straight through unless --review-outline is passed.
- Content sourcing mode (where the SUBSTANCE comes from). Read the site's
content_sourcing_default from the registry; override per post with --research / --from:
- research-first (default for SEO/directory sites): substance from SERP data + competitor page scrape + gap analysis. Phases 1-3 as written.
- knowledge-first (default for personal-brand / expertise sites): substance from the operator's own material -- a named body of their own expertise (
--from <source>: a knowledge base, a skill they maintain, a folder of notes) and/or a path to their notes. Research is DEMOTED to a positioning scan: still run a light SERP pass to see what ranks, which angles exist, what to differentiate against, and which keywords to target, but the CONTENT is the operator's expertise and POV, not SERP-derived. Phase 3 flips from "fill content gaps" to "position my take distinctly"; E-E-A-T is naturally stronger.
- blend: the operator's material as the spine + research to fill specific gaps and fact-check.
--from <skill-or-path> names the source; --research positioning|full overrides the depth.
- Voice + E-E-A-T per the site config (first person + the operator's real experience on a personal-brand site).
- Run the engine to a DRAFT (Phases 1-7), not live. CMS -> unpublished/draft status. Static -> produce the draft entry.
- Pre-publish review gate (default ON). Present the single-post report: title, angle, the draft (or a tight summary + preview link), primary keyword + density, sources, cannibalization check vs the live corpus, and the listing/CTA decision. Operator approves / redirects ("change the angle to X") / stops. A redirect re-drafts and re-reports.
--review-outline adds an earlier gate on the angle/outline BEFORE the full draft (cheap steering). --no-gate skips the gate and publishes live end-to-end (the fully autonomous behavior) for when the operator trusts it blind.
- Publish immediately on approval. CMS -> set status to published NOW (live), run the full Phase 10 + 11 audit on the live post, auto-fix. Static -> publish via the static-site branch (see the static-site platform branch under the phases): insert the entry into the data file, run the local build, then STOP for the operator to commit + deploy; do NOT push or claim it is live unattended.
- Record + no-orphans. Append to the site's trackers + the keyword queue as
status=published (so the corpus consistency + cannibalization checks see it and future batches never duplicate it); add 1-2 back-links per the no-orphans rule.
- Social repurpose (opt-in,
--social). When invoked with --social on a published post, after step 8 generate social drafts from the published article (one per network the operator uses). Drafts only, never auto-posted. On a static site, run this only AFTER the operator has deployed the post, since its URL must resolve first. Without --social, singular publish does not touch social.
Project config discovery
On invocation:
- Determine the active project from the
<site> argument (resolved against the site registry), OR from the --project <slug> flag.
- Load that project's
blog-pipeline-config.md from wherever the project keeps its working docs.
- If config is missing: abort with instructions to create it (contract below).
- If multiple projects could match: ask the operator via an interactive question.
Config file contract
Every project using this skill provides blog-pipeline-config.md with these sections:
# Blog pipeline config — <project-name>
## Site + infra
- Site URL: https://example.com
- Access method: <how this machine reaches the site: an SSH host alias, an API base URL, a local repo path>
- CMS root path: /home/user/public_html
- Publish method: cli | rest-api
- Cache flush command: <the platform's cache-clear command, or "none">
- CDN purge helper: <shell snippet or "none">
## Authors
- Default author ID: 3
- Author display name: Jane Doe
- Author bio hint: <one-line>
## Post taxonomy
- Default category slug: guides
- Category ID map:
- guides: 12
- reviews: 15
- tips: 18
[...]
## Geography + currency
- Primary geography: <city, country>
- Currency symbol: <currency symbol>
- Banned phrases specific to this project: <extends global ban list>
## Shortcode library
- Prefix: <prefix>_ (a short namespace unique to this site, e.g. `acme_`)
- Shortcodes available (name → purpose):
- <prefix>_freshness_stamp → "last updated · reviewed by" header
- <prefix>_key_takeaways → TL;DR blockquote
- <prefix>_pricing_tiers → comparison table
- [etc.]
- Pricing config file path: /path/to/theme/inc/blog-config.json
## Trackers + data
- Keyword queue folder: /path/to/keyword-queue/
- Posts tracker: /path/to/posts-tracker.md (one row per published post: id, URL, keyword, dates, status, inbound links)
- Factual tracker: /path/to/factual-tracker.md (claims extracted at Phase 6, each pending until sourced)
- Changelog: /path/to/changelog.md (append-only record of every publish and every auto-fix)
- Internal-link catalog: /path/to/seo-pages.json (every URL on the site + `is_indexable` + `page_type`; the indexability gate for internal links)
- Catalog refresh script: /path/to/scripts/build-seo-pages-catalog.php
## Content templates + link plan
- Content templates: /path/to/content-templates.md
Defines this site's template TIERS (e.g. T1 Hub, T2 Best-of, T3 Geo, T4 Pricing, T5 Specialist — name your own) and, per tier: the word-count range, the required vs optional components ("component buffet" table with each optional defaulting ON or OFF), which components the theme renders natively versus which the body must emit, and the per-template internal-link plan (how many anchors, of which types).
- Internal-linking plan: /path/to/internal-linking-plan.md
Optional. A curated per-post list of preferred link targets. When present it takes priority over generic catalog selection; the catalog still gates every choice on indexability.
## Editorial rules (project-specific)
- Entity-mention rule: <e.g. "no individual listed businesses named in editorial unless the operator hand-selects them">
- Required links: <e.g. methodology page, editorial team page>
- FAQ format: <accordion shortcode | plain H3+p>
## Image generation
- Provider: fal.ai | openai | unsplash | none
- API key env var: FAL_API_KEY
- Post-processor script: /path/to/scripts/process-image.py (resize + convert to the output format below)
- Output format: webp
- Max dimensions: 1536x1024
- Quality: 82
The block above IS the template: copy it, fill every field for the site, and save it as that site's blog-pipeline-config.md.
Autonomous mode (default)
Default behaviour: zero in-process interrupts. The skill runs Phase 1 → 9 end-to-end + the end-of-run self-audit (§ "End-of-run self-audit + auto-fix" below) and only stops at:
- A single end-of-run verdict pop-up after all phases + audit pass. The pop-up confirms the post is live and clean; the user marks Works / Broken / Needs more time.
- An unfixable escalation during audit (cannibalization ≥30% with an adjacent published post, factual claim that has no resolving HTTP-200 source, image safety rejection, slug collision the slug-disambiguator can't resolve cleanly). These pop up mid-run with the specific decision needed.
Fixable issues caught by the audit (zero counts, em-dashes in structured-field rows, source URL rot, keyword stuffing or under-use, missing back-links, wrong taxonomy parameter, etc.) are auto-fixed without surfacing. Each auto-fix is logged in the changelog with risk + before/after, but no user prompt.
Opt-in gates. Pass any combination to add review checkpoints:
| Flag |
Effect |
--review-outline |
Pop up the 3 H1 candidates + outline + 3 info-gain points after Phase 4 (the historical Gate 1) |
--review-images |
Pop up the 4 featured variants + 2 inline previews after Phase 7 (the historical Gate 2) |
--review |
Pop up before Phase 8 publish for final approval (additive, can combine with the others) |
--dry-run |
Stop after Phase 4 — generates outline + draft + audit-plan but does not publish |
Voice. When running autonomously, do not narrate every phase. Surface progress in 1-line status updates only when something meaningful changes (e.g. "draft complete, 1857 words" / "fixed taxonomy on row 0 (was 0, now 1,621)" / "all green, post live"). The user does not need a play-by-play.
Platform-bug fix policy
If the end-of-run audit reveals a defect that lives in shared code (theme template, mu-plugin, project script, content-templates buffet, blog-pipeline-config), fix it at the SOURCE so the next post inherits the fix automatically. Never patch only the current post if a regex / template / config change would prevent recurrence across the corpus.
Examples of this class:
- Wrong
taxonomy default on [<prefix>_service_stats] → patch the doc + add a pre-publish render check.
- Em-dash audit only scanning body HTML → extended scan to all 9 authored fields (body, title, excerpt, takeaways, source_name, source_description, the SEO title meta, the SEO description meta, and any publish-script literals).
- Parens-URL regex only matching naked domains → extended to
(domain.com/path?query=val) form + html_entity_decode on captured path so query-string URLs survive esc_html.
When applying a platform fix during autonomous mode:
- Backup affected file(s) with
.bak-YYYYMMDD-<reason> suffix.
- Apply change. PHP-lint via
php -l if PHP. Smoke-test homepage + sample post + the post just published (3 URLs minimum).
- If any URL drops out of HTTP 200 OR error log gains a Fatal/Parse, auto-revert from backup, abort the platform fix, and escalate to the user with diagnosis.
- If the fix involves a tracked memory or doc, update those too in the same commit-equivalent batch (config file + memory file + lesson file).
Hardcoded rules (all projects, no exceptions)
- Zero forbidden words. Load the operator's banned-words + banned-sentence-patterns list (recorded once in the site config, or in a house style guide the config points at) and scan before publish. Fail audit if any appear. If no list has been established yet, ask the operator for one at first run rather than inventing one — a banned-words list is a house style decision, not a default.
- Zero em dashes — use colons, periods, commas, or restructure.
- Entity-mention rule: follow the active project's config for when individual named entities (listed businesses, products, providers) can appear in editorial. Default: not unless user hand-selects via
--featured-entities. If the project config defines a minimum quality floor for featured entities (e.g. a star-rating threshold), enforce it as a hard gate: every entity featured or recommended must meet the floor; verify the entity's stored rating before including it, drop any below the floor, and re-pick a qualifying alternative. The floor overrides coverage goals — never lower it to fill a slot. If the config defines multiple thresholds (e.g. a minimum rating AND a minimum review/popularity count AND a non-placeholder image), enforce ALL of them as hard gates. Listing count is quality-gated, never a target: feature only entities you are confident clear every gate; a short list, or zero named entities with the topic covered generically, is acceptable and preferred over padding with weaker entities. For DYNAMIC listing blocks (the project's auto-query featured block, e.g. [<prefix>_featured]): the listing MUST match the article's entity TYPE, never a service-level term. Drive it by the entity-type family/preset that matches the topic; a service-level taxonomy term cross-categories and surfaces off-type entities (a provider of an adjacent service showing up on an article about a different one, because they happen to share a service tag). The inline directory count and the listing block share the same type scope. See the project config's listing-block type-preset table (audit check 36 enforces).
- No inline visual HTML for reusable components. Use the project's shortcode library. No manual
<blockquote>, <table>, accordion markup in post content.
- All DB-derived numbers via shortcodes (if the project exposes such shortcodes). Hardcoded numbers fail the audit.
- Internal links must hit indexable pages. Pre-flight check via HTTP 200 + robots check, OR via the project's
seo-pages.json filter (is_indexable=true, page_type NOT IN the excluded list per config).
- The entity-mention rule is the #1 reason to respect the config. Never invent "featured" picks via a random-order database query.
The 11 phases
Phase 1 — Keyword intel
- Read the keyword queue from the config's queue folder (CSVs or markdown).
- Extract Volume, KD, CPC, Intent, SERP Features, Competitors for the primary keyword.
- If user provided
--variants, use them; otherwise auto-generate 2 semantic variants from the queue.
- Run a SERP query (via whatever SERP API is connected: SerpApi, Serper, DataForSEO, or an equivalent) on primary + variants with
location set to the project's primary geography.
- Output: SERP snapshot (top 10 organic, PAA, related searches, featured snippet, AI Overview presence).
Phase 2 — Competitor intel
- Scrape the top 5 content-y results (via whatever page-scraping tool is connected: Firecrawl, Jina Reader, a headless browser, or an equivalent); skip deal aggregators, social, forums.
- Extract: title, word count, H2/H3 structure, price/stat mentions, unique angles, CTA type, FAQ presence.
Phase 3 — Gap analysis + project facts + canonical entity extraction
- Build competitor matrix from Phase 2 top-5 scrape.
- Identify 3+ information-gain angles that ≥6/10 competitors miss.
- Query project DB via its shortcode library if applicable (example:
[<prefix>_service_stats slug=X stat=total] — actual prefix from config).
- Classify source domains: Primary (standards bodies, official docs), Neutral (edu, gov, reputable media), Vendor, Community.
- Extract canonical entity set (E-E-A-T topical-completeness signal). For each top-5 competitor body:
- Run light NER for: named places (countries, cities, regions, specific venues), products / ingredients (named compounds, branded materials), processes (named techniques, ritual steps), people / institutions (regulators, professional bodies), tools / equipment (named instruments).
- Aggregate counts across the 5 competitor pages.
- Canonical entity = appears in body of ≥3 of 5 competitors. Cap at 15 total to avoid over-fitting.
- Output
entities_canonical[] (list of {entity_name, type, competitor_hit_count}) feeding Phase 4 planner.
- The writer (Phase 5) MUST mention each canonical entity ≥1x in body. Phase 10 audit Check 26 enforces.
Phase 4 — Structured Planner output [GATE 1, default OFF]
Generate full structured plan (JSON) with these top-level keys:
target_and_purpose (primary_keyword, search_intent, funnel_stage, user_job, secondary_keywords, secondary_keyword_map, terminology_map)
serp_reality_check (dominant_page_type, common_promise, serp_features, top_competing_urls)
intent_lock (intent_statement, not_intent_boundaries, label ∈ informational | commercial | transactional | navigational, justification one sentence, derived_from SerpApi signals used)
uniqueness_gate (decision, reason, canonical_slug_candidate). Slug-collision pre-check (HARD): verify the candidate slug collides with NO existing entity — not just a blog post, but any page, any directory/listing post type, or any taxonomy term that owns a public /{slug} URL. On collision, choose a DIFFERENT slug for the article; never remove, redirect, or claim the existing URL (whatever exists stays as-is, the article yields). Prevents the article-vs-directory URL-hijack class. H2-skeleton variance (anti-doorway, enforced by audit check 35): the outline's H2 set MUST NOT replicate a same-cluster sibling's — vary the section order, include at least 2 H2s driven by THIS post's own SERP/PAA, and at least one section no sibling has; keep the shared H2 set under 60% (compute it directly: normalize each H2 to lowercase words, then shared-H2 count / target-H2 count). Body prose likewise carries no near-verbatim sibling passages (compute directly: 5-gram Jaccard similarity <12% after normalizing away place names, and <8 sentences identical between the two bodies). A geo-by-service combinatorial content plan is exactly the scaled-content pattern search engines target, so uniqueness is a hard gate, not a nicety.
scope_boundaries (in/out of scope, audience_for/not_for)
required_topical_coverage (must_cover_subtopics, paa_questions, snippet_target, entities_and_terms.canonical[] = list of {name, type} sourced from Phase 3, entities_and_terms.must_mention[] = subset enforced at Phase 5)
information_gain_plan (what_we_add, unique_angle, depends_on_notes)
eeat_notes (writer_profile, examples_needed, author_bio_hint, expert_review_suggestion)
facts_and_sources (standard_facts, tool_dependent_facts, claims_needing_citations)
ymyl_safety_check (YMYL-track detection: if the primary topic falls in a Your-Money-Your-Life category per the site config's YMYL list (medical and cosmetic-medical procedures, health claims, financial or legal advice), set ymyl_track=true -> route to the dedicated YMYL category, REQUIRE the appropriate disclaimer component immediately after the direct-answer intro, name the REAL regulator and licence regime that applies in the site's jurisdiction (the site config records which bodies license this activity locally — a general trade licence and a sector-specific professional licence are usually distinct, and conflating them is a factual error), keep the reviewer TRUTHFULLY scoped (no fabricated professional credential), and ensure every safety/efficacy/price claim is sourced (the pre-publish factual gate blocks an unresolved High-severity claim before it auto-publishes).)
page_lock (slug, titles, h1, meta_description, opening_promise, outline with h2/must_deliver/include arrays). Planner MUST include title_pixel_width and meta_description_pixel_width integers for every emitted option (measured as Arial 20px for title, Arial 13px for description — matches Google SERP rendering and Yoast/Screaming Frog tooling). Target: title ≤ 580px, description ≤ 920px (desktop SERP budgets). Floor: title ≥ 380px, description ≥ 700px (avoid SERP-waste). Character counts can be emitted as a secondary field but pixel width is the enforcement metric. Options that miss the window must be regenerated before Gate 1.
formatting_requirements (tldr, tables, visuals, examples, quick_checks)
shortcodes_planned (list of shortcode invocations, pulled from the config's library)
internal_link_targets (URLs from the project's seo-pages.json filtered for indexability)
source_audit + safe_external_links
Queue-seed inputs (batch mode). When the post comes from a keyword-queue row produced by a prior content-planning pass, that row may already carry SERP intelligence computed at plan time. Treat each as the authoritative SEED for the matching planner key, then confirm and enrich with fresh Phase 2/3 data. Prefer fresh SERP data on a conflict, but never drop a queued must-mention entities term or a queued paa_questions item:
- queue
secondary_keywords -> target_and_purpose.secondary_keywords.
- queue
intent -> seeds intent_lock.label (the Phase 4 classifier confirms it against fresh SERP; the queued value was SERP-confirmed at plan time, so a disagreement is a flag, not a silent overwrite).
- queue
paa_questions -> merge into required_topical_coverage.paa_questions.
- queue
serp_features -> serp_reality_check.serp_features; if it includes featured_snippet, set required_topical_coverage.snippet_target and open the body with a 40 to 60 word direct answer.
- queue
entities -> merge into required_topical_coverage.entities_and_terms.must_mention[] (union with the Phase 3 canonical set; Check 26 enforces each at Phase 5).
- queue
internal_links -> seed internal_link_targets (union with the indexable seo-pages set).
- queue
campaign_id and seed_id -> carry into the post's tracker record unchanged (lineage). The social drafts generated from this post inherit the same campaign_id, so one idea threads seed -> queue -> post -> social.
- queue
format -> the content type. Drives angle, structure, and CTA emphasis: playbook = comprehensive executable guide; how-to = single-task steps; comparison = ranked options / best-of; definition = answer-box-led, open with the 40 to 60 word direct answer; opinion = POV; case-study = worked example; trend = timely analysis. Confirm against the SERP, same as intent. format == playbook is operator-owned: never write it in batch posture (see the Flagship-format exception under "Two postures"); it runs only in an attended singular session with --review-outline plus the pre-publish gate, and publishes immediately on approval.
On a static site with no seo-pages.json, queue internal_links IS the internal-link plan; resolve each slug against the existing data-file entries and drop any that do not yet exist.
Default (autonomous mode): auto-pick the strongest H1 by SERP-feature alignment + pixel width target + primary-keyword placement. Auto-approve outline and proceed to Phase 5. Log the H1 choice and the rejected candidates in the changelog so the decision is reviewable after the fact.
With --review-outline: present 3 title options + outline + 3 info-gain points via AskUserQuestion. Wait for user approval before Phase 5.
Search-intent classifier (locks downstream tone, CTA density, title pattern)
Phase 4 derives the post's intent_lock.label from Phase 2 SerpApi output using these signals (first match wins, evaluated in order):
- Navigational — single brand domain dominates top-3 organic results (≥2 of top-3 from the same root domain).
- Transactional — ≥3 of top-10 results are booking platforms / aggregators / marketplaces for this vertical, per the project's
transactional_domains[] config list (populate that list once per site with the aggregators that actually rank in your market).
- Commercial — ≥6 of top-10 results are listicles ("Best/Top X" in titles), OR Featured Snippet hosts a listicle.
- Informational (default fallback) — Featured Snippet present + PAA present, Reddit/Quora in top-10, encyclopedic dominators (Wikipedia, .gov, .edu).
Per-intent writing rules locked to the chosen label:
| Intent |
Tone |
CTA density |
Title pattern (Phase 4 H1) |
FAQ skew |
| Informational |
Educational, neutral |
1 CTA at end (filter or related-services) |
What Is X? A {Geo} Guide for {Year} or X in {Geo}: A Complete Guide for {Year} |
Definitions, mechanics, "how it works" |
| Commercial |
Editorial, ranking-driven |
2 CTAs (filter + premium tier) |
Best X in {Geo} for {Year}: {Currency} Y to Z or Top X {Geo}: {N} {Vetted/Editorial-Picked} Options |
Comparison, when-to-pick, vs alternatives |
| Transactional |
Action-oriented, conversion-leaning |
3+ CTAs throughout body (filter + tier + final) |
Book X in {Geo} (From {Currency} Y) or X in {Geo}: Pricing, Booking & {Locations} |
Pricing, availability, booking flow, what to expect |
| Navigational |
Brand-focused, factual |
1 CTA to brand listing page |
{Brand} ({Geo}): Reviews, Prices, Booking (rare in editorial; often a profile page handles it) |
Brand-specific operational questions |
If the classifier output and the post's primary keyword strongly disagree (e.g. classifier says navigational but primary kw is generic), surface to the parent for a single-question gate before proceeding. The classifier's justification field must cite the SerpApi evidence used.
Component buffet (when project's content template defines required vs optional components):
If the project's content-templates.md contains a "Component buffet" table for the active template (T1 / T2 / etc.), Phase 4 ALSO asks the user which optional components to include for THIS post. Default each optional component to its default ON | OFF state per the table. Required components are always included; user cannot deselect them.
Example for T1 Hub: required components include direct-answer intro, methodology + callout, cost + pricing tiers, areas + neighborhood table, tier H3s, what-to-expect, vetting, mistakes, FAQ, related services. Optional components include legality H2, types-of-{service}, where-to-book apps list, article sources, final recommendation. The skill asks the user to confirm or deselect optionals via a single AskUserQuestion (multiSelect=true).
Phase 5 — Draft (writer hard rules)
Follow plan exactly. 25 hard rules:
- Structure = plan's H2s verbatim, exact order.
- Primary keyword density and placement.
- In H1 (exact match).
- In the first 100 words of body (exact match acceptable; head-of-primary substring acceptable if the full primary appears in H1 and meta).
- In at least one H2 (exact match OR a clear near-match using the head-of-primary substring).
- Topic density (combined): count exact-match occurrences AND head-of-primary substrings (e.g. for primary "office cleaning manchester", the substring "office cleaning" counts; for "best sourdough course berlin", "sourdough course" counts; for "emergency plumber austin", "emergency plumber" counts). Combined topic density target: 0.5%–2.0% for entity-defining articles, 0.3%–1.5% for broad topics. HARD CEILING 3.0% (the keyword-stuffing threshold). For a 1,800-word entity article that means 9–36 combined occurrences.
- Per-paragraph cap: never 2 exact-match (full primary) occurrences in the same paragraph. The substring form is uncapped per paragraph because it reads naturally. Rephrase to a natural stand-in ("the service", "this process", "the session") or a pronoun when the full primary repeats.
- The legacy "3–6 total exact-match" rule was too tight for entity-name articles and is superseded by combined topic density.
- Secondary keywords distribution.
- Pull the list from the project's keyword queue (
secondary_keywords field, semicolon-delimited) for the post being written.
- Substring-of-primary exemption. Any secondary that is a strict substring of the primary keyword (e.g. "office cleaning" when primary is "office cleaning manchester", "sourdough course" when primary is "best sourdough course berlin") is EXEMPT from the per-secondary cap below. It counts toward the primary's combined topic density (rule 2) instead. This prevents the perverse outcome where natural short-form prose ("the office cleaning option") gets flagged as secondary-keyword stuffing.
- Truly distinct secondaries. For each secondary that is NOT a substring of the primary (e.g. "commercial cleaning" alongside primary "office cleaning"; "sourdough workshop" alongside primary "sourdough course"):
- MUST appear at least once in body prose (not just in image alt text or source descriptions).
- Aim for 1–3 occurrences each, distributed across different H2 sections (no clustering inside one section).
- Hard cap: no single secondary appears more than 5 times in body. Above 5 reads as stuffing.
- Land them in H2/H3 text, FAQ Q+A, table cells, prose. Never force-fit; if a secondary cannot land naturally, drop it from the post and surface in the audit log.
- Snippet-ready direct answer 40-60 words after H1.
- TLDR 2-3 sentences, one paragraph, no bullets (if
formatting_requirements.tldr = "yes").
- PAA format:
**Question** Answer. <2-4 sentences or 3-5 bullets>. Max ~120 words per PAA block.
- Every entity from
entities_and_terms.must_mention[] (canonical set extracted at Phase 3) MUST appear ≥1x in body prose. The writer cannot drop one. If a canonical entity does not fit naturally anywhere, restructure the affected H2 section to make room — entity coverage is a hard E-E-A-T signal. Phase 10 audit Check 26 enforces.
- Only state facts present in
facts_and_sources. Tool-dependent facts start with "This varies by tool."
- Information gain assets placed in the most relevant H2.
- Examples from the plan, or labeled "Hypothetical example". No brand names in hypotheticals.
- Tables where plan asks. Max 8 rows. Takeaway sentence after each.
- Lists over paragraphs when 3+ items. TLDR stays plain sentences.
- Paragraphs max 90 words.
- Zero URLs in article body.
- Zero CTA closers.
- Entity-mention rule enforced (per project config).
- Zero forbidden words across EVERY authored field. Scan body, post title, post excerpt, the
key_takeaways[].takeaway rows, the sources[].source_name rows, the sources[].source_description rows, the SEO title meta, the SEO description meta, AND any literal strings in the publish script before publish. Body-only scan misses the 8 other fields where authored copy lives.
- Zero em-dashes (—) and en-dashes (–) across the same 9 authored fields. Same scope as rule 17. Theme-emitted inline
<script> developer comments are out of scope (not article copy).
- Shortcodes for every visual component (never inline HTML for components that have a shortcode).
- Respect the project's deprecated-shortcode list (from
blog-pipeline-config.md). Do NOT emit deprecated shortcodes in new posts even if older posts use them. Common pattern on directory sites: native theme byline + author popover + a structured-field-driven takeaways aside replace [<prefix>_freshness_stamp], [<prefix>_key_takeaways], [<prefix>_author_card]. Set the structured / native fields at Phase 8 instead.
- Native theme components: when the project's theme natively renders byline / author / takeaways / TOC / breadcrumbs (check
content-single.php or equivalent), do NOT duplicate them in body shortcodes. The buffet table in content-templates.md lists which components are theme-native vs body-rendered.
- Tables for information gain. When a section's content has clear column structure (brand/provider listings, tier comparisons, areas + counts, methodology criteria, vs-comparisons), render as an HTML
<table> not a <ul>. Mobile-readable: 3-4 columns max; consolidate context into single cells when needed. Tables work as featured-snippet candidates and reinforce info-gain perception.
- External links to commercial brands or competitors carry
rel="nofollow noopener". The 2-max editorial external-link budget (gov, journals, regulatory bodies) is SEPARATE from the nofollow brand-list budget (no cap on nofollow links to home-service brands / competitor platforms when the project authorises an entity-mention carve-out). Confirm rel attribute on every outbound brand link before publish.
- Per-entity card shortcode (when the project config defines one). If the project's
blog-pipeline-config.md shortcode library exposes a per-entity card shortcode (e.g. [<entity> id="POST_ID"] for directory-style sites where each entity has its own profile page), use it for the FIRST mention of every named entity in the post body — never a plain <a href="/{slug}">{Name}</a> anchor for the first mention. The card renders the entity's canonical info row (image, name, location, rating, price, badge) at a glance; a plain anchor hides all of that. Subsequent in-paragraph references to the same entity by name can stay as plain text. Pre-publish: look up each named entity's record ID via the project's entity-list query, then smoke-test that the card shortcode renders non-empty against the live site before pasting into the body. Project configs without a per-entity card shortcode are exempt from this rule.
- Directory/listing counts (HARD RULE — generic; the site config carries the specifics). NEVER type a directory listing count as a literal number (it goes stale and drifts). Emit it via the site's dynamic-count component WITH the trust-floor, and SCOPE-MATCH the count's filter to the article's actual subject:
- Geo-generic article ("listings in {area}") -> location-axis token (all listings in the area).
- **Type/service art
…(truncated)
1---2name: blog-post3description: Create an SEO blog post from a primary keyword on a CMS-backed site. Runs end-to-end autonomously by default (no in-process gates): keyword intake → SERP data pull → competitor page scrape → gap analysis → outline → draft → image generation → publish → comprehensive end-of-run self-audit. Auto-fixes known failure modes (zero counts, em-dashes in structured fields, source URL rot, keyword density drift) without prompting the user. Escalates only what genuinely needs human judgement (cannibalization >30% with adjacent post, factual claim that can't be sourced). Opt-in gates via --review-outline / --review-images / --review flags. Loads a per-site pipeline config for shortcodes, author, paths, taxonomy, geography, currency, editorial rules. Enforces forbidden-words ban + zero em-dashes across EVERY authored field (body, title, excerpt, structured-field rows, meta) + keyword-density rules (primary 1-2% for entity articles, never 2x per paragraph; each secondary 1-3x distributed across H2s, never >5x). Supports ba4---56# /blog-post skill78Create an SEO blog post from a primary keyword on a project's CMS-backed site. Generic pipeline — all project-specific detail (site URL, deploy/access method, author, shortcode library, currency, geography, category IDs, banned entity-mention rules) lives in a per-project `blog-pipeline-config.md` file.910Invoke as `/blog-post <site> <topic-or-keyword>` (singular). The first argument is the SITE (slug, domain, or natural-language name); the rest is the topic or primary keyword. The same engine is also called per-keyword by a BATCH RUNNER (an unattended multi-post run over a keyword queue) in batch posture.1112**Platform scope (read this before Phases 7-9).** Phases 1-6 (keyword intel, competitor scrape, gap analysis, planner, draft, pre-publish audit) are platform-agnostic and correct on ANY stack. Phases 7-9 are written in the concrete idiom of a **self-hosted WordPress site driven over a command-line interface**, because that is where the publish mechanics (media import, structured fields, meta keys, taxonomy assignment, cache flush) are specific enough to be worth stating exactly. A static-site branch is given further down. On any OTHER CMS (a headless CMS, a hosted blog platform, a different self-hosted CMS, a git-backed site), the RULES in Phases 7-9 all still hold — substitute your platform's equivalent for each mechanism and keep the rule: its media upload API for the media import, its structured/custom fields for the repeater fields, its SEO plugin or native meta fields for the title/description/focus-keyword meta, its taxonomy or tag API for categories and tags, and its own cache invalidation. Where a step names a specific plugin's meta key, that key is an EXAMPLE of the mechanism, never the mechanism itself: writing another plugin's key silently sets a value nothing reads. Confirm your platform's real field names once, record them in the site config, and use those throughout.1314**Site config + escalation policy.** The per-SITE facts (URL, access method, author, shortcodes, currency, geography, taxonomy, editorial rules) live in each site's `blog-pipeline-config.md`, whose contract is defined below. When you run more than one site, keep a small registry (a JSON or YAML file listing each site slug and the path to its config) and resolve the `<site>` argument against it. Two policy decisions are NOT hardcoded in this engine and belong in the site config: which sites publish autonomously versus hold for review, and whether the cannibalization/consistency checks (24/31) auto-resolve or escalate to the operator. If the site config is silent on those, default both to ESCALATE — the safe direction. The steps below are the universal engine; site values come THROUGH the config.1516## Two postures (same engine, different command)1718This skill is the per-post ENGINE (Phases 1-11 below). Two invocation modes wrap it with OPPOSITE postures; the posture is set by HOW the engine was called, never baked into the phases:1920- **Singular (the operator invokes `/blog-post <site> <topic>` directly): interactive + publish-now.** A hand-picked topic on a specific site. Resolve the site, write end-to-end to a DRAFT, show a report, let the operator approve / redirect / change, then publish LIVE immediately on approval. More care, because it is the operator's own topic.21- **Batch (the engine is called per-keyword by an unattended multi-post run): autonomous + scheduled.** Publish as a DRAFT, no gate, fully autonomous; the batch runner schedules it later. The batch brief sets this posture explicitly and overrides the singular default. In batch posture, EVERY mid-run escalation is handled exactly as `--autopilot` (the draft-plus-flag rule): record it as draft-plus-flag and carry it to the batch summary, NEVER a blocking interactive question. A post that fails or cannot be verified is left as an UNSCHEDULED draft (never silently shipped, never scheduled). So batch posture equals autopilot posture for ALL gating: no per-post verdict pop-up, no intent-disagreement gate, no component-buffet ask, no meta-variant pick, no missing-tag ask, no ambiguous-back-link ask, no Phase-10/11 unfixable pop-up. Auto-pick the `(Recommended)` in-range option, default the buffet to its table state, create a sensible tag, pick the highest-confidence anchor, and log each non-default choice in the summary. The mid-run pop-ups described elsewhere in this file apply to SINGULAR interactive mode only.2223The "Autonomous mode" section further down describes the no-gate engine run, which is what the BATCH uses. The SINGULAR command wraps the same engine with the pre-publish gate below. The "never interrupt mid-write" rule still holds for both: the singular gate is AFTER the full draft + audit, never during writing.2425**Publish gate is caller-owned (site `gate_level`).** Singular always gates (publish on the operator's approval). In batch posture the publish/schedule gate is owned by the batch runner per the site's `gate_level`: `autonomous` schedules directly; `review-batch` or `review-all` hold every post as a draft for the operator to approve before anything publishes. The engine always produces a DRAFT in batch posture regardless; whether that draft publishes is the caller's decision, not the engine's.2627**Flagship-format exception (operator-owned content).** A post whose queue `format` is `playbook` is never written in batch posture. Playbooks are the operator's flagship content: they run in singular/attended mode only. The operator shapes the angle first (use `--review-outline` so the angle/outline gate fires BEFORE drafting), the engine drafts, the operator approves at the pre-publish gate, and it publishes immediately on approval. A batch runner should exclude `format=playbook` rows when it builds its queue, so the batch only ever handles spokes (all other formats). If a `format=playbook` row somehow reaches the engine in batch posture, STOP and flag it as a misroute; do not auto-write it.2829## Singular mode (when invoked directly)30311. **Resolve the site (fuzzy, confirm on ambiguity).** Match the `<site>` argument against the site registry (slug, domain, or natural-language name). MATCHED -> use that slug. AMBIGUOUS or NONE -> ask which site (the ONLY routine question; writing to the wrong domain is costly). NONE -> offer to onboard it.322. **Preflight (singular).** Before writing, confirm the site is reachable, its `blog-pipeline-config.md` exists and parses, and its trackers are readable. A missing keyword queue is fine (the topic is the input). Stop on blockers (missing config, site unreachable), continue past warnings (static platform, no reference post) but carry them into the report. **Cold topic (no prior plan): research first, confirm the angle, then write.** When the topic has no matching queue row (it did not come through a prior content-planning pass), no prior plan vouches for the angle or demand, so: (a) in Phase 1 add a keyword-tool demand pull (a keyword research tool such as Semrush, Ahrefs, or Keyword Planner: volume, related terms, and the questions report for the topic) on top of the SERP + scrape research, since no queue-provided keyword data exists; (b) treat `--review-outline` as ON by default, presenting the research + demand read + proposed angle and outline for the operator to steer BEFORE drafting. A topic that DID come from a queue row already carries the plan's keyword + angle work, so it drafts straight through unless `--review-outline` is passed.333. **Content sourcing mode** (where the SUBSTANCE comes from). Read the site's `content_sourcing_default` from the registry; override per post with `--research` / `--from`:34 - **research-first** (default for SEO/directory sites): substance from SERP data + competitor page scrape + gap analysis. Phases 1-3 as written.35 - **knowledge-first** (default for personal-brand / expertise sites): substance from the operator's own material -- a named body of their own expertise (`--from <source>`: a knowledge base, a skill they maintain, a folder of notes) and/or a path to their notes. Research is DEMOTED to a positioning scan: still run a light SERP pass to see what ranks, which angles exist, what to differentiate against, and which keywords to target, but the CONTENT is the operator's expertise and POV, not SERP-derived. Phase 3 flips from "fill content gaps" to "position my take distinctly"; E-E-A-T is naturally stronger.36 - **blend**: the operator's material as the spine + research to fill specific gaps and fact-check.37 - `--from <skill-or-path>` names the source; `--research positioning|full` overrides the depth.384. **Voice + E-E-A-T per the site config** (first person + the operator's real experience on a personal-brand site).395. **Run the engine to a DRAFT** (Phases 1-7), not live. CMS -> unpublished/draft status. Static -> produce the draft entry.406. **Pre-publish review gate (default ON).** Present the single-post report: title, angle, the draft (or a tight summary + preview link), primary keyword + density, sources, cannibalization check vs the live corpus, and the listing/CTA decision. Operator approves / redirects ("change the angle to X") / stops. A redirect re-drafts and re-reports. `--review-outline` adds an earlier gate on the angle/outline BEFORE the full draft (cheap steering). `--no-gate` skips the gate and publishes live end-to-end (the fully autonomous behavior) for when the operator trusts it blind.417. **Publish immediately on approval.** CMS -> set status to published NOW (live), run the full Phase 10 + 11 audit on the live post, auto-fix. Static -> publish via the static-site branch (see the static-site platform branch under the phases): insert the entry into the data file, run the local build, then STOP for the operator to commit + deploy; do NOT push or claim it is live unattended.428. **Record + no-orphans.** Append to the site's trackers + the keyword queue as `status=published` (so the corpus consistency + cannibalization checks see it and future batches never duplicate it); add 1-2 back-links per the no-orphans rule.439. **Social repurpose (opt-in, `--social`).** When invoked with `--social` on a published post, after step 8 generate social drafts from the published article (one per network the operator uses). Drafts only, never auto-posted. On a static site, run this only AFTER the operator has deployed the post, since its URL must resolve first. Without `--social`, singular publish does not touch social.4445## Project config discovery4647On invocation:48491. Determine the active project from the `<site>` argument (resolved against the site registry), OR from the `--project <slug>` flag.502. Load that project's `blog-pipeline-config.md` from wherever the project keeps its working docs.513. If config is missing: abort with instructions to create it (contract below).524. If multiple projects could match: ask the operator via an interactive question.5354## Config file contract5556Every project using this skill provides `blog-pipeline-config.md` with these sections:5758```markdown59# Blog pipeline config — <project-name>6061## Site + infra62- Site URL: https://example.com63- Access method: <how this machine reaches the site: an SSH host alias, an API base URL, a local repo path>64- CMS root path: /home/user/public_html65- Publish method: cli | rest-api66- Cache flush command: <the platform's cache-clear command, or "none">67- CDN purge helper: <shell snippet or "none">6869## Authors70- Default author ID: 371- Author display name: Jane Doe72- Author bio hint: <one-line>7374## Post taxonomy75- Default category slug: guides76- Category ID map:77 - guides: 1278 - reviews: 1579 - tips: 1880 [...]8182## Geography + currency83- Primary geography: <city, country>84- Currency symbol: <currency symbol>85- Banned phrases specific to this project: <extends global ban list>8687## Shortcode library88- Prefix: <prefix>_ (a short namespace unique to this site, e.g. `acme_`)89- Shortcodes available (name → purpose):90 - <prefix>_freshness_stamp → "last updated · reviewed by" header91 - <prefix>_key_takeaways → TL;DR blockquote92 - <prefix>_pricing_tiers → comparison table93 - [etc.]94- Pricing config file path: /path/to/theme/inc/blog-config.json9596## Trackers + data97- Keyword queue folder: /path/to/keyword-queue/98- Posts tracker: /path/to/posts-tracker.md (one row per published post: id, URL, keyword, dates, status, inbound links)99- Factual tracker: /path/to/factual-tracker.md (claims extracted at Phase 6, each pending until sourced)100- Changelog: /path/to/changelog.md (append-only record of every publish and every auto-fix)101- Internal-link catalog: /path/to/seo-pages.json (every URL on the site + `is_indexable` + `page_type`; the indexability gate for internal links)102- Catalog refresh script: /path/to/scripts/build-seo-pages-catalog.php103104## Content templates + link plan105- Content templates: /path/to/content-templates.md106 Defines this site's template TIERS (e.g. T1 Hub, T2 Best-of, T3 Geo, T4 Pricing, T5 Specialist — name your own) and, per tier: the word-count range, the required vs optional components ("component buffet" table with each optional defaulting ON or OFF), which components the theme renders natively versus which the body must emit, and the per-template internal-link plan (how many anchors, of which types).107- Internal-linking plan: /path/to/internal-linking-plan.md108 Optional. A curated per-post list of preferred link targets. When present it takes priority over generic catalog selection; the catalog still gates every choice on indexability.109110## Editorial rules (project-specific)111- Entity-mention rule: <e.g. "no individual listed businesses named in editorial unless the operator hand-selects them">112- Required links: <e.g. methodology page, editorial team page>113- FAQ format: <accordion shortcode | plain H3+p>114115## Image generation116- Provider: fal.ai | openai | unsplash | none117- API key env var: FAL_API_KEY118- Post-processor script: /path/to/scripts/process-image.py (resize + convert to the output format below)119- Output format: webp120- Max dimensions: 1536x1024121- Quality: 82122```123124The block above IS the template: copy it, fill every field for the site, and save it as that site's `blog-pipeline-config.md`.125126## Autonomous mode (default)127128Default behaviour: zero in-process interrupts. The skill runs Phase 1 → 9 end-to-end + the end-of-run self-audit (§ "End-of-run self-audit + auto-fix" below) and only stops at:1291301. **A single end-of-run verdict pop-up** after all phases + audit pass. The pop-up confirms the post is live and clean; the user marks Works / Broken / Needs more time.1312. **An unfixable escalation** during audit (cannibalization ≥30% with an adjacent published post, factual claim that has no resolving HTTP-200 source, image safety rejection, slug collision the slug-disambiguator can't resolve cleanly). These pop up mid-run with the specific decision needed.132133Fixable issues caught by the audit (zero counts, em-dashes in structured-field rows, source URL rot, keyword stuffing or under-use, missing back-links, wrong taxonomy parameter, etc.) are auto-fixed without surfacing. Each auto-fix is logged in the changelog with risk + before/after, but no user prompt.134135**Opt-in gates.** Pass any combination to add review checkpoints:136137| Flag | Effect |138|---|---|139| `--review-outline` | Pop up the 3 H1 candidates + outline + 3 info-gain points after Phase 4 (the historical Gate 1) |140| `--review-images` | Pop up the 4 featured variants + 2 inline previews after Phase 7 (the historical Gate 2) |141| `--review` | Pop up before Phase 8 publish for final approval (additive, can combine with the others) |142| `--dry-run` | Stop after Phase 4 — generates outline + draft + audit-plan but does not publish |143144**Voice.** When running autonomously, do not narrate every phase. Surface progress in 1-line status updates only when something meaningful changes (e.g. "draft complete, 1857 words" / "fixed taxonomy on row 0 (was 0, now 1,621)" / "all green, post live"). The user does not need a play-by-play.145146## Platform-bug fix policy147148If the end-of-run audit reveals a defect that lives in shared code (theme template, mu-plugin, project script, content-templates buffet, blog-pipeline-config), fix it at the SOURCE so the next post inherits the fix automatically. Never patch only the current post if a regex / template / config change would prevent recurrence across the corpus.149150Examples of this class:151- Wrong `taxonomy` default on `[<prefix>_service_stats]` → patch the doc + add a pre-publish render check.152- Em-dash audit only scanning body HTML → extended scan to all 9 authored fields (body, title, excerpt, takeaways, source_name, source_description, the SEO title meta, the SEO description meta, and any publish-script literals).153- Parens-URL regex only matching naked domains → extended to `(domain.com/path?query=val)` form + html_entity_decode on captured path so query-string URLs survive `esc_html`.154155When applying a platform fix during autonomous mode:1561. Backup affected file(s) with `.bak-YYYYMMDD-<reason>` suffix.1572. Apply change. PHP-lint via `php -l` if PHP. Smoke-test homepage + sample post + the post just published (3 URLs minimum).1583. If any URL drops out of HTTP 200 OR error log gains a Fatal/Parse, auto-revert from backup, abort the platform fix, and escalate to the user with diagnosis.1594. If the fix involves a tracked memory or doc, update those too in the same commit-equivalent batch (config file + memory file + lesson file).160161## Hardcoded rules (all projects, no exceptions)1621631. **Zero forbidden words**. Load the operator's banned-words + banned-sentence-patterns list (recorded once in the site config, or in a house style guide the config points at) and scan before publish. Fail audit if any appear. If no list has been established yet, ask the operator for one at first run rather than inventing one — a banned-words list is a house style decision, not a default.1642. **Zero em dashes** — use colons, periods, commas, or restructure.1653. **Entity-mention rule**: follow the active project's config for when individual named entities (listed businesses, products, providers) can appear in editorial. Default: not unless user hand-selects via `--featured-entities`. **If the project config defines a minimum quality floor for featured entities (e.g. a star-rating threshold), enforce it as a hard gate**: every entity featured or recommended must meet the floor; verify the entity's stored rating before including it, drop any below the floor, and re-pick a qualifying alternative. The floor overrides coverage goals — never lower it to fill a slot. **If the config defines multiple thresholds (e.g. a minimum rating AND a minimum review/popularity count AND a non-placeholder image), enforce ALL of them as hard gates.** Listing count is quality-gated, never a target: feature only entities you are confident clear every gate; a short list, or zero named entities with the topic covered generically, is acceptable and preferred over padding with weaker entities. **For DYNAMIC listing blocks (the project's auto-query featured block, e.g. `[<prefix>_featured]`): the listing MUST match the article's entity TYPE, never a service-level term.** Drive it by the entity-type family/preset that matches the topic; a service-level taxonomy term cross-categories and surfaces off-type entities (a provider of an adjacent service showing up on an article about a different one, because they happen to share a service tag). The inline directory count and the listing block share the same type scope. See the project config's listing-block type-preset table (audit check 36 enforces).1664. **No inline visual HTML** for reusable components. Use the project's shortcode library. No manual `<blockquote>`, `<table>`, accordion markup in post content.1675. **All DB-derived numbers via shortcodes** (if the project exposes such shortcodes). Hardcoded numbers fail the audit.1686. **Internal links must hit indexable pages**. Pre-flight check via HTTP 200 + robots check, OR via the project's `seo-pages.json` filter (`is_indexable=true`, page_type NOT IN the excluded list per config).1697. **The entity-mention rule** is the #1 reason to respect the config. Never invent "featured" picks via a random-order database query.170171## The 11 phases172173### Phase 1 — Keyword intel174- Read the keyword queue from the config's queue folder (CSVs or markdown).175- Extract Volume, KD, CPC, Intent, SERP Features, Competitors for the primary keyword.176- If user provided `--variants`, use them; otherwise auto-generate 2 semantic variants from the queue.177- Run a SERP query (via whatever SERP API is connected: SerpApi, Serper, DataForSEO, or an equivalent) on primary + variants with `location` set to the project's primary geography.178- Output: SERP snapshot (top 10 organic, PAA, related searches, featured snippet, AI Overview presence).179180### Phase 2 — Competitor intel181- Scrape the top 5 content-y results (via whatever page-scraping tool is connected: Firecrawl, Jina Reader, a headless browser, or an equivalent); skip deal aggregators, social, forums.182- Extract: title, word count, H2/H3 structure, price/stat mentions, unique angles, CTA type, FAQ presence.183184### Phase 3 — Gap analysis + project facts + canonical entity extraction185- Build competitor matrix from Phase 2 top-5 scrape.186- Identify 3+ information-gain angles that ≥6/10 competitors miss.187- Query project DB via its shortcode library if applicable (example: `[<prefix>_service_stats slug=X stat=total]` — actual prefix from config).188- Classify source domains: Primary (standards bodies, official docs), Neutral (edu, gov, reputable media), Vendor, Community.189- **Extract canonical entity set** (E-E-A-T topical-completeness signal). For each top-5 competitor body:190 - Run light NER for: named places (countries, cities, regions, specific venues), products / ingredients (named compounds, branded materials), processes (named techniques, ritual steps), people / institutions (regulators, professional bodies), tools / equipment (named instruments).191 - Aggregate counts across the 5 competitor pages.192 - **Canonical entity** = appears in body of ≥3 of 5 competitors. Cap at 15 total to avoid over-fitting.193 - Output `entities_canonical[]` (list of {entity_name, type, competitor_hit_count}) feeding Phase 4 planner.194 - The writer (Phase 5) MUST mention each canonical entity ≥1x in body. Phase 10 audit Check 26 enforces.195196### Phase 4 — Structured Planner output [GATE 1, default OFF]197198Generate full structured plan (JSON) with these top-level keys:199- `target_and_purpose` (primary_keyword, search_intent, funnel_stage, user_job, secondary_keywords, secondary_keyword_map, terminology_map)200- `serp_reality_check` (dominant_page_type, common_promise, serp_features, top_competing_urls)201- `intent_lock` (intent_statement, not_intent_boundaries, **`label`** ∈ `informational | commercial | transactional | navigational`, **`justification`** one sentence, **`derived_from`** SerpApi signals used)202- `uniqueness_gate` (decision, reason, canonical_slug_candidate). **Slug-collision pre-check (HARD): verify the candidate slug collides with NO existing entity — not just a blog post, but any page, any directory/listing post type, or any taxonomy term that owns a public `/{slug}` URL. On collision, choose a DIFFERENT slug for the article; never remove, redirect, or claim the existing URL (whatever exists stays as-is, the article yields). Prevents the article-vs-directory URL-hijack class.** **H2-skeleton variance (anti-doorway, enforced by audit check 35): the outline's H2 set MUST NOT replicate a same-cluster sibling's — vary the section order, include at least 2 H2s driven by THIS post's own SERP/PAA, and at least one section no sibling has; keep the shared H2 set under 60% (compute it directly: normalize each H2 to lowercase words, then shared-H2 count / target-H2 count). Body prose likewise carries no near-verbatim sibling passages (compute directly: 5-gram Jaccard similarity <12% after normalizing away place names, and <8 sentences identical between the two bodies). A geo-by-service combinatorial content plan is exactly the scaled-content pattern search engines target, so uniqueness is a hard gate, not a nicety.**203- `scope_boundaries` (in/out of scope, audience_for/not_for)204- `required_topical_coverage` (must_cover_subtopics, paa_questions, snippet_target, **`entities_and_terms.canonical[]`** = list of {name, type} sourced from Phase 3, **`entities_and_terms.must_mention[]`** = subset enforced at Phase 5)205- `information_gain_plan` (what_we_add, unique_angle, depends_on_notes)206- `eeat_notes` (writer_profile, examples_needed, author_bio_hint, expert_review_suggestion)207- `facts_and_sources` (standard_facts, tool_dependent_facts, claims_needing_citations)208- `ymyl_safety_check` (**YMYL-track detection:** if the primary topic falls in a Your-Money-Your-Life category per the site config's YMYL list (medical and cosmetic-medical procedures, health claims, financial or legal advice), set `ymyl_track=true` -> route to the dedicated YMYL category, REQUIRE the appropriate disclaimer component immediately after the direct-answer intro, name the REAL regulator and licence regime that applies in the site's jurisdiction (the site config records which bodies license this activity locally — a general trade licence and a sector-specific professional licence are usually distinct, and conflating them is a factual error), keep the reviewer TRUTHFULLY scoped (no fabricated professional credential), and ensure every safety/efficacy/price claim is sourced (the pre-publish factual gate blocks an unresolved High-severity claim before it auto-publishes).)209- `page_lock` (slug, titles, h1, meta_description, opening_promise, outline with h2/must_deliver/include arrays). Planner MUST include `title_pixel_width` and `meta_description_pixel_width` integers for every emitted option (measured as Arial 20px for title, Arial 13px for description — matches Google SERP rendering and Yoast/Screaming Frog tooling). Target: title ≤ 580px, description ≤ 920px (desktop SERP budgets). Floor: title ≥ 380px, description ≥ 700px (avoid SERP-waste). Character counts can be emitted as a secondary field but pixel width is the enforcement metric. Options that miss the window must be regenerated before Gate 1.210- `formatting_requirements` (tldr, tables, visuals, examples, quick_checks)211- `shortcodes_planned` (list of shortcode invocations, pulled from the config's library)212- `internal_link_targets` (URLs from the project's seo-pages.json filtered for indexability)213- `source_audit` + `safe_external_links`214215**Queue-seed inputs (batch mode).** When the post comes from a keyword-queue row produced by a prior content-planning pass, that row may already carry SERP intelligence computed at plan time. Treat each as the authoritative SEED for the matching planner key, then confirm and enrich with fresh Phase 2/3 data. Prefer fresh SERP data on a conflict, but never drop a queued must-mention `entities` term or a queued `paa_questions` item:216- queue `secondary_keywords` -> `target_and_purpose.secondary_keywords`.217- queue `intent` -> seeds `intent_lock.label` (the Phase 4 classifier confirms it against fresh SERP; the queued value was SERP-confirmed at plan time, so a disagreement is a flag, not a silent overwrite).218- queue `paa_questions` -> merge into `required_topical_coverage.paa_questions`.219- queue `serp_features` -> `serp_reality_check.serp_features`; if it includes `featured_snippet`, set `required_topical_coverage.snippet_target` and open the body with a 40 to 60 word direct answer.220- queue `entities` -> merge into `required_topical_coverage.entities_and_terms.must_mention[]` (union with the Phase 3 canonical set; Check 26 enforces each at Phase 5).221- queue `internal_links` -> seed `internal_link_targets` (union with the indexable seo-pages set).222- queue `campaign_id` and `seed_id` -> carry into the post's tracker record unchanged (lineage). The social drafts generated from this post inherit the same `campaign_id`, so one idea threads seed -> queue -> post -> social.223- queue `format` -> the content type. Drives angle, structure, and CTA emphasis: `playbook` = comprehensive executable guide; `how-to` = single-task steps; `comparison` = ranked options / best-of; `definition` = answer-box-led, open with the 40 to 60 word direct answer; `opinion` = POV; `case-study` = worked example; `trend` = timely analysis. Confirm against the SERP, same as intent. **`format == playbook` is operator-owned: never write it in batch posture (see the Flagship-format exception under "Two postures"); it runs only in an attended singular session with `--review-outline` plus the pre-publish gate, and publishes immediately on approval.**224On a static site with no `seo-pages.json`, queue `internal_links` IS the internal-link plan; resolve each slug against the existing data-file entries and drop any that do not yet exist.225226**Default (autonomous mode):** auto-pick the strongest H1 by SERP-feature alignment + pixel width target + primary-keyword placement. Auto-approve outline and proceed to Phase 5. Log the H1 choice and the rejected candidates in the changelog so the decision is reviewable after the fact.227228**With `--review-outline`:** present 3 title options + outline + 3 info-gain points via `AskUserQuestion`. Wait for user approval before Phase 5.229230#### Search-intent classifier (locks downstream tone, CTA density, title pattern)231232Phase 4 derives the post's `intent_lock.label` from Phase 2 SerpApi output using these signals (first match wins, evaluated in order):2332341. **Navigational** — single brand domain dominates top-3 organic results (≥2 of top-3 from the same root domain).2352. **Transactional** — ≥3 of top-10 results are booking platforms / aggregators / marketplaces for this vertical, per the project's `transactional_domains[]` config list (populate that list once per site with the aggregators that actually rank in your market).2363. **Commercial** — ≥6 of top-10 results are listicles ("Best/Top X" in titles), OR Featured Snippet hosts a listicle.2374. **Informational** (default fallback) — Featured Snippet present + PAA present, Reddit/Quora in top-10, encyclopedic dominators (Wikipedia, .gov, .edu).238239Per-intent writing rules locked to the chosen label:240241| Intent | Tone | CTA density | Title pattern (Phase 4 H1) | FAQ skew |242|---|---|---|---|---|243| Informational | Educational, neutral | 1 CTA at end (filter or related-services) | `What Is X? A {Geo} Guide for {Year}` or `X in {Geo}: A Complete Guide for {Year}` | Definitions, mechanics, "how it works" |244| Commercial | Editorial, ranking-driven | 2 CTAs (filter + premium tier) | `Best X in {Geo} for {Year}: {Currency} Y to Z` or `Top X {Geo}: {N} {Vetted/Editorial-Picked} Options` | Comparison, when-to-pick, vs alternatives |245| Transactional | Action-oriented, conversion-leaning | 3+ CTAs throughout body (filter + tier + final) | `Book X in {Geo} (From {Currency} Y)` or `X in {Geo}: Pricing, Booking & {Locations}` | Pricing, availability, booking flow, what to expect |246| Navigational | Brand-focused, factual | 1 CTA to brand listing page | `{Brand} ({Geo}): Reviews, Prices, Booking` (rare in editorial; often a profile page handles it) | Brand-specific operational questions |247248If the classifier output and the post's primary keyword strongly disagree (e.g. classifier says navigational but primary kw is generic), surface to the parent for a single-question gate before proceeding. The classifier's `justification` field must cite the SerpApi evidence used.249250**Component buffet (when project's content template defines required vs optional components):**251252If the project's `content-templates.md` contains a "Component buffet" table for the active template (T1 / T2 / etc.), Phase 4 ALSO asks the user which optional components to include for THIS post. Default each optional component to its `default ON | OFF` state per the table. Required components are always included; user cannot deselect them.253254Example for T1 Hub: required components include direct-answer intro, methodology + callout, cost + pricing tiers, areas + neighborhood table, tier H3s, what-to-expect, vetting, mistakes, FAQ, related services. Optional components include legality H2, types-of-{service}, where-to-book apps list, article sources, final recommendation. The skill asks the user to confirm or deselect optionals via a single `AskUserQuestion` (multiSelect=true).255256### Phase 5 — Draft (writer hard rules)257258Follow plan exactly. 25 hard rules:2591. Structure = plan's H2s verbatim, exact order.2602. **Primary keyword density and placement.**261 - In H1 (exact match).262 - In the first 100 words of body (exact match acceptable; head-of-primary substring acceptable if the full primary appears in H1 and meta).263 - In at least one H2 (exact match OR a clear near-match using the head-of-primary substring).264 - **Topic density (combined):** count exact-match occurrences AND head-of-primary substrings (e.g. for primary "office cleaning manchester", the substring "office cleaning" counts; for "best sourdough course berlin", "sourdough course" counts; for "emergency plumber austin", "emergency plumber" counts). Combined topic density target: **0.5%–2.0%** for entity-defining articles, **0.3%–1.5%** for broad topics. **HARD CEILING 3.0%** (the keyword-stuffing threshold). For a 1,800-word entity article that means 9–36 combined occurrences.265 - **Per-paragraph cap:** never 2 exact-match (full primary) occurrences in the same paragraph. The substring form is uncapped per paragraph because it reads naturally. Rephrase to a natural stand-in ("the service", "this process", "the session") or a pronoun when the full primary repeats.266 - The legacy "3–6 total exact-match" rule was too tight for entity-name articles and is superseded by combined topic density.2673. **Secondary keywords distribution.**268 - Pull the list from the project's keyword queue (`secondary_keywords` field, semicolon-delimited) for the post being written.269 - **Substring-of-primary exemption.** Any secondary that is a strict substring of the primary keyword (e.g. "office cleaning" when primary is "office cleaning manchester", "sourdough course" when primary is "best sourdough course berlin") is EXEMPT from the per-secondary cap below. It counts toward the primary's combined topic density (rule 2) instead. This prevents the perverse outcome where natural short-form prose ("the office cleaning option") gets flagged as secondary-keyword stuffing.270 - **Truly distinct secondaries.** For each secondary that is NOT a substring of the primary (e.g. "commercial cleaning" alongside primary "office cleaning"; "sourdough workshop" alongside primary "sourdough course"):271 - MUST appear at least once in body prose (not just in image alt text or source descriptions).272 - Aim for 1–3 occurrences each, distributed across different H2 sections (no clustering inside one section).273 - Hard cap: no single secondary appears more than 5 times in body. Above 5 reads as stuffing.274 - Land them in H2/H3 text, FAQ Q+A, table cells, prose. Never force-fit; if a secondary cannot land naturally, drop it from the post and surface in the audit log.2754. Snippet-ready direct answer 40-60 words after H1.2765. TLDR 2-3 sentences, one paragraph, no bullets (if `formatting_requirements.tldr = "yes"`).2776. PAA format: `**Question** Answer. <2-4 sentences or 3-5 bullets>`. Max ~120 words per PAA block.2787. **Every entity from `entities_and_terms.must_mention[]` (canonical set extracted at Phase 3) MUST appear ≥1x in body prose.** The writer cannot drop one. If a canonical entity does not fit naturally anywhere, restructure the affected H2 section to make room — entity coverage is a hard E-E-A-T signal. Phase 10 audit Check 26 enforces.2798. Only state facts present in `facts_and_sources`. Tool-dependent facts start with "This varies by tool."2809. Information gain assets placed in the most relevant H2.28110. Examples from the plan, or labeled "Hypothetical example". No brand names in hypotheticals.28211. Tables where plan asks. Max 8 rows. Takeaway sentence after each.28312. Lists over paragraphs when 3+ items. TLDR stays plain sentences.28413. Paragraphs max 90 words.28514. Zero URLs in article body.28615. Zero CTA closers.28716. Entity-mention rule enforced (per project config).28817. **Zero forbidden words across EVERY authored field.** Scan body, post title, post excerpt, the `key_takeaways[].takeaway` rows, the `sources[].source_name` rows, the `sources[].source_description` rows, the SEO title meta, the SEO description meta, AND any literal strings in the publish script before publish. Body-only scan misses the 8 other fields where authored copy lives.28918. **Zero em-dashes (—) and en-dashes (–) across the same 9 authored fields.** Same scope as rule 17. Theme-emitted inline `<script>` developer comments are out of scope (not article copy).29019. Shortcodes for every visual component (never inline HTML for components that have a shortcode).29120. Respect the project's deprecated-shortcode list (from `blog-pipeline-config.md`). Do NOT emit deprecated shortcodes in new posts even if older posts use them. Common pattern on directory sites: native theme byline + author popover + a structured-field-driven takeaways aside replace `[<prefix>_freshness_stamp]`, `[<prefix>_key_takeaways]`, `[<prefix>_author_card]`. Set the structured / native fields at Phase 8 instead.29221. Native theme components: when the project's theme natively renders byline / author / takeaways / TOC / breadcrumbs (check `content-single.php` or equivalent), do NOT duplicate them in body shortcodes. The buffet table in `content-templates.md` lists which components are theme-native vs body-rendered.29322. **Tables for information gain.** When a section's content has clear column structure (brand/provider listings, tier comparisons, areas + counts, methodology criteria, vs-comparisons), render as an HTML `<table>` not a `<ul>`. Mobile-readable: 3-4 columns max; consolidate context into single cells when needed. Tables work as featured-snippet candidates and reinforce info-gain perception.29423. **External links to commercial brands or competitors** carry `rel="nofollow noopener"`. The 2-max editorial external-link budget (gov, journals, regulatory bodies) is SEPARATE from the nofollow brand-list budget (no cap on nofollow links to home-service brands / competitor platforms when the project authorises an entity-mention carve-out). Confirm rel attribute on every outbound brand link before publish.29524. **Per-entity card shortcode (when the project config defines one).** If the project's `blog-pipeline-config.md` shortcode library exposes a per-entity card shortcode (e.g. `[<entity> id="POST_ID"]` for directory-style sites where each entity has its own profile page), use it for the FIRST mention of every named entity in the post body — never a plain `<a href="/{slug}">{Name}</a>` anchor for the first mention. The card renders the entity's canonical info row (image, name, location, rating, price, badge) at a glance; a plain anchor hides all of that. Subsequent in-paragraph references to the same entity by name can stay as plain text. Pre-publish: look up each named entity's record ID via the project's entity-list query, then smoke-test that the card shortcode renders non-empty against the live site before pasting into the body. Project configs without a per-entity card shortcode are exempt from this rule.29625. **Directory/listing counts (HARD RULE — generic; the site config carries the specifics).** NEVER type a directory listing count as a literal number (it goes stale and drifts). Emit it via the site's dynamic-count component WITH the trust-floor, and SCOPE-MATCH the count's filter to the article's actual subject:297 - **Geo-generic article** ("listings in {area}") -> location-axis token (all listings in the area).298 - **Type/service art299300…(truncated)