diff --git a/plugins/linkedin-studio/CLAUDE.md b/plugins/linkedin-studio/CLAUDE.md index a4ddb0e..fc75e62 100644 --- a/plugins/linkedin-studio/CLAUDE.md +++ b/plugins/linkedin-studio/CLAUDE.md @@ -32,9 +32,9 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide **Hook editing:** Edit `hooks/hooks.template.json` + `hooks/prompts/*.md`, then run `python3 hooks/scripts/compile-hooks.py`. Do not edit `hooks.json` directly. Prompts are loaded at runtime by gatekeeper scripts; the compile step is only needed when adding `type: prompt` hooks. -## Commands (26) +## Commands (27) -All content commands (post, quick, react, pipeline, first-post, video, multiplatform, carousel, newsletter) auto-copy output to clipboard via `clipboard-helper.mjs`. Interactive steps are minimized — angle, format, and post type are inferred from context, with max 2 questions per post. **v2.0.0 net change:** 5 commands removed (`templates`, `publish`, `authority`, `collab`, `speaking` — absorbed into `quick`, `calendar`, `strategy`, `outreach` respectively) + 2 commands added (`newsletter`, `outreach`) = 27 → 24. **v3.1.0** adds 2 longform companions (`headless-review`, `pivot`) = 24 → 26. +All content commands (post, quick, react, pipeline, first-post, video, multiplatform, carousel, newsletter) auto-copy output to clipboard via `clipboard-helper.mjs`. Interactive steps are minimized — angle, format, and post type are inferred from context, with max 2 questions per post. **v2.0.0 net change:** 5 commands removed (`templates`, `publish`, `authority`, `collab`, `speaking` — absorbed into `quick`, `calendar`, `strategy`, `outreach` respectively) + 2 commands added (`newsletter`, `outreach`) = 27 → 24. **v3.1.0** adds 2 longform companions (`headless-review`, `pivot`) = 24 → 26. **Remediation Step 16** adds `firsthour` (wiring orphan agent #11 `engagement-coach`) = 26 → 27. | Command | Purpose | |---------|---------| @@ -51,6 +51,7 @@ All content commands (post, quick, react, pipeline, first-post, video, multiplat | `/linkedin:pivot` | **(v3.1)** Re-open a long-form edition after a late substantive change so cleared gates (fact-check → editorial → persona → headless) re-run before lock; logs `pivots[]`, resets `currentPhase`, un-locks if needed (pivot heuristic: >20 % word-count change or >2 new sections) | | `/linkedin:batch` | Create a full week of content | | `/linkedin:calendar` | View/manage post scheduling queue + publish action (mark scheduled posts as published) | +| `/linkedin:firsthour` | Post-publish first-hour / reply-loop sprint — delegates to `engagement-coach` for a timestamped target list + draft comments + timeline, persists the plan to state (`recordFirstHourPlan`), hands off to `post-feedback-monitor` | | `/linkedin:carousel` | Structured multi-slide carousel generator | | `/linkedin:video` | Video script generator (30s-2min) | | `/linkedin:multiplatform` | Adapt content for other platforms (short-form/cross-format; long-form → `/linkedin:newsletter`) | diff --git a/plugins/linkedin-studio/commands/firsthour.md b/plugins/linkedin-studio/commands/firsthour.md new file mode 100644 index 0000000..086f2ab --- /dev/null +++ b/plugins/linkedin-studio/commands/firsthour.md @@ -0,0 +1,118 @@ +--- +name: linkedin:firsthour +description: | + Run the critical first hour after you publish — the window that decides ~70% of a post's + reach. Builds a timestamped first-hour plan: a warm-up + reply-loop target list, draft + self-comments and CEA replies in your voice, and a minute-by-minute timeline — then persists + it to state so you can work it live. Hands off to the 48-hour monitor afterwards. + Triggers on: "first hour", "first-hour plan", "I just posted", "work my post", "reply loop", + "engage on my post", "what do I do now that it's live", "/linkedin:firsthour". +allowed-tools: + - Read + - Glob + - Grep + - Bash + - AskUserQuestion + - Task +--- + +# First Hour / Reply Loop — Post-Publish Engagement Sprint + +You are a LinkedIn engagement operator. A post just went live (or is about to). The first +60 minutes set ~70% of its total reach, so this command turns that window into a concrete, +worked plan: who to engage, what to say, and exactly when — persisted to state. + +## Step 0: Load Context + +- Read `~/.claude/linkedin-studio.local.md` for posting state (streak, weekly progress, recent posts, follower phase). +- Read `assets/voice-samples/authentic-voice-samples.md` so every draft comment is in the user's voice. +- Note the user's growth phase (follower count) — it sets daily comment volume and target split. + +## Step 1: Identify the Post + +Establish what just shipped. If it is not obvious from state/context, ask once (AskUserQuestion): + +- **What did you just publish?** (topic + the hook/first line) +- **When did it go live?** (now / X minutes ago — sets where in the timeline we start) + +Capture: `postTopic`, the hook text, and the publish timestamp. + +## Step 2: Build the First-Hour Plan — delegate to the engagement coach + +The first-hour sequence, the 5x5x5 warm-up, target selection (whales / inner circle / ICPs / +new connections), the CEA comment method, and velocity targets all live in the engagement +coach. Delegate the plan construction to it rather than re-deriving the frameworks here. + +Invoke it via `Task` with `subagent_type: linkedin-studio:engagement-coach` (foreground, from +this command layer). Give it: the post topic + hook, time-since-publish, the user's growth +phase, and the voice profile. Ask it to return: + +1. **Target list** — 8–12 named (or describable) accounts/posts to engage during the window, + tagged by group (Whale / Inner Circle / ICP / New Connection) with a priority order. +2. **Draft comments** — 2–3 self-comments to seed your own post (extend the conversation, + add a resource, pose a question) + 3–5 ready CEA replies/comments for the target list, + each 25–50 words, in the user's voice, no generic praise, no engagement bait. +3. **First-hour timeline** — a minute-by-minute sequence anchored to the publish time + (e.g. `09:10 — add value self-comment`, `09:30 — reply to every comment`). + +## Step 3: Present the Plan + +Show, in this order: + +1. **Timeline** (anchored to the real publish time) — what to do at each mark. +2. **Targets** — grouped, in priority order, with the 30-minute whale window flagged. +3. **Draft comments** — self-comments first, then the CEA replies, each labelled. +4. **Velocity checkpoints** — the 5/15/30/60-minute reaction+comment targets, with the + "below this = hook/timing issue" warnings, so the user can self-diagnose mid-window. + +Auto-copy the self-comments + draft replies to clipboard silently (so they're one paste away): + +```bash +printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +``` + +Then confirm: "Copied your draft comments to clipboard." + +## Step 4: Persist the Plan to State + +Record the plan deterministically (additive — it creates the fields/section on older state +files and never touches existing fields): + +```bash +node --input-type=module -e " +import { writeState, recordFirstHourPlan } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs'; +writeState(content => recordFirstHourPlan(content, { + planDate: 'YYYY-MM-DD HH:MM', + postTopic: 'topic_area', + targets: ['Whale: ...', 'Inner circle: ...'], + draftComments: ['Self-comment ...', 'Reply ...'], + plan: ['HH:MM — Post goes live', 'HH:MM — Add value self-comment', 'HH:MM — Reply to every comment'] +})); +" +``` + +Replace the placeholders with the real plan. This persists the plan to the **First-Hour Plans** +section of `~/.claude/linkedin-studio.local.md` and stamps `last_firsthour_date` / +`firsthour_active`. + +## Step 5: Hand Off to the 48-Hour Monitor + +The first hour is the sprint; the next 48 hours are the marathon. Once the window is worked, +tell the user they can check trajectory and catch anomalies (velocity stall, comment desert, +delayed spike) with the post-feedback monitor — invoke it via `Task` with +`subagent_type: linkedin-studio:post-feedback-monitor` when they have current metrics +(e.g. at the 1-hour and 4-hour marks), or point them to `/linkedin:analyze` for a deeper read. + +## Principles + +1. **Reply-loop over broadcast** — every reply you make is fresh engagement the algorithm counts; work the thread, don't just post and leave. +2. **Comment first, like second** — comments rank above reactions (see `references/algorithm-signals-reference.md`). +3. **Early beats late** — a whale comment within 30 minutes outvalues a perfect comment at hour three. +4. **Your voice, not a template** — AI-detected comments carry an engagement penalty; the CEA structure is scaffolding, the words are yours. +5. **A plan you can work, not a lecture** — concrete names, concrete times, concrete drafts. + +## Reference Files + +- `assets/voice-samples/authentic-voice-samples.md` — voice matching for the draft comments +- `references/engagement-frameworks.md` — hook types, CEA, engagement hierarchy +- `references/algorithm-signals-reference.md` — first-hour weighting, signal order, timing data diff --git a/plugins/linkedin-studio/config/state-file.template.md b/plugins/linkedin-studio/config/state-file.template.md index b241bff..09c9579 100644 --- a/plugins/linkedin-studio/config/state-file.template.md +++ b/plugins/linkedin-studio/config/state-file.template.md @@ -32,6 +32,10 @@ next_planned_topic: "" pending_5x5x5: false content_series_active: "" +# First-hour / reply-loop engagement +last_firsthour_date: null # "YYYY-MM-DD HH:MM" of the most recent first-hour plan +firsthour_active: false # true while a first-hour loop is in progress + # Profile expertise_areas: - "general" @@ -59,3 +63,8 @@ expertise_areas: ## Milestone Log + +## First-Hour Plans + + + diff --git a/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs b/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs index 767a36d..cc13ace 100644 --- a/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs +++ b/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs @@ -1,6 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { updatePostTracking, pruneContentHistory, updateFollowerCount } from '../state-updater.mjs'; +import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan } from '../state-updater.mjs'; const SAMPLE_STATE = `--- last_post_date: "2026-04-05" @@ -293,3 +293,75 @@ describe('updateFollowerCount', () => { assert.ok(result.content.includes('[2026-04] 920 (+70)')); }); }); + +describe('recordFirstHourPlan', () => { + const PLAN_OPTS = { + planDate: '2026-05-30 09:00', + postTopic: 'AI governance', + targets: ['Whale: @bigvoice (within 30 min)', 'Inner circle: @peer'], + draftComments: ['Your point about model-cards resonates — we found...'], + plan: ['09:00 — Post goes live', '09:10 — Add value self-comment', '09:30 — Reply to every comment'] + }; + + test('creates a non-R-initial First-Hour Plans section when absent', () => { + // SAMPLE_STATE has no ## First-Hour Plans section → must be created (additive) + assert.ok(!SAMPLE_STATE.includes('## First-Hour Plans')); + const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS); + assert.notEqual(result, null); + assert.ok(result.content.includes('## First-Hour Plans')); + }); + + test('records topic, targets, draft comments and timeline in the entry', () => { + const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS); + assert.notEqual(result, null); + assert.ok(result.content.includes('[2026-05-30 09:00]')); + assert.ok(result.content.includes('AI governance')); + assert.ok(result.content.includes('Whale: @bigvoice (within 30 min)')); + assert.ok(result.content.includes('Your point about model-cards resonates')); + assert.ok(result.content.includes('09:10 — Add value self-comment')); + }); + + test('is additive — inserts last_firsthour_date when the field is absent', () => { + assert.ok(!/^last_firsthour_date:/m.test(SAMPLE_STATE)); + const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS); + assert.notEqual(result, null); + assert.match(result.content, /^last_firsthour_date: "2026-05-30 09:00"$/m); + }); + + test('updates an existing last_firsthour_date without duplicating it', () => { + const withField = SAMPLE_STATE.replace( + 'last_post_date: "2026-04-05"', + 'last_post_date: "2026-04-05"\nlast_firsthour_date: null' + ); + const result = recordFirstHourPlan(withField, { ...PLAN_OPTS, planDate: '2026-05-30 11:00' }); + assert.notEqual(result, null); + const hits = result.content.match(/^last_firsthour_date:/gm) || []; + assert.equal(hits.length, 1, 'field must not be duplicated'); + assert.match(result.content, /^last_firsthour_date: "2026-05-30 11:00"$/m); + }); + + test('leaves existing fields and sections untouched (round-trip)', () => { + const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS); + assert.notEqual(result, null); + assert.match(result.content, /^last_post_date: "2026-04-05"$/m); + assert.match(result.content, /^follower_count: 850$/m); + assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."')); + }); + + test('gracefully defaults empty targets/comments/plan', () => { + const result = recordFirstHourPlan(SAMPLE_STATE, { + planDate: '2026-05-30 09:00', + postTopic: 'minimal' + }); + assert.notEqual(result, null); + assert.ok(result.content.includes('## First-Hour Plans')); + assert.ok(result.content.includes('minimal')); + }); + + test('returns a changes array describing what changed', () => { + const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS); + assert.notEqual(result, null); + assert.ok(Array.isArray(result.changes)); + assert.ok(result.changes.length > 0); + }); +}); diff --git a/plugins/linkedin-studio/hooks/scripts/state-updater.mjs b/plugins/linkedin-studio/hooks/scripts/state-updater.mjs index 774fa95..a7948de 100644 --- a/plugins/linkedin-studio/hooks/scripts/state-updater.mjs +++ b/plugins/linkedin-studio/hooks/scripts/state-updater.mjs @@ -202,6 +202,63 @@ export function updateFollowerCount(stateContent, { count, month }) { return { content, changes }; } +/** + * Record a first-hour / reply-loop engagement plan deterministically. + * + * Additive by contract (older state files predate these fields): a missing + * scalar is inserted, a missing section is created — existing fields are never + * touched. Mirrors updatePostTracking: scalar replace + newest-first section + * append. The section name is deliberately non-`R`-initial so it falls outside + * pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window. + * + * @param {string} stateContent - Full state file content + * @param {{ planDate: string, postTopic?: string, targets?: string[], draftComments?: string[], plan?: string[] }} opts + * @returns {{ content: string, changes: string[] } | null} + */ +export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', targets = [], draftComments = [], plan = [] }) { + let content = stateContent; + const changes = []; + + // 1. last_firsthour_date — replace in place, else insert after last_post_date (additive) + if (/^last_firsthour_date: .*/m.test(content)) { + content = replaceField(content, 'last_firsthour_date', `"${planDate}"`); + } else if (/^last_post_date: .*/m.test(content)) { + content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_firsthour_date: "${planDate}"`); + } + changes.push(`last_firsthour_date → ${planDate}`); + + // 2. firsthour_active flag — only touch if the field is declared (additive) + if (/^firsthour_active: .*/m.test(content)) { + content = replaceField(content, 'firsthour_active', 'true'); + changes.push('firsthour_active → true'); + } + + // 3. Build the plan entry block + const fmtList = (items) => (Array.isArray(items) && items.length ? items.map((i) => `- ${i}`).join('\n') : '- _(none)_'); + const entry = [ + `### [${planDate}] ${postTopic}`.trimEnd(), + '**Targets:**', + fmtList(targets), + '**Draft comments:**', + fmtList(draftComments), + '**First-hour timeline:**', + fmtList(plan), + '' + ].join('\n'); + + // 4. Append to ## First-Hour Plans (newest first); create the section if absent (additive) + if (/^## First-Hour Plans\b/m.test(content)) { + content = content.replace(/^(## First-Hour Plans\n\n?)/m, `$1${entry}\n`); + } else { + const trimmed = content.replace(/\s*$/, ''); + content = `${trimmed}\n\n## First-Hour Plans\n\n\n\n${entry}\n`; + } + changes.push(`First-Hour Plans += ${planDate} "${postTopic}"`); + + if (content === stateContent) return null; + return { content, changes }; +} + /** * I/O wrapper: read state file, apply update function, write atomically. * @param {function(string): {content: string}|null} updateFn - Pure update function @@ -244,10 +301,21 @@ if (import.meta.url === `file://${process.argv[1]}`) { count: parseInt(getArg('--count') || '0', 10), month: getArg('--month') || new Date().toISOString().slice(0, 7) })); + } else if (args.includes('--record-firsthour')) { + const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; }; + const splitList = (s) => (s ? s.split(';').map(x => x.trim()).filter(Boolean) : []); + writeState(content => recordFirstHourPlan(content, { + planDate: getArg('--date') || new Date().toISOString().slice(0, 16).replace('T', ' '), + postTopic: getArg('--topic') || '', + targets: splitList(getArg('--targets')), + draftComments: splitList(getArg('--comments')), + plan: splitList(getArg('--plan')) + })); } else { console.log('Usage:'); console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post'); console.log(' node state-updater.mjs --prune [days]'); console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04'); + console.log(' node state-updater.mjs --record-firsthour --date "YYYY-MM-DD HH:MM" --topic "topic" --targets "a;b" --comments "c;d" --plan "e;f"'); } } diff --git a/plugins/linkedin-studio/scripts/test-runner.sh b/plugins/linkedin-studio/scripts/test-runner.sh index 6d62014..69e0c70 100755 --- a/plugins/linkedin-studio/scripts/test-runner.sh +++ b/plugins/linkedin-studio/scripts/test-runner.sh @@ -36,7 +36,7 @@ warn() { echo -e "${YELLOW}⚠${NC} $1"; WARN=$((WARN + 1)); } # Source of truth: CLAUDE.md headers + STATE.md Telling. Bump these together # with the files when adding/removing an agent, command, reference, or skill. EXPECT_AGENTS=19 -EXPECT_COMMANDS=26 +EXPECT_COMMANDS=27 EXPECT_REFS=25 EXPECT_SKILLS=6