<!-- Given away by The Rabble Group (rabblegroup.com) as part of "How to build a content engine." This is our actual site-sync skill, unedited except for stripping a local path. It references our own voice files, repo paths, and a behavioral-health flavor - adapt them to your business. No license, take it and make it yours. -->

---
name: site-sync
version: 2.0.0
description: >
  Closes the publishing loop. Reads the blog markdown files in
  site/src/content/blog/ from the git repo (NOT a CMS - there is no CMS),
  detects draft -> published transitions (frontmatter draft: true -> draft: false),
  and updates the pipeline's source-of-truth files to reflect what is live:
  engine tracking in _state/ (last-sync timestamp + an append-only sync log) and
  the matching entries' status in context/content-roadmap.md.

  v2.0.0 (the engagement fork): replaced all WordPress/Pods/Webflow REST polling with a
  read of the markdown files in the Astro content collection. The git repo is the
  source of truth. Transition detection is now "draft flag flipped," matched to a
  roadmap entry by slug/title/keywords/signal_id. No API calls anywhere. No
  thinking/ graph (dropped in the engagement), so no _signals.json updates.

  Surfaces anomalies (low-confidence matches, out-of-band publishes, conflicting
  state) for human review rather than auto-mutating ambiguous cases. Bias toward
  "propose, don't auto-mutate" when uncertain.

inputs:
  required:
    - site/src/content/blog/*.md      # the content collection - source of truth for live state
    - context/content-roadmap.md      # source-of-truth for planned posts (status to flip)
  optional:
    - _state/last-site-sync.txt       # timestamp of last successful run
    - context/seo-clusters.md         # cluster id -> name, for inferring unplanned entries

outputs:
  - context/content-roadmap.md        # updated (status flips planned -> published, retroactive unplanned entries)
  - _state/last-site-sync.txt         # timestamp of this run
  - _state/site-sync-log.md           # append-only log of every run

upstream: [git repo (site/src/content/blog/ markdown)]
downstream: [radar (next run reads updated roadmap), voice-learner (diffs draft vs published markdown), analytics-tracker]

cadence: on demand or via a scheduled task / post-deploy hook; daily is plenty given the modest publish cadence

status: active
pipeline: content-engine
---

# Site Sync

You read the blog markdown in the git repo and update the pipeline's
source-of-truth files to reflect what is live. You close the loop between
"the engine produced a draft" and "the draft is published."

This engagement publishes via markdown-in-git on Astro/Netlify. There is NO CMS, no
WordPress, no Webflow, no REST API. "Live" is determined by one fact in the
repo: a post's frontmatter `draft` flag. `draft: true` is not published;
`draft: false` is published (Astro's production build excludes `draft: true`).
A published post is also reachable on the live site at `/blog/<slug>/`.

You do NOT generate content. You do NOT modify draft body text. You only touch:
- frontmatter status tracking via the roadmap (you do not rewrite the blog files),
- `context/content-roadmap.md` (`status`, `published_url`),
- `_state/` tracking files (timestamp + log).

When uncertain, **flag - don't mutate**. The pipeline's integrity depends on
this restraint.

---

## Before You Run

### Required reads

- All `*.md` files in `site/src/content/blog/` - the live-state source of truth. Parse each one's frontmatter.
- `context/content-roadmap.md` - find planned posts to potentially mark published.

There is no `.env` to load and no credentials to manage. The data source is the
local git working tree (or a fresh `git pull` of it). If `site/src/content/blog/`
does not exist or is empty, stop: write the failure to `_state/site-sync-log.md`
and return.

### Optional: last-run timestamp

Read `_state/last-site-sync.txt`. Contains a single ISO-8601 timestamp of the
last successful run, e.g., `2026-06-16T08:00:00Z`.

- If present: use it as a filter to focus on files changed since last run. The
  reliable signal is git - `git -C site log --since=<ts> --name-only -- src/content/blog/`
  tells you which blog files changed. (You may still scan all files for state;
  the timestamp just narrows where to look for transitions.)
- If absent (first run ever): scan all blog files and treat any `draft: false`
  post as a candidate publish to reconcile against the roadmap.

### No thinking graph

This engagement dropped the `thinking/` directory and `_signals.json`. There is no signal
registry to update here. If a post anchors to a `signal_id` (it is a frontmatter
field), use it only as a matching key against the roadmap - do not try to write a
signal registry. It does not exist.

---

## Step 1: Read the Content Collection

For each `*.md` in `site/src/content/blog/`, parse the YAML frontmatter and note:

- `draft` (boolean) - the publish state. This is the field that matters most.
- `title`, slug (the filename minus `.md`), `category`, `cluster`
- `target_keywords[]`
- `signal_id` (optional) - matching key if set
- `draft_filename` (optional) - if set, an explicit matching key to a roadmap/brief
- `pubDate`, `updatedDate`

To detect a transition you need the previous state. Two ways, in order of preference:

1. **Git diff (preferred, reliable).** Use git to see what the `draft` field was at last sync vs. now:
   ```bash
   # files changed in the blog dir since last run
   git -C <your-repo>/site \
     log --since="$LAST_RUN_TS" --name-only --pretty=format: -- src/content/blog/ \
     | sort -u

   # for a given file, what the frontmatter looked like at the last-sync commit vs now
   git -C <your-repo>/site \
     show "HEAD@{$LAST_RUN_TS}:src/content/blog/<slug>.md" 2>/dev/null | head -20
   ```
   A transition is a file whose `draft` flipped from `true` (or absent->defaulting true) to `false` between then and now.

2. **Roadmap cross-reference (fallback).** If git history is unavailable (shallow clone, fresh checkout), infer the transition by comparing current `draft: false` posts against the roadmap: any post that is `draft: false` in the repo but whose matching roadmap entry still says `status: planned` is an unreconciled publish - treat it as a publish transition.

**Failure modes:**
- Blog dir missing or empty -> log and stop.
- A file with malformed/unparseable frontmatter -> skip it, record an anomaly ("could not parse frontmatter for <file>"), continue with the rest.
- Git not available and no roadmap to cross-reference -> log and stop.

---

## Step 2: Match Each Published Post

For each post that is `draft: false` (published) - and especially each one whose
`draft` flipped to `false` since last run - determine the transition, then match
it to a roadmap entry.

### Transition Detection

- **`draft: false`, matching roadmap entry status `planned`/`in_progress`** -> **publish transition.** Process per 2A/2B below.
- **`draft: false`, matching roadmap entry already `published`, same URL** -> no-op (already synced).
- **`draft: false`, matching roadmap entry already `published`, different URL** -> **anomaly.** Flag for review; don't overwrite (possible slug change or a mis-set match).
- **`draft: true`** -> not published. No roadmap change. (An edited-but-still-draft file is just an in-flight draft; voice-learner handles draft churn, not site-sync.)
- **Was `draft: false` last run, now missing/deleted** -> **anomaly** (unpublish or file removed). Flag for review; note that it had counted as published. Do not silently revert the roadmap.

The published URL for any post is deterministic: `<site>/blog/<slug>/`, where
`<slug>` is the filename minus `.md` and `<site>` is `https://yourdomain.com`.
You construct it; you do not fetch it. (Optionally a single `curl -sI` HEAD check
can confirm it resolves, but it is not required - the repo is the source of truth.)

### 2A. Deterministic match

Match the published post to a roadmap entry, in priority order:

1. **`signal_id`** - if the post has a `signal_id` and a roadmap entry references the same signal, match.
2. **`draft_filename`** - if set, match the roadmap/brief entry that names this file.
3. **`target_keywords` overlap** - the roadmap entries list `target_keywords`; a strong overlap with the post's `target_keywords` is a high-confidence match.
4. **Slug / title** - exact slug match or exact title match against a roadmap entry's title.

**If matched AND the entry's status is `planned` or `in_progress`:**
- Flip its status to `published`
- Populate `published_url:` `https://yourdomain.com/blog/<slug>/`
- Populate `published_date:` from the post's `pubDate` (or `updatedDate` if the brief treats that as the live date). Add the field if the entry doesn't have one.
- Continue to the next post.

**If matched AND status is already `published`:**
- Verify `published_url` matches the constructed URL. If it does, no-op (already synced).
- If it differs -> **anomaly:** flag for review. Don't overwrite (possible slug change at republish, or a mis-set match).

**If no roadmap entry matches** -> go to 2B (unplanned content).

### 2B. Unplanned content (no matching roadmap entry)

A `draft: false` post that matches no planned roadmap entry is **unplanned
content** - it was written and published outside the planned set (e.g., a fast
reaction post off a radar signal). Add a retroactive entry rather than silently
ignoring it.

Append to `context/content-roadmap.md` under a top-level section
`## Unplanned (auto-added by site-sync)` (create the section if it doesn't exist):

```markdown
### unplanned-YYYY-MM-DD-<slug>
- **status:** published
- **cluster:** <from the post's `cluster` frontmatter, or "uncategorized">
- **category:** <the post's `category`>
- **target_keywords:** <from the post's `target_keywords`>
- **provenance:** unplanned - auto-added by site-sync on YYYY-MM-DD
- **source signal:** <signal_id if set, else "none">
- **published_url:** https://yourdomain.com/blog/<slug>/
- **notes:** Detected published post does not match any planned roadmap entry. Cluster/category are read from frontmatter; review and slot into the right tier.
```

Mark an anomaly: "Unplanned content added retroactively - review tier/cluster assignment."

Do NOT invent a brief, a planned tier, or strategic framing for it. site-sync
records what is live; the human owns strategy.

### Low-confidence matches

If a published post partially matches a planned entry (some keyword overlap, a
fuzzy title match) but you are not confident:
- **Do NOT mutate the roadmap.** Add an anomaly:
  "Published post `<slug>` likely matches planned entry `<entry title>` (partial keyword/title overlap). Confirm the match, or set `draft_filename`/`signal_id` on the post to make it deterministic."

Bias toward proposing over auto-mutating. A wrong status flip is worse than an
unreconciled one a human resolves next run.

---

## Step 3: Update Files

After processing all posts, write changes in this order (so state stays
consistent if interrupted):

### 3A. Update `context/content-roadmap.md`

- For each `planned -> published` transition: edit that entry's `status` and add/set `published_url` (and `published_date`) in place. The roadmap uses a `- **status:** ...` markdown field under numbered `### N.` headings - edit the value, preserve the rest of the entry.
- For each unplanned post: append to the `## Unplanned (auto-added by site-sync)` section (create it if missing).
- Never reorder, renumber, or rewrite planned entries beyond their status/url fields.

### 3B. Append to `_state/site-sync-log.md`

Append a new run section (create the file if it doesn't exist). Format:

```markdown
## Run 2026-06-16T08:00:00Z

- **Source:** site/src/content/blog/ (git working tree @ <short commit sha>)
- **Files scanned:** N (changed since 2026-06-15T08:00:00Z: M)
- **Publish transitions detected:** N
  - <slug> -> https://yourdomain.com/blog/<slug>/ (matched: signal_id | draft_filename | keywords | slug)
- **Unplanned published posts:** N
  - <slug> (added retroactively)
- **Anomalies flagged:** N
  - [details]
- **Files updated:**
  - roadmap: [entries flipped planned -> published]
- **Errors:** none | [details]
```

### 3C. Update `_state/last-site-sync.txt`

Write the current UTC timestamp to this file (overwrite). This becomes the
`since`/`modified_after` filter for the next run.

**Only update this file if the run succeeded.** A failed run should leave the
timestamp unchanged so the next run re-attempts the same window.

---

## Anomaly Handling

Anomalies are surfaced in the log AND available to the next radar run (the radar
reads `_state/site-sync-log.md` to pull recent anomalies into its Continuity
Check).

**Categories:**

1. **Conflict** - pipeline state contradicts repo state. Don't mutate.
   - "Post `<slug>` matches planned entry already marked published at a different URL."
   - "Post that was published last run is now missing/deleted from the collection."
2. **Low-confidence match** - partial keyword/title overlap, no deterministic key. Don't mutate. Ask the human to confirm or to set `draft_filename`/`signal_id`.
3. **Unplanned publish** - a `draft: false` post with no matching planned entry. Added retroactively to the "Unplanned" section, flagged for tier/cluster review.
4. **Unparseable frontmatter** - a blog file whose YAML couldn't be read. Skipped; flagged.

---

## What This Skill Does NOT Do

- Does NOT call any CMS, WordPress, Webflow, or REST API (there is no CMS).
- Does NOT write or modify blog file body text, or any frontmatter in the blog files. (It reads them; it edits the roadmap and `_state/`, not the posts.)
- Does NOT flip `draft: false`. Publishing is a human action (flip + commit). site-sync only *detects* it after the fact.
- Does NOT create planned roadmap entries silently (only the explicit "Unplanned" section, with full provenance).
- Does NOT delete or archive anything.
- Does NOT touch `_signals.json` or any `thinking/` artifact (they don't exist in the engagement).
- Does NOT modify voice files or any `context/` file other than `content-roadmap.md`.
- Does NOT trigger downstream skills (radar reads the updated roadmap on its own next cycle).
- Does NOT serve or alter the markdown that AI assistants receive; it never participates in serving content, so cloaking is not even possible here.

---

## Common Failure Modes & Recovery

| Symptom | Cause | Fix |
|---|---|---|
| Blog dir empty / missing | Wrong path, or repo not checked out | Confirm `site/src/content/blog/` exists; `git pull` first |
| Transition not detected | Git history shallow; no last-sync commit to diff against | Fall back to roadmap cross-reference (Step 1, method 2) |
| Same post reconciled every run | Roadmap status flip didn't persist, or `published_url` mismatch | Verify the roadmap edit landed; check the constructed URL matches |
| All published posts show "unplanned" | Roadmap `target_keywords` don't overlap the posts' keywords | Align keywords, or set `draft_filename`/`signal_id` on posts for deterministic matching |
| Unparseable frontmatter on one file | Malformed YAML (e.g., a quoted-vs-unquoted date, a stray em dash in a value) | Fix the frontmatter in the blog file; rerun. Em dashes are banned - check values. |

---

## Run Order

When invoked (manually, on a schedule, or post-deploy), execute in this order:

1. Read `_state/last-site-sync.txt` (if present) and the blog collection.
2. For each blog file: parse frontmatter; determine publish transitions (git diff preferred, roadmap cross-reference fallback).
3. For each published post: match to a roadmap entry (2A) or record as unplanned (2B); collect anomalies.
4. Update files in order: roadmap -> log.
5. Update `_state/last-site-sync.txt` only on success.

**Idempotent design:** running site-sync twice in a row with no repo changes
should produce no roadmap mutations and an empty (or "no transitions") run-log
section. If you see duplicate updates, the matching logic has a bug.

---

## Test Invocation

For testing without a schedule:

```
Read skills/site-sync.md and execute it.
Use _state/last-site-sync.txt as the since-filter (or scan all files if it's the first run).
After completion, show me the latest run section of _state/site-sync-log.md.
```

This is the verification pattern: trigger the skill, then read the log to see
what it did. The log is the audit trail. Because the data source is local
markdown, you can also dry-run safely - flip a test draft to `draft: false` on a
branch, run site-sync, and inspect the roadmap diff before committing.
