getlifeprotection.com (GLP blog + content pipeline)
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;
CNAMEpoints the custom domain. - Permalink pattern is
/blog/:slug/(set in_config.yml). A post’s URL comes from its front-matterslug:, which can differ from the filename. future: falsein_config.ymlis 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 inscripts/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.mjsreads 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/byscripts/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.mdlists 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/, andrules.md. Excluded from the Jekyll build.GLP Content/: a gitignored, Drive-synced working tree used by the Cowork Mac tasks. It holdsblog-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/.
- Thursday generator (GitHub Actions,
generate-week.yml). Runsscripts/generate-post.mjs --weekat 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 areview/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-syncedGLP 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. - 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. - Weekly review + image + publish (human-gated). Elijah reviews both sources
(the
glp-weekly-reviewskill), attaches hero images (theattach-imagesskill), then runs the publish path: move winners to_drafts/, run the REALsync-drafts.mjsgate, 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. - Daily deploy cron (GitHub Actions,
pages.yml). Builds and deploys at 13:11 UTC daily (~06:11 Pacific). Because offuture: 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. - Monday SEO audit (GitHub Actions,
seo-audit.yml). Runs 13:30 UTC Mondays, commits a report to_seo-reports/. - Thursday mirror job (launchd,
com.elijahmang.glp-cowork-pull). Runs 11:00 Thursday (Mac local time), before the 2 PM Cowork run. Itgit pull --ff-only origin mainon~/getlifeprotection, thenrsync -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/.
The internal-link rule (07-18 hard-won; the most important rule here)
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-matterslug:(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 slughow-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 underfuture: 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 todate: <= 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.jsonas 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/*.mdcarries caption, tags and payload together; the PNGs undersocial/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_TEMPLATEinscripts/social-rules.mjs(D8): carousel to carousel,cardto hook,breakdownto stat,questionto nothing at all. Three vocabularies disagreed before this; do not re-derive it. - The pipeline:
generate-social.mjs(drafts, validates, holds) thenrender-social-cards.mjs(payload to PNG, records hashes) thenverify-social-queue.mjs(full check) thensocial-pr-body.mjsandsocial-notify.mjs. Deps live insocial/templates/and resolve throughscripts/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.mjshas its OWN write guard, and it is an ALLOWLIST. Do not reuse the blog’sisProtectedTarget: 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_requesttrigger anywhere here, so a green PR page means nothing was run.social-rerender.ymlfires on every push to asocial/**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) andpayload_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.mjsis 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 touchingtemplates.mjsfor 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.
linuxis the authoritative entry, because that is where the cards that reach a review PR are rendered;darwinexists 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
pathsFILTER 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 makessocial/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.ymltherefore carries nopathsfilter. 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: attributedon the quote card is blocked in TWO places, template and validator. It was documented as blocked for months while rendering fine; seesocial/rules.md2.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)
- Review + select with the
glp-weekly-reviewskill: 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. - Attach images with the
attach-imagesskill: it finds a warm, authentic, DIVERSEimages.unsplash.comhero per winner (natural mix, not a diversity quota; never default to all-white stock), shows Elijah clickable candidates, and writes only theimage:line on approval. Never a watermarkedplus.unsplash.comURL. - 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 missingtitle/slug/date/description, with a malformed date or slug, with noimage:, or with aplus.unsplash.comimage; it WARNS (does not block) on a duplicate hero image. It does not commit or push; it printsgit statusfor your review. That review is the human gate. Run--dry-runfirst to preview. - Future-date Mon–Sun so each post publishes on its own day under
future: false. - Rebuild the content index (
build-content-index.mjs, whichsync-draftsalso does after a move) and commit it; the index should never drift stale. - Commit + push, then the daily
pages.ymlcron 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.jsonfor what is live caused the 07-18 dead-link grind. It lags_posts/. Verify links against_posts/(or_posts-live/for Cowork), filtered todate: <= today, resolving by front-matterslug:. - 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/andscripts/are excluded from the Jekyll build (the optional- front-matter plugin would otherwise render every.mdand parse Liquid inside, which broke the 2026-07-03 deploy). Keep internal.mdout 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.mjsappended⚠️ needs imageto 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-imagesran, 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 fromp.hasImageand 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.jsonis gitignored (.gitignore:27), so it exists only on the CI runner and is gone once the run ends. The committed_review/PR_BODY.mdis the only durable artifact, so it is the one that gets re-derived. Nothing calls the refresh automatically yet; after/attach-imagespushes, 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 onorigin/main. So the directory being tracked is the normal state, and an untracked file sitting in it is the exception. A singlegit add _held-for-later/(or anygit 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 everygit addthat night named explicit paths, which was luck of habit, not a guardrail.