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.
30 sec) and read-only. Use for "where do we stand right now" without paying the refresh cost.
/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 (/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:
# 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→ ifSTALE[]is non-empty, refresh those sources without prompting. If empty, skip Phase 0.Plain
/flywheel(no flags) → ifSTALE[]is non-empty, surface the list viaAskUserQuestion: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
⚪ stalein 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 atdata/videos.json. - Buffer (
3-5 min, gstack browser, may need cookie picker click): invoke/dev/claude-social-media-skills/buffer-stats/cache/snapshot-.{json,md}/buffer-statsvia theSkilltool. The sub-skill writes `then exits. Auth:cookie-import-browser chrome buffer.comif cookies expired — seeEdge: buffer-snapshot-stale`. - LinkedIn (
2-3 min, gstack browser): invoke/dev/claude-social-media-skills/linkedin-stats/cache/snapshot-.json/linkedin-statsvia theSkilltool. Writes `. Auth: gstack browser must be logged in to LinkedIn — seeEdge: linkedin-snapshot-stale`. - TikTok (~2-4 min, claude-in-chrome — INTERACTIVE only): invoke
/tiktok-statsvia theSkilltool. Scrapes TikTok Studio per-post engagement →tiktok-stats/cache/snapshot-<date>.json; resolves the JOIN'stiktok_business(#373). No headless path — on an unattended cron run, skip it (the JOIN stayspending #373until a later interactive run). SeeEdge: interactive-stats-skipped. - Threads (~1-2 min, claude-in-chrome — INTERACTIVE only): invoke
/threads-statsvia theSkilltool. Scrapes Threads Insights for the live@mikeladyaccount →threads-stats/cache/snapshot-<date>.json; resolves the JOIN'sthreads(#375). Same interactive-only caveat. SeeEdge: 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). 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.
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) |
~/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:
YouTube data refresh (~3-5 min, no browser):
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 rulesCached at
data/videos.json+data/snapshots/videos-<UTC>.json.Buffer engagement refresh (
3-5 min, gstack browser): Invoke/dev/claude-social-media-skills/buffer-stats/cache/snapshot-.{json,md}/buffer-statsskill. It writes `. Auth:cookie-import-browser chrome buffer.com` (one-time picker click); cookies carry from buffer.com to publish.buffer.com and analyze.buffer.com.LinkedIn refresh (
2-3 min, gstack browser): Invoke/dev/claude-social-media-skills/linkedin-stats/cache/snapshot-.json`. Auth: gstack browser must be logged in to LinkedIn (cookies usually carry from a prior session)./linkedin-statsskill (or scrapelinkedin.com/dashboarddirectly for the headline numbers if the full skill isn't required). Writes `TikTok refresh (
2-4 min, claude-in-chrome — interactive only): Invoke/dev/claude-social-media-skills/tiktok-stats/cache/snapshot-.json/tiktok-statsskill. Scrapes TikTok Studio (tiktok.com/tiktokstudio/content) per-post engagement → `, resolving the JOIN'stiktok_business(#373`). Auth: logged into TikTok in the browser. No headless path — skip on unattended runs.Threads refresh (
1-2 min, claude-in-chrome — interactive only): Invoke/dev/claude-social-media-skills/threads-stats/cache/snapshot-.json/threads-statsskill. Scrapes Threads Insights (threads.com/insights) for the live@mikeladyaccount → `, resolving the JOIN'sthreads(#375). (@enterprisevibecode` is dead/paused, #588 — don't scrape it.) Auth: logged into Threads. No headless path — skip on unattended runs.YouTube weekly review (closed-loop, optional):
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
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.
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_BELOWinstead of literal numbers. If you find yourself typing2-3/weekor1,800into status logic, you're doing it wrong — reference the variable so the priorities doc stays the single source of truth. - Phase 0's
STALE_DAYSshould read from$STALE_SNAPSHOT_DAYSif 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:
> ⚠ 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:
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[], recomputederivative_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_MINbut 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 iflong_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_countvs 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_TOTALin$P2_WEEKSweeks - needed per week = (
$P2_TOTAL- current) /$P2_WEEKSweeks - 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):
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):
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_subsBUTbeehiiv_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:
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). Use the pm_* helpers to count throughput and surface conflicts:
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:
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):
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):
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)