# Flywheel

> Use when user wants a weekly dashboard of the Enterprise Vibe Code growth flywheel — "flywheel report", "weekly rollup", "how's the flywheel spinning", "am I on track", "week over week growth", "priorities check". Produces a single markdown report against the 5 growth-plan priorities.

- Skill: `michaellady/flywheel` (Agent Skill)
- Install (CLI): `npx skillmds@latest add michaellady/flywheel`
- Raw SKILL.md: https://api.skillmd.com/api/skills/michaellady/flywheel/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: michaellady (https://skillmd.com/u/michaellady)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/michaellady/flywheel

---


# flywheel

Aggregate signal from **every surface** — YouTube, beehiiv, LinkedIn, Buffer (IG/FB), TikTok, Threads — plus the consulting log into one weekly report against the 5 priorities. `/flywheel` is the **single front door**: `--refresh` collects the latest data from all accounts (invoking each platform's stats skill), runs the per-source-content JOIN, and compounds the cross-surface hypothesis ledger — so one command gets you current numbers everywhere and scores last week's predictions. It runs against the 5 priorities in `~/dev/claude-social-media-skills/youtube-analytics/enterprise_vibe_code_growth_priorities.md`. Answers "is the flywheel spinning this week?" with specific numbers, not vibes.

## Usage

`/flywheel` — **default: auto-detect stale data + prompt to refresh.** Checks each upstream source's snapshot age against `stale_snapshot_days` from the priorities-doc targets block (default 14d). If anything is stale, surfaces the list and asks "refresh these N stale sources now?" with default = yes. On yes, invokes the relevant sub-skills inline before composing. On no, falls through to cached-only mode. ~30 sec if everything fresh; ~5-15 min if a refresh is needed.
`/flywheel --refresh` — force-refresh ALL upstream snapshots regardless of staleness. **The canonical Sunday weekly review.** Skips the prompt. ~10-15 min wall-clock end-to-end.
`/flywheel --refresh-stale` — auto-accept the staleness prompt (refresh anything stale, skip the prompt). Semantically equivalent to plain `/flywheel` + "yes" — useful for scripted / unattended runs.
`/flywheel --cached` — skip the freshness check entirely. Read whatever's cached, flag staleness in the report, never invoke sub-skills. Fast (~30 sec) and read-only. Use for "where do we stand right now" without paying the refresh cost.
`/flywheel --days 30` — custom window
`/flywheel --no-save` — produce the report but don't overwrite today's snapshot
`/flywheel --compare 2026-04-12` — diff against a specific older snapshot

### When to use which mode

- **Daily / mid-week check:** plain `/flywheel` — if everything's fresh you get the fast read; if a source went stale overnight, you get prompted and can choose to refresh.
- **Sunday weekly review:** `/flywheel --refresh` — force-refresh everything, no prompts, canonical artifact.
- **Catch-up after a few days:** plain `/flywheel` (accept the prompt) — same as `--refresh-stale`, just with a confirmation gate.
- **"Just show me what we have":** `/flywheel --cached` — explicit opt-out of the refresh prompt for a guaranteed fast read.

## 🟢 Happy Path (read first; everything below is edge-case detail)

The default flow auto-detects stale data and prompts to refresh. ~30 sec when nothing's stale, ~5-15 min when something needs refreshing.

**Phase 0 — Freshness check + conditional sub-skill invocation.** Determine which sources need refreshing, then either prompt the user or skip per the flags:

```bash
# Check each source's snapshot age. Threshold = stale_snapshot_days from the priorities-doc
# targets block (Phase 1.5 parses it). Phase 0 runs BEFORE Phase 1.5 — read the value directly
# here so the threshold is a single source of truth across both phases.
PRIORITIES_DOC=~/dev/claude-social-media-skills/youtube-analytics/enterprise_vibe_code_growth_priorities.md
STALE_DAYS=$(awk '/^<!-- flywheel-targets-start -->/{f=1;next} /^<!-- flywheel-targets-end -->/{f=0} f && /^```json/{c=1;next} f && c && /^```/{c=0;next} f && c' "$PRIORITIES_DOC" 2>/dev/null | jq -r '.stale_snapshot_days // 14' 2>/dev/null)
STALE_DAYS=${STALE_DAYS:-14}
STALE_DAYS=${STALE_SNAPSHOT_DAYS:-$STALE_DAYS}  # env-var override still wins for debugging
LN_CACHE=~/dev/claude-social-media-skills/linkedin-stats/cache
BF_CACHE=~/dev/claude-social-media-skills/buffer-stats/cache
TT_CACHE=~/dev/claude-social-media-skills/tiktok-stats/cache
TH_CACHE=~/dev/claude-social-media-skills/threads-stats/cache
YT_DATA=~/dev/claude-social-media-skills/youtube-analytics/data/videos.json

age_days() {
  local f=$1
  [ ! -e "$f" ] && echo 9999 && return
  local now=$(date +%s)
  local mtime=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f")
  echo $(( (now - mtime) / 86400 ))
}

LN_AGE=$(age_days "$(ls -1 $LN_CACHE/snapshot-*.json 2>/dev/null | tail -1)")
BF_AGE=$(age_days "$(ls -1 $BF_CACHE/snapshot-*.json 2>/dev/null | tail -1)")
TT_AGE=$(age_days "$(ls -1 $TT_CACHE/snapshot-*.json 2>/dev/null | tail -1)")
TH_AGE=$(age_days "$(ls -1 $TH_CACHE/snapshot-*.json 2>/dev/null | tail -1)")
YT_AGE=$(age_days "$YT_DATA")

STALE=()
[ "$LN_AGE" -ge "$STALE_DAYS" ] && STALE+=("linkedin-stats (age ${LN_AGE}d)")
[ "$BF_AGE" -ge "$STALE_DAYS" ] && STALE+=("buffer-stats (age ${BF_AGE}d)")
[ "$TT_AGE" -ge "$STALE_DAYS" ] && STALE+=("tiktok-stats (age ${TT_AGE}d)")
[ "$TH_AGE" -ge "$STALE_DAYS" ] && STALE+=("threads-stats (age ${TH_AGE}d)")
[ "$YT_AGE" -ge "$STALE_DAYS" ] && STALE+=("yt-analytics videos.json (age ${YT_AGE}d)")
```

**Routing logic based on flags + staleness:**
- **`--cached`** → skip Phase 0 entirely; mark stale sources in the report and proceed to Phase 1.
- **`--refresh`** → refresh ALL surface sub-skills unconditionally (YouTube, Buffer, LinkedIn, TikTok, Threads — skip the prompt). The canonical "get latest data from every account" run. (Unattended/headless contexts skip the interactive browser scrapes — TikTok/Threads — gracefully.)
- **`--refresh-stale`** → if `STALE[]` is non-empty, refresh those sources without prompting. If empty, skip Phase 0.
- **Plain `/flywheel`** (no flags) → if `STALE[]` is non-empty, surface the list via `AskUserQuestion`:

  > Found N stale source(s): {list with ages}. Refresh now? Default = yes.
  > - **Yes, refresh now (Recommended)** — invoke the listed sub-skills inline before composing the report (~5-15 min depending on what's stale).
  > - **No, use cached and flag in report** — proceed without refreshing; stale sources render as `⚪ stale` in the report.
  > - **Refresh selectively** — pick which sources to refresh (if some are slower than others).

  Default to "Yes, refresh now" if no user input arrives within the AskUserQuestion timeout.

**Phase 0a — Per-source refresh invocation (only if `STALE[]` contains the source):**
- **YouTube** (~3-5 min, no browser, no user attention): `cd ~/dev/claude-social-media-skills/youtube-analytics && go run . fetch && go run . fetch-analytics --all && go run . cohort auto`. Cached at `data/videos.json`.
- **Buffer** (~3-5 min, gstack browser, may need cookie picker click): invoke `/buffer-stats` via the `Skill` tool. The sub-skill writes `~/dev/claude-social-media-skills/buffer-stats/cache/snapshot-<date>.{json,md}` then exits. Auth: `cookie-import-browser chrome buffer.com` if cookies expired — see `Edge: buffer-snapshot-stale`.
- **LinkedIn** (~2-3 min, gstack browser): invoke `/linkedin-stats` via the `Skill` tool. Writes `~/dev/claude-social-media-skills/linkedin-stats/cache/snapshot-<date>.json`. Auth: gstack browser must be logged in to LinkedIn — see `Edge: linkedin-snapshot-stale`.
- **TikTok** (~2-4 min, claude-in-chrome — INTERACTIVE only): invoke `/tiktok-stats` via the `Skill` tool. Scrapes TikTok Studio per-post engagement → `tiktok-stats/cache/snapshot-<date>.json`; resolves the JOIN's `tiktok_business` (`#373`). No headless path — on an unattended cron run, **skip** it (the JOIN stays `pending #373` until a later interactive run). See `Edge: interactive-stats-skipped`.
- **Threads** (~1-2 min, claude-in-chrome — INTERACTIVE only): invoke `/threads-stats` via the `Skill` tool. Scrapes Threads Insights for the live `@mikelady` account → `threads-stats/cache/snapshot-<date>.json`; resolves the JOIN's `threads` (`#375`). Same interactive-only caveat. See `Edge: interactive-stats-skipped`.

If a sub-skill fails (auth lapsed, cookie picker not closed, gstack process dropped), surface the failure clearly and continue with the OTHER sub-skills + cached data for the failed one. **Don't abort the whole flywheel composition over one stale source** — the report still has value with most sources fresh.

**Phase 1 — Resolve window.** Default `DAYS=7`; compute `SINCE` / `UNTIL` and `REPORT=$SNAP_DIR/$(date -u +%Y-%m-%d).md`.

**Phase 1.5 — Load priority targets.** Parse the canonical `flywheel-targets` JSON block from `~/dev/claude-social-media-skills/youtube-analytics/enterprise_vibe_code_growth_priorities.md` and expose every numeric target as a shell variable. The skill **must not** hardcode targets — the priorities doc is the single source of truth so cadence shifts only need to be made there. See `Edge: targets-block-missing-or-malformed` for the fallback behavior.

**Phase 2 — YouTube.** `go run . analyze --since $SINCE` in `~/dev/claude-social-media-skills/youtube-analytics` (reads `data/videos.json` — see `Edge: youtube-videos-json-stale`). Grep the formatted output for streams/long-form/shorts/views/revenue/subs-gained. Compute Priority 1 (`long_form_per_week` against `$P1_MIN`–`$P1_MAX` from the targets block — counts essays + newsletters) + Priority 4 (`livestreams_per_week` against `$P4_PER_WEEK`). **Strategy pivoted 2026-05-18** — see `project_content_strategy_pivot_2026_05_18.md` memory.

**Phase 3 — beehiiv.** Two MCP calls: `beehiiv_stats` (current subs + delta) and `beehiiv_attribution` (source mix). If the tool is missing, hit `Edge: beehiiv-mcp-restart-required`. Compute Priority 2 pace toward 1,800 in 12 months and YouTube attribution %.

**Phase 4 — LinkedIn.** Read the latest `~/dev/claude-social-media-skills/linkedin-stats/cache/snapshot-*.json` (cached only — don't re-scrape). Pull newsletter subs, profile followers, company followers for Priority 3.

**Phase 4.5 — Buffer.** Read the latest `~/dev/claude-social-media-skills/buffer-stats/cache/snapshot-*.json`. Render the buffer-tracked subset as `BF_BUFFER_TRACKED_FOLLOWERS` — never call it the cross-channel total.

**Phase 4.55 — Post-manifests (non-Buffer scheduling).** Walk `~/dev/claude-social-media-skills/youtube-analytics/data/*/`*.json files that match the post-manifest shape (see [`_shared/post-manifest/README.md`](../_shared/post-manifest/README.md)). These hold per-post schedule IDs for content NOT routed through Buffer (`opus-clips` today; future direct-publish skills). Count posts toward Priority 1 throughput; surface conflicts via `pm_conflicts`. Engagement metrics aren't in the manifest — Phase 4.56 fills those in by JOINing against the per-platform stats snapshots. For now the manifest gives an accurate **publication count** that complements `buffer-stats`'s engagement-side view.

**Phase 4.56 — Per-source-content closed-loop JOIN.** For each source-content ID discovered in Phase 4.55 (long-form YouTube IDs, newsletter slugs, GitHub PR refs), call `ca_join_engagement` from `_shared/content-attribution/` to assemble a unified record across every platform (`youtube_shorts`, `linkedin_personal`, `instagram_business`, `facebook_page`, `linkedin_page`, `tiktok_business`, etc.). Aggregate `source_engagement` + `derived_engagement` per source; compute `amplification_ratio = derived_reach / source_reach`. Render as "Per-source-content closed-loop attribution" section in the report; persist the array as `content_attribution[]` in the JSON snapshot for week-over-week diffing. **Credits derivative engagement back to Priority 1** — a long-form essay's true throughput value is source + every derivative. See `Edge: content-attribution-module-missing` and `Edge: zero-derivatives-for-source`. Depends on tasks **#381** (the `_shared/content-attribution/` module) and **#377** (buffer-stats Insights coverage of all 6 channels) landing first; until then Phase 4.56 degrades gracefully.

**Phase 4.7 — Cross-source reconciliation.** Compose the cross-channel reach table from the authoritative source per channel (LinkedIn personal/page → linkedin-stats; IG/FB → buffer-stats; YouTube → yt-analytics; beehiiv → beehiiv-mcp; **Threads → threads-stats; TikTok → tiktok-stats**). Annotate each row with its source.

**Phase 4.6 — Channel ROI.** Per Buffer-connected channel: `channel_roi_score = (avg_impressions_per_post * eng_rate * 100) / (sent_count + 1)`. Bucket into 🟢/🟡/🔴/⚪ and render the ROI table.

**Phase 5 — Consulting pipeline.** `(cd ~/dev/consulting-log && ./cl json)` (local-only — see `Edge: consulting-log-local-only`). Aggregate pipeline / realized revenue / content gaps for Priority 5.

**Phase 5.5 — Compound (cross-surface hypothesis ledger).** This is the closed-loop's *learning* step — generalize YouTube's predict→grade→learn ledger to the whole presence. Runs the **same tested binary** (`youtube-analytics insights`) pointed at the repo-level ledger `~/dev/claude-social-media-skills/insights/` via `--ledger-dir`. **Flags must precede the positional id/date** (Go stdlib `flag` quirk — see `yt-analytics` SKILL.md). Degrades gracefully: if the binary or ledger is missing, note it and skip (don't abort the run). See `Edge: insights-ledger-missing`.

```bash
LED=~/dev/claude-social-media-skills/insights
YT=~/dev/claude-social-media-skills/youtube-analytics
# 1) GRADE last week's predictions using THIS run's numbers (content_attribution[],
#    format_engagement, channel_roi[] computed in Phases 4.5–4.7). The MODEL judges
#    the verdict — the binary only persists it.
( cd "$YT" && go run . insights pending --ledger-dir "$LED" --as-of "$(date -u +%Y-%m-%d)" )
#    For each pending hypothesis, decide confirm|refute|inconclusive from the data above, then:
# ( cd "$YT" && go run . insights grade --ledger-dir "$LED" --verdict <v> --outcome "<what the data showed>" <id> )
# 2) WRITE 2–4 new hypotheses for next cycle, grounded in this run's data
( cd "$YT" && go run . insights new --ledger-dir "$LED" "$(date -u -v+7d +%Y-%m-%d 2>/dev/null || date -u -d '+7 days' +%Y-%m-%d)" )
#    Then edit the new <date>.md frontmatter: each hypothesis sets surface
#    (e.g. format:carousel, source/<id>, linkedin, cross), metric, direction,
#    evaluate_after, prediction — drawn from the highest-amplification source in
#    content_attribution[], the winning format in format_engagement, or a
#    channel_roi[] bucket transition. NO evidence_video_ids needed for
#    cross-surface hypotheses.
```

Surface the graded verdicts + new hypotheses in the report's "Compounding" section (Phase 6). This is what makes the system **compound** — predictions get scored, and the score informs next week's compose decisions.

**Phase 6 — Compose report.** Write the fixed-structure markdown to `$REPORT` and print to stdout. Then **always** write the parallel `$SNAP_DIR/<date>.json` per the **frozen contract** in Phase 7 (every top-level key present — `schema_version`, `channel_roi[]`, `format_engagement`, `reconciled_reach[]`, `voice_corpus_freshness`, `content_attribution[]` — empty/null when a phase degraded). Read `voice_corpus_freshness` from `_shared/voice-corpus/cache.json` (`voice-corpus --print-only`). The `_shared/dashboard/` single-pane app reads this snapshot live — it is the dashboard's source of truth, so a complete snapshot is not optional.

**Phase 7 — Week-over-week diff.** If `$SNAP_DIR/$(date -v-7d).md` exists, diff key numbers into the "Week-over-week delta" section.

## Data sources

| Source | How | What it gives |
|---|---|---|
| YouTube | `go run . analyze --since <date>` in `~/dev/claude-social-media-skills/youtube-analytics` | streams/week, long-form count, views, revenue, subs |
| beehiiv list | `mcp__beehiiv__beehiiv_stats` | current subscriber count |
| beehiiv attribution | `mcp__beehiiv__beehiiv_attribution` | source mix (YouTube vs LinkedIn vs direct) |
| LinkedIn | `~/dev/claude-social-media-skills/linkedin-stats/cache/snapshot-<latest>.json` | newsletter subs, profile + page followers |
| Buffer | `~/dev/claude-social-media-skills/buffer-stats/cache/snapshot-<latest>.json` | per-channel followers/engagement for IG/FB fan-out + queue health |
| TikTok | `~/dev/claude-social-media-skills/tiktok-stats/cache/snapshot-<latest>.json` (via `/tiktok-stats` scrape) | per-post TikTok engagement → JOIN `tiktok_business` |
| Threads | `~/dev/claude-social-media-skills/threads-stats/cache/snapshot-<latest>.json` (via `/threads-stats` scrape) | per-post Threads engagement + followers (live `@mikelady`) → JOIN `threads` |
| Consulting | `(cd ~/dev/consulting-log && ./cl json)` | pipeline stages, realized revenue, content gaps |

If any source fails or is stale, note it in the report — don't silently drop the row.

## Process

### Phase 0 — Freshness check + conditional refresh (default behavior)

**As of 2026-05-18, plain `/flywheel` no longer skips this phase.** It checks each source's snapshot age, lists stale ones, and prompts the user (default = yes) before invoking sub-skills. Use `--cached` to opt out of the freshness check.

**Decision flow:**

| Invocation | Freshness check? | If stale found | If all fresh |
|---|---|---|---|
| `/flywheel` | yes | prompt user (default yes) → refresh + compose | skip Phase 0 → compose immediately |
| `/flywheel --refresh` | no | force-refresh all surfaces (YT, Buffer, LinkedIn, TikTok, Threads) | force-refresh all surfaces |
| `/flywheel --refresh-stale` | yes | refresh stale without prompting | skip Phase 0 → compose |
| `/flywheel --cached` | skipped | use cached + flag in report | skip Phase 0 → compose |

**Freshness check implementation:** compare each source's snapshot `mtime` against `stale_snapshot_days` (default 14, configurable per Phase 0 of the Happy Path). Sources to check:
- `~/dev/claude-social-media-skills/youtube-analytics/data/videos.json` → YouTube freshness
- `~/dev/claude-social-media-skills/buffer-stats/cache/snapshot-*.json` (newest) → Buffer freshness
- `~/dev/claude-social-media-skills/linkedin-stats/cache/snapshot-*.json` (newest) → LinkedIn freshness

(Consulting log is not snapshotted — it's read live from markdown each run, never stale by definition.)

**Prompt format (when stale sources found on plain `/flywheel`):**

Surface via `AskUserQuestion` with a single question and the list of stale sources inline:

> "Found {N} stale source(s): {LinkedIn (age 15d), Buffer (age 8d), ...}. Refresh now?"
>
> - Yes, refresh now (Recommended) — invoke sub-skills inline, ~5-15 min depending on what's stale
> - No, use cached and flag in report — proceed immediately with stale data clearly marked
> - Refresh selectively — pick which sources to refresh (presented as multi-select sub-question)

The recommended option (yes) is the default. If the user has set `AUTO_DECIDE` for this question via `/plan-tune`, accept the default.

**Sub-skill invocation order (only invoke sources actually flagged stale OR selected by user):**

Each has its own auth + scrape; they don't share session:

1. **YouTube data refresh** (~3-5 min, no browser):
   ```bash
   cd ~/dev/claude-social-media-skills/youtube-analytics
   go run . fetch                       # video metadata, snapshots automatically
   go run . fetch-analytics --all       # aggregate + per-day + traffic-sources + sub-status
   go run . cohort auto                 # refresh cohort assignments from rules
   ```
   Cached at `data/videos.json` + `data/snapshots/videos-<UTC>.json`.

2. **Buffer engagement refresh** (~3-5 min, gstack browser):
   Invoke `/buffer-stats` skill. It writes `~/dev/claude-social-media-skills/buffer-stats/cache/snapshot-<date>.{json,md}`.
   Auth: `cookie-import-browser chrome buffer.com` (one-time picker click); cookies carry from buffer.com to publish.buffer.com and analyze.buffer.com.

3. **LinkedIn refresh** (~2-3 min, gstack browser):
   Invoke `/linkedin-stats` skill (or scrape `linkedin.com/dashboard` directly for the headline numbers if the full skill isn't required). Writes `~/dev/claude-social-media-skills/linkedin-stats/cache/snapshot-<date>.json`.
   Auth: gstack browser must be logged in to LinkedIn (cookies usually carry from a prior session).

4. **TikTok refresh** (~2-4 min, claude-in-chrome — interactive only):
   Invoke `/tiktok-stats` skill. Scrapes TikTok Studio (`tiktok.com/tiktokstudio/content`) per-post engagement → `~/dev/claude-social-media-skills/tiktok-stats/cache/snapshot-<date>.json`, resolving the JOIN's `tiktok_business` (`#373`).
   Auth: logged into TikTok in the browser. **No headless path** — skip on unattended runs.

5. **Threads refresh** (~1-2 min, claude-in-chrome — interactive only):
   Invoke `/threads-stats` skill. Scrapes Threads Insights (`threads.com/insights`) for the live `@mikelady` account → `~/dev/claude-social-media-skills/threads-stats/cache/snapshot-<date>.json`, resolving the JOIN's `threads` (`#375`). (`@enterprisevibecode` is dead/paused, #588 — don't scrape it.)
   Auth: logged into Threads. **No headless path** — skip on unattended runs.

6. **YouTube weekly review** (closed-loop, optional):
   ```bash
   cd ~/dev/claude-social-media-skills/youtube-analytics
   go run . insights pending            # past-due hypotheses; grade them in the report's narrative
   go run . cohort report --since <last-monday>
   ```

If a sub-skill fails (auth lapsed, cookie picker not closed, gstack process dropped), surface the failure clearly and continue with the OTHER sub-skills + cached data for the failed one. Don't abort the whole flywheel composition over one stale source.

After Phase 0 completes (or is skipped), proceed to Phase 1 with the freshly-written cache files in scope.

### Phase 1 — Resolve window

```bash
DAYS=${DAYS:-7}
UNTIL=$(date -u +%Y-%m-%d)
SINCE=$(date -v-${DAYS}d -u +%Y-%m-%d 2>/dev/null || date -d "$DAYS days ago" -u +%Y-%m-%d)
SNAP_DIR=~/dev/flywheel-snapshots
mkdir -p "$SNAP_DIR"
REPORT="$SNAP_DIR/$(date -u +%Y-%m-%d).md"
```

### Phase 1.5 — Load priority targets from priorities doc

The priorities doc carries a fenced JSON block between `<!-- flywheel-targets-start -->` and `<!-- flywheel-targets-end -->` anchors. Parse it and expose every value as a shell variable so the rest of the skill never hardcodes a cadence target.

```bash
PRIORITIES_DOC=~/dev/claude-social-media-skills/youtube-analytics/enterprise_vibe_code_growth_priorities.md

TARGETS_JSON=$(awk '
  /^<!-- flywheel-targets-start -->/ {flag=1; next}
  /^<!-- flywheel-targets-end -->/   {flag=0}
  flag && /^```json/                 {in_code=1; next}
  flag && in_code && /^```/          {in_code=0; next}
  flag && in_code                    {print}
' "$PRIORITIES_DOC")

if [ -z "$TARGETS_JSON" ] || ! printf '%s' "$TARGETS_JSON" | jq empty 2>/dev/null; then
  # Edge: targets-block-missing-or-malformed — fall back to embedded defaults so
  # /flywheel keeps working even if the priorities doc is mid-edit. The report
  # MUST surface this fallback so the user knows the numbers aren't authoritative.
  TARGETS_JSON='{
    "stale_snapshot_days": 14,
    "priority_1": {"target_min": 2, "target_max": 3},
    "priority_2": {"target_total": 1800, "target_horizon_weeks": 52,
                   "yt_attribution_healthy_pct": 50, "yt_attribution_worrying_pct": 30},
    "priority_3": {"target_per_week": 1},
    "priority_4": {"target_per_week": 1, "fallback_long_form_min": 3},
    "priority_5": {"target_gaps": 0, "yellow_threshold": 1, "red_threshold": 3},
    "channel_roi": {"high_threshold": 100, "mid_threshold": 10, "below_followers_threshold": 50}
  }'
  TARGETS_FALLBACK=1
fi

STALE_SNAPSHOT_DAYS=$(printf '%s' "$TARGETS_JSON" | jq -r '.stale_snapshot_days')
P1_MIN=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_1.target_min')
P1_MAX=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_1.target_max')
P2_TOTAL=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_2.target_total')
P2_WEEKS=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_2.target_horizon_weeks')
P2_YT_HEALTHY=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_2.yt_attribution_healthy_pct')
P2_YT_WORRY=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_2.yt_attribution_worrying_pct')
P3_PER_WEEK=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_3.target_per_week')
P4_PER_WEEK=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_4.target_per_week')
P4_FALLBACK_LF=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_4.fallback_long_form_min')
P5_YELLOW=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_5.yellow_threshold')
P5_RED=$(printf '%s' "$TARGETS_JSON" | jq -r '.priority_5.red_threshold')
ROI_HIGH=$(printf '%s' "$TARGETS_JSON" | jq -r '.channel_roi.high_threshold')
ROI_MID=$(printf '%s' "$TARGETS_JSON" | jq -r '.channel_roi.mid_threshold')
ROI_BELOW=$(printf '%s' "$TARGETS_JSON" | jq -r '.channel_roi.below_followers_threshold')
```

Two consequences for the rest of the skill:
- **Every later phase reads `$P1_MIN`/`$P1_MAX`/…/`$ROI_BELOW`** instead of literal numbers. If you find yourself typing `2-3/week` or `1,800` into status logic, you're doing it wrong — reference the variable so the priorities doc stays the single source of truth.
- **Phase 0's `STALE_DAYS` should read from `$STALE_SNAPSHOT_DAYS`** if Phase 1.5 has already run; otherwise the env-var override default applies as before.

If `$TARGETS_FALLBACK=1`, prepend the rendered report with a warning line so the user notices:

```markdown
> ⚠ Targets block missing or malformed in priorities doc — using embedded defaults. Fix `~/dev/claude-social-media-skills/youtube-analytics/enterprise_vibe_code_growth_priorities.md` and re-run.
```

### Phase 2 — YouTube analytics

Run the existing CLI and capture its report:

```bash
YT_REPORT=$(cd ~/dev/claude-social-media-skills/youtube-analytics && go run . analyze --since "$SINCE" 2>&1)
```

Extract key numbers from the output:
- total streams in window
- total long-form in window (non-shorts, non-live)
- total shorts in window
- views
- revenue
- net subs gained

The `analyze` output is human-formatted — use simple grep/awk to pluck numbers. If the format changes, fall back to counting video entries in `data/videos.json` directly with jq.

**Priority 1 check** (long-form `$P1_MIN`–`$P1_MAX`/week — pivoted 2026-05-18 from "streams 3-4×/week"):
- `long_form_per_week = (actual_long_form_videos + actual_newsletters) / DAYS * 7`
- Newsletters count toward this — long-form essays and newsletters are the same priority. Pull newsletter count from beehiiv stats (Phase 3): `new_subs_in_window > 0 OR recent_posts contains item in window`.
- target: `$P1_MIN`–`$P1_MAX`/week combined long-form output (essays + newsletters)
- status: on_track if `≥ $P1_MIN`, behind otherwise
- **Derivative-credited variant (preferred when Phase 4.56 ran successfully):** a long-form's value is source + every derivative. After Phase 4.56 emits `content_attribution[]`, recompute `derivative_credited_throughput = long_form_count + (sum over sources of (clamp(amplification_ratio, 0, 3) - 1))` — i.e. high-amplification long-forms count for up to 3× their base value, capped so a single viral clip can't single-handedly satisfy the target. Surface BOTH numbers in the report (raw count + derivative-credited). Verdict precedence: if raw count `≥ $P1_MIN` → 🟢 regardless. If raw count `< $P1_MIN` but derivative-credited `≥ $P1_MIN` → 🟡 "throughput soft, but derivatives compensate — keep stacking clips on existing long-forms before forcing a new one." If both `< $P1_MIN` → 🔴.

**Priority 4 check** (`$P4_PER_WEEK` livestream/week as community surface — pivoted 2026-05-18 from "long-form 2-3/week"):
- `streams_per_week = actual_lives / DAYS * 7`
- target: `$P4_PER_WEEK`/week (was 3-4/week pre-2026-05-18)
- status: on_track if `≥ $P4_PER_WEEK`, OR if `long_form_per_week ≥ $P4_FALLBACK_LF` (the priority is "keep the surface alive"; if long-form output is strong, skipping the stream is fine)
- Skipping streams entirely for >2 consecutive weeks should flag as 🟡 (not 🔴 — Priority 1 is the primary now)

### Phase 3 — beehiiv

Two tool calls:

```
mcp__beehiiv__beehiiv_stats (window_days: DAYS)
mcp__beehiiv__beehiiv_attribution (window_days: DAYS)
```

From `beehiiv_stats`:
- current subscriber count
- `delta_count` vs last snapshot (if history_sufficient)

From `beehiiv_attribution`:
- total new subs in window
- % from YouTube (the Priority 2 success metric)

**Priority 2 check** (push viewers to Beehiiv):
- target trajectory: from today's count to `$P2_TOTAL` in `$P2_WEEKS` weeks
- needed per week = (`$P2_TOTAL` - current) / `$P2_WEEKS` weeks
- actual this window = attribution.total_subs_in_window
- status: on_track if actual ≥ needed, behind otherwise
- Also note: youtube % of new subs (healthy if `≥ $P2_YT_HEALTHY%`, worrying if `< $P2_YT_WORRY%`)

### Phase 4 — LinkedIn

Read the latest cached snapshot instead of re-scraping every run (LinkedIn scraping is slow + interactive):

```bash
LN_CACHE=~/dev/claude-social-media-skills/linkedin-stats/cache
LATEST_LN=$(ls -1 "$LN_CACHE"/snapshot-*.json 2>/dev/null | tail -1)
if [ -n "$LATEST_LN" ]; then
  LN_NL_SUBS=$(jq -r .newsletter.subscribers "$LATEST_LN")
  LN_NL_VIEWS_7D=$(jq -r '.newsletter.article_views_7d // "n/a"' "$LATEST_LN")
  LN_NL_IMPS_7D=$(jq -r '.newsletter.impressions_7d // "n/a"' "$LATEST_LN")
  LN_PROFILE_FOLLOWERS=$(jq -r .profile.followers "$LATEST_LN")
  LN_COMPANY_FOLLOWERS=$(jq -r .company.followers "$LATEST_LN")
  LN_SNAP_DATE=$(basename "$LATEST_LN" .json | sed 's/snapshot-//')
else
  LN_NL_SUBS="unknown — run /linkedin-stats"
fi
```

If the latest LinkedIn snapshot is older than `$STALE_SNAPSHOT_DAYS` (from the targets block), flag it — stale LinkedIn data is less useful than no LinkedIn data.

**Newsletter platform comparison (recurring metric, added 2026-05-20).** Both newsletters carry the SAME weekly content; compare them head-to-head to track which platform's audience is actually engaged. Read `linkedin-stats/cache/newsletter-platform-comparison.json` (refreshed by `/linkedin-stats` + the beehiiv MCP):

```bash
CMP=~/dev/claude-social-media-skills/linkedin-stats/cache/newsletter-platform-comparison.json
if [ -f "$CMP" ]; then
  BH_SUBS=$(jq -r .current.beehiiv_subs "$CMP")
  LI_SUBS=$(jq -r .current.linkedin_newsletter_subs "$CMP")
  # Engagement-per-subscriber proxy: beehiiv opens-on-latest vs LinkedIn 7d article views.
  # The headline insight to surface: LinkedIn has the bigger list, beehiiv has the engaged one.
  BH_LATEST_OPENS=$(jq -r '.per_issue[0].beehiiv_opens' "$CMP")
  LI_VIEWS_7D=$(jq -r '.current.linkedin_7d_article_views' "$CMP")
fi
```

The comparison is NOT 1:1 (beehiiv = email opens/clicks; LinkedIn = public reactions/comments + article views) — render both columns side by side, never sum them. **LinkedIn has NO historical sub-count timeseries** (only current); beehiiv `recipients` per issue IS its sub-growth curve. Don't fabricate a LinkedIn growth curve.

**Priority 3 check** (cross-post newsletter to LinkedIn weekly):
- requires evidence that a LinkedIn article was published in the window
- heuristic: newsletter subscriber count increased ≥N since last snapshot → posting is active
- **engagement-quality signal:** if `linkedin_newsletter_subs > beehiiv_subs` BUT `beehiiv_opens >> linkedin_article_views`, surface that LinkedIn is the *feeder* (vanity-larger, low read-through) and beehiiv is the *owned engaged audience*. This reinforces Priority 2's "push to beehiiv" — the LinkedIn list's value is funneling to beehiiv, not as a destination.
- if user wants per-issue history, it's in `newsletter-platform-comparison.json` (`.per_issue[]`, all 13 editions paired by title)

### Phase 4.5 — Buffer (IG/FB/Threads fan-out)

Read the latest cached Buffer snapshot. Don't re-run `/buffer-stats` here — it's slow (scrapes Buffer Analyze) and the user runs it weekly:

```bash
BF_CACHE=~/dev/claude-social-media-skills/buffer-stats/cache
LATEST_BF=$(ls -1 "$BF_CACHE"/snapshot-*.json 2>/dev/null | tail -1)
if [ -n "$LATEST_BF" ]; then
  BF_SNAP_DATE=$(basename "$LATEST_BF" .json | sed 's/snapshot-//')
  # CRITICAL: distinguish "channels Buffer Analyze can scrape engagement for"
  # (subset — only FB pages, IG business, LinkedIn pages — NOT LinkedIn personal,
  # NOT Threads) from "channels we post to" (full set, includes everything).
  # Conflating these caused the 2026-05-03 flywheel report to show
  # total_followers=26 when actual was ~2,200 across all surfaces.
  BF_ENGAGEMENT_TRACKED_CHANNELS=$(jq -r '.engagement_tracked_channels // (.channels | length)' "$LATEST_BF")
  BF_POSTING_CHANNELS=$(jq -r '.posting_channels // .channels_active // (.channels | length)' "$LATEST_BF")
  BF_BUFFER_TRACKED_FOLLOWERS=$(jq -r '[.channels[].engagement.followers // 0] | add' "$LATEST_BF")
  BF_TOTAL_FOLLOWERS_DELTA=$(jq -r '[.channels[].engagement.followers_delta // 0] | add' "$LATEST_BF")
  BF_AVG_ENG_RATE=$(jq -r '[.channels[].engagement.engagement_rate // 0] | add / length' "$LATEST_BF")
  BF_TOP_POST=$(jq -r '.top_posts[0] | "\(.service): \(.text_snippet) (\(.engagement) engagement)"' "$LATEST_BF")
  # Stale-data flag (same $STALE_SNAPSHOT_DAYS threshold as LinkedIn, from the targets block)
  BF_STALE=$(( $(date -u +%s) - $(date -j -f "%Y-%m-%d" "$BF_SNAP_DATE" +%s 2>/dev/null || date -d "$BF_SNAP_DATE" +%s) > STALE_SNAPSHOT_DAYS*86400 ))
else
  BF_BUFFER_TRACKED_FOLLOWERS=""; BF_STALE=1
fi
```

**Render the buffer-tracked subset as `BF_BUFFER_TRACKED_FOLLOWERS`, NOT as a channel-wide total.** The difference matters: today (2026-05-03) `BF_BUFFER_TRACKED_FOLLOWERS=26` (FB page + IG business + LinkedIn page only) but the actual cross-channel follower count is ~2,200 (LinkedIn personal alone is 2,104). Reporting "Total followers: 26" misleads.

If the latest Buffer snapshot is older than `$STALE_SNAPSHOT_DAYS` (from the targets block), flag it. Note that Buffer is the fan-out layer (Priority 2's "push viewers to Beehiiv" uses Buffer as the distribution surface for IG/FB/Threads), so its health informs Priority 2's attribution mix — if IG/Threads followers are growing but beehiiv attribution shows 0% from those surfaces, that's a link-in-bio / call-to-action problem, not a Buffer problem.

### Phase 4.55 — Post-manifest publication count

Walk every JSON file under `~/dev/claude-social-media-skills/youtube-analytics/data/*/` that matches the post-manifest shape (top-level `clips[]` or `posts[]` array with `scheduled_posts[]` entries — see [`_shared/post-manifest/README.md`](../_shared/post-manifest/README.md)). Use the `pm_*` helpers to count throughput and surface conflicts:

```bash
source ~/dev/claude-social-media-skills/_shared/post-manifest/post_manifest.sh

PM_TOTAL_SCHEDULED=0
PM_CONFLICT_COUNT=0
PM_SOURCES=()  # array of "manifest_path::source_type::source_id" tuples for Phase 4.56

shopt -s nullglob
for MANIFEST in ~/dev/claude-social-media-skills/youtube-analytics/data/*/*.json; do
  # Accept only manifests with the expected shape — skip yt-analytics videos.json etc.
  jq -e '.clips? // .posts? // empty | type == "array"' "$MANIFEST" >/dev/null 2>&1 || continue

  PM_TOTAL_SCHEDULED=$(( PM_TOTAL_SCHEDULED + $(pm_count_scheduled "$MANIFEST") ))
  PM_CONFLICT_COUNT=$(( PM_CONFLICT_COUNT + $(pm_conflicts "$MANIFEST" | jq 'length') ))

  # Pull the source-content ID for the Phase 4.56 JOIN. opus-clips manifests
  # have `source_video.id`; future linkedin_pulses / crosspost manifests will
  # have `source_pulse.slug` / `source_article.url` — be permissive.
  SRC_ID=$(jq -r '.source_video.id // .source_pulse.slug // .source_article.id // empty' "$MANIFEST")
  if [ -n "$SRC_ID" ]; then
    # Source type inferred from parent directory (opus_clips/, linkedin_pulses/, ...)
    SRC_TYPE=$(basename "$(dirname "$MANIFEST")")
    PM_SOURCES+=("$MANIFEST::$SRC_TYPE::$SRC_ID")
  fi
done
```

`PM_TOTAL_SCHEDULED` and `PM_CONFLICT_COUNT` feed the report's appendix; `PM_SOURCES[]` is the input for Phase 4.56.

**Closed-loop-id coverage health (added 2026-05-31).** A manifest entry with no captured `scheduleId` is dark to the Phase 4.56 JOIN — the standing "always capture closed-loop ids" rule. Run the lint so coverage gaps surface every week instead of silently undercounting derivative reach:

```bash
PMTOOL=~/dev/claude-social-media-skills/_shared/post-manifest/pm-tool/pm-tool
[ -x "$PMTOOL" ] || ( cd "$(dirname "$PMTOOL")" && go build -o pm-tool . ) 2>/dev/null
PM_IDLINT=$("$PMTOOL" lint --root ~/dev/claude-social-media-skills/youtube-analytics/data/opus_clips 2>&1)
PM_IDLINT_RC=$?
# exit 65 = RECOVERABLE gaps (pending posts missing a scheduleId) → surface as a
#           🟡 health line AND a fix nudge: `pm-tool backfill --manifest <p>`.
# exit 0  = no recoverable gaps (stale/fired gaps are reported but don't fail).
```

Render one line in the report's appendix: `Closed-loop IDs: <ok|N recoverable gaps> (M stale, fired-without-id)`. A non-zero `PM_IDLINT_RC` is a 🟡 — the data is recoverable with `pm-tool backfill` while the posts are still pending.

### Phase 4.56 — Per-source-content closed-loop JOIN

For each source-content ID discovered in Phase 4.55, call `ca_join_engagement` (from `_shared/content-attribution/`, built by task #381) to produce a unified per-source record covering every derivative across every platform. The JOIN engine is responsible for the actual `[scheme:id]` / `scheduleId` / time-window correlation logic — **flywheel does not implement it here**, it only orchestrates and aggregates.

**Module dependency check (Edge: content-attribution-module-missing):**

```bash
CA_DIR=~/dev/claude-social-media-skills/_shared/content-attribution
CA_BIN="$CA_DIR/content-attribution"
# Build the Go binary if it's not on disk (gitignored — built per-machine).
if [ ! -x "$CA_BIN" ] && [ -f "$CA_DIR/main.go" ]; then
  ( cd "$CA_DIR" && go build -o content-attribution . ) 2>/dev/null
fi
if [ ! -x "$CA_BIN" ]; then
  CA_AVAILABLE=0
  CA_SKIP_REASON="_shared/content-attribution/ binary missing and build failed — task #381/#383"
else
  CA_AVAILABLE=1
fi
```

`content-attribution` is a **Go binary** (rewritten from bash 2026-05-19, task #383) — it runs identically under any shell, so no `bash -c` wrapper or sourcing is needed. Just call it: `content-attribution join --source-id <id>`. The binary is gitignored (built per-machine like `voice-corpus`), hence the build-if-missing guard above.

If `CA_AVAILABLE=0`, skip the entire phase, render a stub section in the report (`> ⚠ Per-source-content attribution unavailable: <reason>. Land task #381 to enable.`), and emit `content_attribution: []` in the JSON snapshot. **Do not fail the whole `/flywheel` run** — the rest of the report still has value.

**JOIN execution (when module is present):**

```bash
CONTENT_ATTR_JSON='[]'  # accumulator — jq-mergeable array of per-source records

if [ "$CA_AVAILABLE" = "1" ]; then
  for entry in "${PM_SOURCES[@]}"; do
    MANIFEST="${entry%%::*}"
    REST="${entry#*::}"
    SRC_TYPE="${REST%%::*}"
    SRC_ID="${REST#*::}"

    # ca_join_engagement reads:
    #   - the post-manifest at $MANIFEST (for derivative IDs + scheduleIds)
    #   - ~/dev/claude-social-media-skills/youtube-analytics/data/videos.json (for YouTube Shorts metrics)
    #   - ~/dev/claude-social-media-skills/buffer-stats/cache/snapshot-*.json (per-post engagement)
    #   - ~/dev/claude-social-media-skills/linkedin-stats/cache/snapshot-*.json (per-post engagement)
    #   - any other *-stats/cache/snapshot-*.json available
    # ...and emits a single JSON record matching the shape in CLOSED-LOOP-UNIFICATION-PLAN.md
    # (source{}, derivatives[], source_engagement{}, derived_engagement{}, amplification_ratio).
    # Go binary — shell-agnostic, call directly (no bash -c, no sourcing).
    REC=$("$CA_BIN" join --source-type "$SRC_TYPE" --source-id "$SRC_ID" --manifest "$MANIFEST" 2>/dev/null)
    [ -z "$REC" ] || ! printf '%s' "$REC" | jq -e . >/dev/null 2>&1 && continue

    # Edge: zero-derivatives-for-source — a long-form that produced no clips at all
    # (e.g. an essay we haven't fanned out yet). Surface but don't error — render
    # in the report with status="no derivatives yet" so the user can see the gap.
    DERIV_COUNT=$(printf '%s' "$REC" | jq '.derivatives | length')
    if [ "$DERIV_COUNT" = "0"

…(truncated)
