feat(linkedin-studio): first-hour/reply-loop command with tracked state

Wire orphan agent #11 (engagement-coach) by giving it a command surface, and
add the tracked first-hour state the plan calls for (remediation Step 16).

- commands/firsthour.md (new, 27th command): post-publish first-hour /
  reply-loop sprint. Delegates plan construction to engagement-coach via
  Task (subagent_type: linkedin-studio:engagement-coach) — returns a grouped
  target list (whales/inner-circle/ICPs/new connections), 2-3 seed
  self-comments + 3-5 CEA replies in the user's voice, and a minute-by-minute
  timeline anchored to publish time. Presents timeline/targets/drafts +
  velocity checkpoints, auto-copies the drafts to clipboard, persists the
  plan, then hands off to post-feedback-monitor for the 48h window.
- hooks/scripts/state-updater.mjs: new pure mutation recordFirstHourPlan()
  mirroring updatePostTracking — additive by contract (inserts
  last_firsthour_date after last_post_date when absent, creates the
  ## First-Hour Plans section when absent, never touches existing fields).
  Section name is deliberately non-R-initial so it stays outside
  pruneContentHistory's "## Recent Posts ... (?=\n## [^R])" capture window.
  + a --record-firsthour CLI branch for parity with the other mutations.
- config/state-file.template.md: additive scalars (last_firsthour_date,
  firsthour_active) + the ## First-Hour Plans section.
- hooks/scripts/__tests__/state-updater.test.mjs: extend (existing file) with
  7 recordFirstHourPlan tests — section creation, field insertion vs in-place
  update (no duplication), round-trip non-interference, graceful empty
  defaults, changes array.
- CLAUDE.md: register the command (## Commands 26 -> 27, table row).
- scripts/test-runner.sh: EXPECT_COMMANDS 26 -> 27 (registration guard).

Verify: grep 'subagent_type: linkedin-studio:engagement-coach' commands/ ->
firsthour.md; node --test state-updater -> 26/26; full hook suite -> 83/83;
bash scripts/test-runner.sh -> exit 0 (62 passed, commands 27/27).

Plan Step 16 (Wave 4 S3).
[skip-docs]: tre-doc + version bump deferred to Step 21 per remediation plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-30 05:35:44 +02:00
commit 3ae8adb6ff
6 changed files with 272 additions and 4 deletions

View file

@ -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`) |

View file

@ -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** — 812 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** — 23 self-comments to seed your own post (extend the conversation,
add a resource, pose a question) + 35 ready CEA replies/comments for the target list,
each 2550 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' '<DRAFT_COMMENTS_BLOCK>' | 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

View file

@ -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
<!-- Updated when follower_count changes. Format: [YYYY-MM] count (+delta) -->
## First-Hour Plans
<!-- First-hour / reply-loop plans, newest first. Written by /linkedin:firsthour. -->
<!-- Format: ### [YYYY-MM-DD HH:MM] topic -->

View file

@ -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);
});
});

View file

@ -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<!-- First-hour / reply-loop plans. Format: ### [YYYY-MM-DD HH:MM] topic -->\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"');
}
}

View file

@ -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