getlifeprotection.com (GLP blog + content pipeline)

What this is

The consumer-facing marketing site for Get Life Protection (getlifeprotection.com): a Jekyll blog that educates everyday people shopping for life insurance and routes them to a quote or a policy review. This repo is the site’s source. It is NOT the CRM (that is ~/agent-dashboard) and NOT the personal-advisor brand (no leverage/IUL-for-investors/generational-wealth framing). Write for a regular person who searched a question like “life insurance with COPD.”

The site is live and content publishes on a drip with no manual step per post, so a mistake in a draft (a dead internal link, an em dash, a priced dollar figure) ships on its own date unless it was caught before it entered the queue. That is why the rules below are strict.

Stack and how it ships

  • Jekyll (kramdown, GitHub Pages gem), deployed via GitHub Actions (not “deploy from branch”). Hosted on GitHub Pages; CNAME points the custom domain.
  • Permalink pattern is /blog/:slug/ (set in _config.yml). A post’s URL comes from its front-matter slug:, which can differ from the filename.
  • future: false in _config.yml is load-bearing: a post dated ahead of today stays hidden until a build runs on or after its date. That is what makes the drip work without a manual publish per post. Do not flip it.
  • Local build/preview: bundle exec jekyll build / bundle exec jekyll serve. Node pipeline scripts live in scripts/ and run on Node (nvm; the pinned version path is used by the skills, e.g. ~/.nvm/versions/node/v22.22.2/bin/node).

Where things live

  • _posts/: published posts (YYYY-MM-DD-slug.md). This is the source of truth for what is or will be live. Future-dated files sit here committed ahead of time and go live on their date.
  • _drafts/: the in-repo staging folder. sync-drafts.mjs reads here and moves publish-ready files into _posts/. The Thursday generator also commits its clean drafts here (drafts-only, never _posts/).
  • _data/content-index.json: a generated index of published posts (title, slug, url, date, image, sourceFile), rebuilt from _posts/ by scripts/build-content-index.mjs. Convenience, not the source of truth for what is live (see the internal-link rule; it can lag _posts/).
  • _review/: the generator’s review-branch home: _review/PR_BODY.md lists which drafts were clean / held for compliance / dropped on validation; _review/held/ holds the compliance-held copies.
  • _seo-reports/: the Monday SEO audit output.
  • content/glp-voice-and-rules.md: the canonical voice, compliance, and front-matter spec. The generator reads it. Treat it as authoritative for voice.
  • scripts/: the pipeline: generate-post.mjs (weekly generator), sync-drafts.mjs (the publish gate), build-content-index.mjs, review-pr.mjs, notify-discord.mjs, seo-audit.mjs, plus versioned skill reference copies (*.skill.md). scripts/ is excluded from the Jekyll build. The SOCIAL engine also lives here: generate-social.mjs, social-rules.mjs, social-prompt.mjs, render-social-cards.mjs, verify-social-queue.mjs, social-pr-body.mjs, social-notify.mjs, social-deps.mjs, check-card-pixels.mjs, test-social.mjs. See the social section below.
  • social/: the social engine’s data and design: templates/ (the five approved card masters, the vendored fonts, the pixel baselines), queue/ (one file per approved post), assets/ (rendered PNGs, derived), review/held/ (compliance holds, committed so they are readable in the PR), log/, and rules.md. Excluded from the Jekyll build.
  • GLP Content/: a gitignored, Drive-synced working tree used by the Cowork Mac tasks. It holds blog-drafts/ (the Cowork writer’s output), _posts-live/ (the mirror of published posts, see below), plus research, newsletters, and reviews. It exists only in the MAIN working copy (~/getlifeprotection), never in a worktree, and never in the repo’s git history.

How the content pipeline works end to end

Two independent draft sources feed one human-gated publish path. Nothing auto-publishes; the drip only moves posts that a human already placed in _posts/.

  1. Thursday generator (GitHub Actions, generate-week.yml). Runs scripts/generate-post.mjs --week at 14:00 UTC Thursdays, targeting the UPCOMING Mon–Sun. It is deliberately disarmed: it writes and commits _drafts/ ONLY (two layers enforce this), never _posts/. It pushes a review/week-of-<monday> branch and opens ONE review PR, then notifies Discord with the digest and any compliance escalation. On GitHub’s servers the Drive-synced GLP Content/ research tree is absent, so it falls back to the in-repo topic bank. Dedup is vs _posts/ only; drafts never count as covered.
  2. Cowork blog-writer (Task 2, scheduled Mac). Runs ~2 PM and drafts 7 posts (Mon–Sun) into GLP Content/blog-drafts/. Its live-link source of truth is _posts-live/ (see the internal-link rule). Its SKILL lives at ~/Documents/Claude/Scheduled/task-2-blog-writer/SKILL.md.
  3. Weekly review + image + publish (human-gated). Elijah reviews both sources (the glp-weekly-review skill), attaches hero images (the attach-images skill), then runs the publish path: move winners to _drafts/, run the REAL sync-drafts.mjs gate, rebuild the content index, future-date Mon–Sun, and commit. glp-weekly-publish (the one-command wrapper) is coming in week 2; for now the tail is run by hand.
  4. Daily deploy cron (GitHub Actions, pages.yml). Builds and deploys at 13:11 UTC daily (~06:11 Pacific). Because of future: false, this is the build that makes each future-dated post go live on its own date. It ignores pushes that only touch _seo-reports/, _data/seo-snapshots.json, or _drafts/ so those commits do not trigger a pointless rebuild.
  5. Monday SEO audit (GitHub Actions, seo-audit.yml). Runs 13:30 UTC Mondays, commits a report to _seo-reports/.
  6. Thursday mirror job (launchd, com.elijahmang.glp-cowork-pull). Runs 11:00 Thursday (Mac local time), before the 2 PM Cowork run. It git pull --ff-only origin main on ~/getlifeprotection, then rsync -a --delete _posts/ → "GLP Content/_posts-live/" so the Cowork task reads the EXACT current published set. It fails loud in its log if either step fails. This job is the patch that fixed the Cowork link bug: Cowork could not see git/live truth, so the mirror hands it a fresh copy of _posts/.

A blog link is safe ONLY if its target is a post that is actually LIVE right now. A 404 from a dead internal link ships silently on the drip. Resolve links this way:

  • Live = published on origin/main _posts, dated on or before today. Resolve a link by the target post’s front-matter slug: (the permalink), which can differ from the filename. Never build a slug from a title, and never guess one (e.g. “How Much Life Insurance Do I Actually Need?” has slug how-much-life-insurance-do-i-need, no “actually”).
  • Filter to date: <= today. Future-dated posts are committed in _posts/ (and mirrored into _posts-live/) but are NOT live yet under future: false. A file being in the folder does NOT mean it is live; linking a future-dated post is a dead link until its date arrives.
  • For the Cowork writer, the source of truth is _posts-live/ (the Thursday mirror of _posts/), filtered to date: <= today. Report the slug-count health line every run (“Loaded N live slugs; skipped M future-dated”); a count of 0 or far below expected means the pull/mirror failed and blog links are suspect. If _posts-live/ is missing or empty, fall back to product pages / /quote/ / /policy-review/ only and say so, never guess.
  • Never link against blog-drafts/, sibling same-week drafts, or a post’s title. A draft that loses its publish slot to the generator never goes live and 404s (three Cowork targets lost their slots and died this way).
  • Do NOT trust _data/content-index.json as the source of truth for what is live. It is a convenience index rebuilt from _posts/ and it can lag. Trusting it stale caused the 07-18 dead-link grind. Verify against _posts/ (or _posts-live/ for Cowork), not the index.

Every blog link must resolve to a real live post. /quote/ and /policy-review/ and the product pages (/life-insurance/term|whole-life|final-expense|iul/) always exist and are safe fallbacks; blog-to-blog links are the risky ones.

The social engine (GLP Social Phase 1, separate pipeline, same repo)

A SECOND pipeline lives under social/, feeding Facebook rather than the blog. It shares this repo, the compliance machinery and the Discord notifier, and shares nothing else. Read social/rules.md (the social addendum to the voice file) and the brain’s Projects/Specs/GLP_SOCIAL_PHASE1_SPEC.md before touching it.

  • It publishes nothing, and cannot. Generation runs in GitHub Actions and holds no Meta credentials; publishing is S2, in a Vercel app that does not exist yet. Merging the weekly review PR is the approval (spec D2), and an unmerged PR publishes nothing, ever.
  • The slate is FIVE (D5): 2 carousels, 1 story card, 1 breakdown, 1 text-only question post. The Reel slot is NOT backfilled with a static.
  • One file per post, and the card payload lives in its front matter (D6). social/queue/*.md carries caption, tags and payload together; the PNGs under social/assets/ are DERIVED. The queue file is the source of truth for what a card says, so anything reading post content (including the PR body) reads the queue, not the run summary.
  • Format to template is fixed in code at FORMAT_TEMPLATE in scripts/social-rules.mjs (D8): carousel to carousel, card to hook, breakdown to stat, question to nothing at all. Three vocabularies disagreed before this; do not re-derive it.
  • The pipeline: generate-social.mjs (drafts, validates, holds) then render-social-cards.mjs (payload to PNG, records hashes) then verify-social-queue.mjs (full check) then social-pr-body.mjs and social-notify.mjs. Deps live in social/templates/ and resolve through scripts/social-deps.mjs.
  • Run the offline suite with node scripts/test-social.mjs. The criterion is all green, never a number.

Social gotchas (each one cost something)

  • scripts/social-rules.mjs has its OWN write guard, and it is an ALLOWLIST. Do not reuse the blog’s isProtectedTarget: that is a denylist whose legitimate target is _drafts/, so it does not protect _drafts/ or _held-for-later/ at all.
  • The re-render workflow is the only CI this repo has. There is no pull_request trigger anywhere here, so a green PR page means nothing was run. social-rerender.yml fires on every push to a social/** branch, re-renders from the queue front matter, re-validates, and pings Discord on failure. It exists because the review step invites hand-editing nested YAML in GitHub’s web editor, sometimes on a phone.
  • Hashing the PNG does not catch a stale card; hashing the PAYLOAD does. Each image records both sha256 (the file) and payload_sha256 (the slide data that produced it). Edit card text without re-rendering and the PNG is untouched, so its hash still matches while the file and the pixels now say different things. The first version of this check passed exactly that edit.
  • The PR body’s card images pin to the COMMIT SHA, never the branch. PR #23’s own contact sheet is blank today because it pinned to a branch that was later deleted.
  • A RE-RUN FOR THE SAME WEEK MUST REFRESH THE EXISTING PR’S BODY. The generator targets the upcoming Monday, so two runs in the same week push to the SAME social/week-of-<monday> branch. The create-PR call then fails because one already exists, and the original code just looked up the URL and moved on, leaving a body that described the PREVIOUS run’s cards, captions and figures while the branch carried a fresh week. Same shape as the stale-PNG bug: the body is DERIVED from the queue, and a derived thing that is not regenerated keeps describing what it used to be. The workflow now PATCHes the body, and warns loudly if it cannot.
  • THE PR BODY SAYS THAT SEEING THE ARTWORK ON MOBILE COSTS ONE TAP. Do not soften that. It is measured (PR #28: even a public control image fails in a PR body on the mobile app), and the whole reason the wording is blunt is that a review surface which reads as solved when it is not is how a gate stops being one. If a future change makes it untrue, change the mechanism and then the words, in that order.
  • The brand fonts are vendored in social/templates/fonts/ and the lockfile is committed. The renderer makes no network call. scripts/check-card-pixels.mjs is the gate on any change to how a card is DRAWN: it re-renders every master and compares SHA-256 against the approved baseline. Run it before and after touching templates.mjs for any non-design reason, and only re-record the baseline with an approved design change.
  • CARD PIXEL BASELINES ARE PER PLATFORM, and a render is only byte-reproducible WITHIN one. Measured 2026-08-25 on a real runner: macOS and Linux Chromium rasterise the same HTML with the same vendored font bytes to different pixels, all ten masters. Vendoring did not cause it and cannot fix it, because font bytes are now identical everywhere and the RASTERISER belongs to the platform. What was verified rather than assumed: the difference is glyph-edge antialiasing only (2.5 to 4.2 percent of pixels, no row over 50 percent differing), and text metrics, line breaks and layout are IDENTICAL. A relaid-out line would have put whole rows near 100 percent. linux is the authoritative entry, because that is where the cards that reach a review PR are rendered; darwin exists so the check is runnable on the Mac, where template work happens. Record on the platform you are on; the file merges rather than overwrites.
  • A paths FILTER INVERTED THE RE-RENDER CHECK’S PURPOSE, and it would have looked like it was working. Measured 2026-08-25 on a real runner: a path-filtered workflow does NOT run on the push that CREATES a branch, only on later pushes to it. The generator makes social/week-of-<monday> fresh every week and pushes the whole slate in that first push, so the filter skipped the one push the check exists for while running faithfully on every edit afterwards. That inversion is the finding, not the filter. Edit a caption, watch the check fire, conclude the branch is covered. It was covered for everything except the content that arrived before anyone touched it, which is all of it. A guard that runs on the cases you happen to test and skips the case it was built for reads as GREEN rather than as ABSENT, which is worse than having no guard, because no guard is at least visible. social-rerender.yml therefore carries no paths filter. Before adding one to any workflow, ask what its FIRST run is supposed to cover.
  • A COMPOSITE CONTACT SHEET MAKES PROPERTIES OF THE WEEK VISIBLE THAT NO PER-ITEM CHECK CAN SEE. The topic-cluster soft flag exists because five identical cluster labels stack up in one image in a way they never do across five separate files. Five separate files cannot show a spread. The dedup engine keeps SUBJECTS distinct and was never watching the week’s shape, so five distinct subjects can be five faces of one topic and pass everything. That argues for the sheet beyond its review use: it is the only artifact that sees the week as a week.
  • mode: attributed on the quote card is blocked in TWO places, template and validator. It was documented as blocked for months while rendering fine; see social/rules.md 2.4 for why that record was true and still misleading.

Voice and content rules (canonical in content/glp-voice-and-rules.md)

Read content/glp-voice-and-rules.md for the full spec. The rules a session must never get wrong:

  • Plain agent/human voice. Write like a knowledgeable friend explaining something over coffee, not an essayist being clever. Kill the AI tells: the “quiet = wisdom” tic, knowing-aside section openers (“Here is the part that gets glossed over”), the “not X, it is Y” seesaw as a rhythm, and several sections or posts opening with the same move.
  • No em dashes, anywhere. An em dash is an AI-writing signal. Use commas, periods, or restructure. (The weekly-review script counts them; any count > 0 is a fail to fix or drop.)
  • “Guarantee” is allowed only as a conditioned mechanism, never an outcome promise. The test: does the sentence promise the reader a RESULT (approval, rates, “will have coverage”) or DESCRIBE how the contract works with its conditions stated (policy in force, premiums paid, age/eligibility, contestability)? The first is prohibited; the second is fine. “Guaranteed issue” / “guaranteed acceptance within the eligible age range” as product terms are OK; “guaranteed coverage” with no condition is not.
  • No specific premium/price dollar figures (a coverage amount like “$1M of term” is fine; a monthly/annual price is not). No named rate classes (“Preferred Plus”). No hashtags. Soft CTAs only (gentle nudge to /quote/, /policy-review/, the phone, or a related post; never “act now” or “DM me”). “Most people don’t realize” at most once across a whole week.
  • Carrier references use qualifying language (“carriers known for,” “many carriers specialize in”), never “this carrier will approve you.” Carrier facts are never taken from model memory; if a draft asserts a carrier rule you cannot verify against ~/carrier-knowledge/, flag it rather than pass it.
  • Structure: reader-first opening (lead with the situation/question and answer the core question within ~200 words; no scene, metaphor, or literary cold-open), 800–1500 words, at least three H2 sections, primary keyword in the title and at least one H2, at least three internal links (≥1 product page AND ≥2 distinct already-live blog posts).

Front-matter shape (match live posts exactly)

layout: post
title: "<under 60 chars; includes the primary keyword>"
slug: <kebab-case; matches the intended URL>
description: "<140–160 chars, plain English; includes the primary keyword>"
category: "<e.g. Coverage Planning | Health and Underwriting | Final Expense>"
primary_keyword: "<main search phrase; also in the title and ≥1 H2>"
date: YYYY-MM-DD        # unquoted; THIS is the field the site builds from
image: "<a free images.unsplash.com URL>"   # never plus.unsplash.com

Filename is YYYY-MM-DD-slug.md and the date in the filename must match date:. Body starts immediately with the opening paragraph, NO leading # H1 (the layout renders the title). Use description:, not meta_description:. Generated drafts carry image_search: (a hint) instead of image:; the real hero is added at the image step before sync.

BOTH draft sources now emit this exact shape, verified 2026-08-17 against all three Cowork drafts selected that week. Cowork output is layout, title, slug, description, category, primary_keyword, date, image_search, identical to the generator’s, and the only missing field on either side is image:. So a Cowork draft needs NOTHING a generator draft does not, and one /attach-images pass covers a mixed week. sync-drafts.mjs still renames meta_description to description; treat that as DEFENSIVE handling of a shape it might be handed, NOT as compensation for a live difference between the sources. Cowork emitted meta_description and no layout/category as recently as 2026-06-26, so the rename earns its place; it just is not describing today’s inputs. Re-verify before relying on this: Task 2’s prompt lives outside this repo (~/Documents/Claude/Scheduled/task-2-blog-writer/SKILL.md), so its output can change with no signal here. A record describing another tool’s output goes stale silently, and nothing in this repo watches for it.

The publish flow (review → select → image → sync gate → future-date → index → drip)

  1. Review + select with the glp-weekly-review skill: it gathers both draft sources, scores each against the criteria above, and proposes a Mon–Sun lineup with a pinned red-flag block. Elijah approves or overrides. Review only, never publishes.
  2. Attach images with the attach-images skill: it finds a warm, authentic, DIVERSE images.unsplash.com hero per winner (natural mix, not a diversity quota; never default to all-white stock), shows Elijah clickable candidates, and writes only the image: line on approval. Never a watermarked plus.unsplash.com URL.
  3. Sync gate: run the REAL scripts/sync-drafts.mjs (never bypass it). It validates and normalizes front matter, then moves publish-ready files from _drafts/ into _posts/. It refuses (non-zero exit) any draft missing title / slug / date / description, with a malformed date or slug, with no image:, or with a plus.unsplash.com image; it WARNS (does not block) on a duplicate hero image. It does not commit or push; it prints git status for your review. That review is the human gate. Run --dry-run first to preview.
  4. Future-date Mon–Sun so each post publishes on its own day under future: false.
  5. Rebuild the content index (build-content-index.mjs, which sync-drafts also does after a move) and commit it; the index should never drift stale.
  6. Commit + push, then the daily pages.yml cron drips each post live on its date. Confirm the Actions deploy went green.

glp-weekly-publish (a one-command wrapper over review → image → sync tail, with a gated Discord report) is the week-2 build; it never arms auto-publish.

Hard-won gotchas (each cost a real incident or a long grind; don’t relearn them)

  • Trusting _data/content-index.json for what is live caused the 07-18 dead-link grind. It lags _posts/. Verify links against _posts/ (or _posts-live/ for Cowork), filtered to date: <= today, resolving by front-matter slug:.
  • A draft in a folder is not a live post. Future-dated posts sit in _posts/ and _posts-live/ before their date; sibling same-week drafts may lose their slot and never publish. Both 404 if linked as if live.
  • GLP Content/ is gitignored and Drive-synced, main-repo-only. Edits there are real and immediate but exist only in ~/getlifeprotection, never in a worktree and never in git history. The generator’s servers never see it.
  • The generator is drafts-only by design. It writes _drafts/ and opens a review PR; it never touches _posts/. Do not “fix” it to publish; the human gate is intentional.
  • docs/ and scripts/ are excluded from the Jekyll build (the optional- front-matter plugin would otherwise render every .md and parse Liquid inside, which broke the 2026-07-03 deploy). Keep internal .md out of the build path.
  • Skill reference copies live at scripts/<name>.skill.md (versioned), the live skill at ~/.claude/skills/<name>/SKILL.md. Keep them in sync when a skill changes (byte-identical apart from the header comment).
  • A WARNING THAT IS ALWAYS ON CARRIES NO INFORMATION, AND THE READER LEARNS TO SKIP IT (2026-08-31). scripts/review-pr.mjs appended ⚠️ needs image to every clean draft unconditionally, with no front-matter check, and the body’s header asserted flatly that every draft was image-less. This is a worse failure than the stale-artifact bugs already recorded here, and the difference is worth holding onto. A stale artifact is TRUE until the world moves and re-running fixes it; this was FALSE in every state after /attach-images ran, and re-running could not fix it, because the claim was hardcoded rather than derived. It survived for the life of the script because images are always attached AFTER the body is written, so the warning was accurate at the one moment anyone compared it to reality. Nobody noticed it was permanent. The general shape: a check that cannot ever pass is indistinguishable from a check that always fails, and both read as “working” to anyone who only ever sees the failing case. When a warning is meant to clear, prove it clears. Fixed by deriving the marker from p.hasImage and re-deriving an existing body from the drafts on disk (--refresh-images). Note WHY refresh reads the body rather than the run summary: _run-summary.json is gitignored (.gitignore:27), so it exists only on the CI runner and is gone once the run ends. The committed _review/PR_BODY.md is the only durable artifact, so it is the one that gets re-derived. Nothing calls the refresh automatically yet; after /attach-images pushes, run it and PATCH the PR body, or the body still under-reports (it can no longer over-report).
  • _held-for-later/ is a TRACKED directory, so a benched draft is protected only by not being staged. It is not gitignored and it is not empty: five older drafts are committed and live on origin/main. So the directory being tracked is the normal state, and an untracked file sitting in it is the exception. A single git add _held-for-later/ (or any git add -A / git add . from the repo root) sweeps every benched draft into the commit, and nothing about the directory stops it. Stage benched drafts BY PATH, never by directory, and read the staged list before committing. Noticed 2026-08-16, when the two drafts the 08-13 CI run produced (life-insurance-with-a-mental-health-history, life-insurance-when-you-buy-first-home) sat untracked in there through a seven-post publish; they survived because every git add that night named explicit paths, which was luck of habit, not a guardrail.