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>
367 lines
13 KiB
JavaScript
367 lines
13 KiB
JavaScript
import { describe, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan } from '../state-updater.mjs';
|
|
|
|
const SAMPLE_STATE = `---
|
|
last_post_date: "2026-04-05"
|
|
first_post_date: "2026-01-15"
|
|
last_post_topic: "AI strategy"
|
|
posts_this_week: 2
|
|
weekly_goal: 3
|
|
current_streak: 5
|
|
longest_streak: 12
|
|
current_week: "2026-W14"
|
|
last_import_date: "2026-04-01"
|
|
last_import_week: "2026-W14"
|
|
follower_count: 850
|
|
follower_target: 10000
|
|
target_date: "2026-12-31"
|
|
monthly_growth: []
|
|
projected_10k_date: ""
|
|
growth_rate_needed: 0
|
|
---
|
|
|
|
# LinkedIn Session State
|
|
|
|
## Recent Posts
|
|
|
|
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
|
|
- [2026-04-03] "Three things I learned..." (1200) - leadership
|
|
- [2026-03-28] "Why most teams fail at..." (1350) - team building
|
|
|
|
## Session Notes
|
|
|
|
## Planned Content
|
|
|
|
## Milestone Log
|
|
`;
|
|
|
|
describe('updatePostTracking', () => {
|
|
test('sets last_post_date to provided date', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-07',
|
|
postTopic: 'AI governance',
|
|
hookText: 'The real problem with AI governance...',
|
|
charCount: 1500,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^last_post_date: "2026-04-07"$/m);
|
|
});
|
|
|
|
test('sets last_post_topic', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-07',
|
|
postTopic: 'AI governance',
|
|
hookText: 'The real problem...',
|
|
charCount: 1500,
|
|
format: 'post'
|
|
});
|
|
assert.match(result.content, /^last_post_topic: "AI governance"$/m);
|
|
});
|
|
|
|
test('increments posts_this_week when same week', () => {
|
|
// 2026-04-06 is a Monday, ISO W15. current_week is W14.
|
|
// Use a date that stays in W14: 2026-04-05 is Sunday W14 — but last_post_date is already 04-05.
|
|
// Let's use a state with current_week matching the post date week.
|
|
const w15State = SAMPLE_STATE.replace('current_week: "2026-W14"', 'current_week: "2026-W15"');
|
|
const result = updatePostTracking(w15State, {
|
|
postDate: '2026-04-07', // Tuesday W15
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^posts_this_week: 3$/m); // was 2, incremented
|
|
});
|
|
|
|
test('increments streak when gap <= 2 days', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-06', // 1 day after last_post_date 2026-04-05
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^current_streak: 6$/m); // was 5, incremented
|
|
});
|
|
|
|
test('resets streak to 1 when gap > 2 days', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-09', // 4 days after 2026-04-05
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^current_streak: 1$/m);
|
|
});
|
|
|
|
test('sets first_post_date when null', () => {
|
|
const nullFirstPost = SAMPLE_STATE.replace(
|
|
'first_post_date: "2026-01-15"',
|
|
'first_post_date: null'
|
|
);
|
|
const result = updatePostTracking(nullFirstPost, {
|
|
postDate: '2026-04-07',
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^first_post_date: "2026-04-07"$/m);
|
|
});
|
|
|
|
test('does NOT overwrite existing first_post_date', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-07',
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^first_post_date: "2026-01-15"$/m);
|
|
});
|
|
|
|
test('triggers week rollover when ISO week changes', () => {
|
|
// 2026-04-14 is W16, current_week is W14
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-14',
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
// After rollover, posts_this_week resets to 0 then increments to 1
|
|
assert.match(result.content, /^posts_this_week: 1$/m);
|
|
assert.match(result.content, /^current_week: "2026-W16"$/m);
|
|
});
|
|
|
|
test('appends to Recent Posts section', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-06',
|
|
postTopic: 'AI governance',
|
|
hookText: 'The real problem with AI governance today...',
|
|
charCount: 1500,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('- [2026-04-06] "The real problem with AI governance today..." (1500) - AI governance'));
|
|
// Existing entries should still be there
|
|
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
|
|
});
|
|
|
|
test('updates longest_streak when current exceeds it', () => {
|
|
const highStreak = SAMPLE_STATE.replace('current_streak: 5', 'current_streak: 12');
|
|
const result = updatePostTracking(highStreak, {
|
|
postDate: '2026-04-06', // 1 day gap, streak increments to 13
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^current_streak: 13$/m);
|
|
assert.match(result.content, /^longest_streak: 13$/m);
|
|
});
|
|
|
|
test('does not update longest_streak when current is lower', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-06',
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^current_streak: 6$/m);
|
|
assert.match(result.content, /^longest_streak: 12$/m); // unchanged
|
|
});
|
|
|
|
test('returns changes array describing what changed', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-06',
|
|
postTopic: 'AI governance',
|
|
hookText: 'Hook',
|
|
charCount: 1500,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(Array.isArray(result.changes));
|
|
assert.ok(result.changes.length > 0);
|
|
});
|
|
});
|
|
|
|
describe('pruneContentHistory', () => {
|
|
test('removes entries older than 90 days', () => {
|
|
const today = new Date();
|
|
const old = new Date(today);
|
|
old.setDate(old.getDate() - 100);
|
|
const oldDate = old.toISOString().slice(0, 10);
|
|
|
|
const recent = new Date(today);
|
|
recent.setDate(recent.getDate() - 10);
|
|
const recentDate = recent.toISOString().slice(0, 10);
|
|
|
|
const stateWithOld = SAMPLE_STATE.replace(
|
|
'## Recent Posts\n\n',
|
|
`## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n`
|
|
);
|
|
|
|
const result = pruneContentHistory(stateWithOld, 90);
|
|
assert.notEqual(result, null);
|
|
assert.equal(result.pruned, 1);
|
|
assert.ok(!result.content.includes(oldDate));
|
|
assert.ok(result.content.includes(recentDate));
|
|
});
|
|
|
|
test('preserves entries within 90 days', () => {
|
|
const today = new Date();
|
|
const recent = new Date(today);
|
|
recent.setDate(recent.getDate() - 30);
|
|
const recentDate = recent.toISOString().slice(0, 10);
|
|
|
|
const stateWithRecent = SAMPLE_STATE.replace(
|
|
'## Recent Posts\n\n',
|
|
`## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n`
|
|
);
|
|
|
|
const result = pruneContentHistory(stateWithRecent, 90);
|
|
assert.equal(result, null); // nothing to prune
|
|
});
|
|
|
|
test('returns null when no entries exist', () => {
|
|
const emptyRecent = SAMPLE_STATE.replace(
|
|
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
|
|
'## Recent Posts\n\n'
|
|
);
|
|
const result = pruneContentHistory(emptyRecent, 90);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test('handles custom maxAgeDays', () => {
|
|
const today = new Date();
|
|
const old = new Date(today);
|
|
old.setDate(old.getDate() - 40);
|
|
const oldDate = old.toISOString().slice(0, 10);
|
|
|
|
const stateWithOld = SAMPLE_STATE.replace(
|
|
'## Recent Posts\n\n',
|
|
`## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n`
|
|
);
|
|
|
|
const result = pruneContentHistory(stateWithOld, 30);
|
|
assert.notEqual(result, null);
|
|
assert.equal(result.pruned, 1);
|
|
});
|
|
});
|
|
|
|
describe('updateFollowerCount', () => {
|
|
test('updates follower_count', () => {
|
|
const result = updateFollowerCount(SAMPLE_STATE, {
|
|
count: 920,
|
|
month: '2026-04'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^follower_count: 920$/m);
|
|
});
|
|
|
|
test('recalculates growth_rate_needed', () => {
|
|
const result = updateFollowerCount(SAMPLE_STATE, {
|
|
count: 920,
|
|
month: '2026-04'
|
|
});
|
|
assert.notEqual(result, null);
|
|
const match = result.content.match(/^growth_rate_needed: (\d+)$/m);
|
|
assert.ok(match, 'growth_rate_needed should be present');
|
|
const rate = parseInt(match[1], 10);
|
|
assert.ok(rate > 0, 'growth_rate_needed should be positive');
|
|
});
|
|
|
|
test('appends to Milestone Log section', () => {
|
|
const result = updateFollowerCount(SAMPLE_STATE, {
|
|
count: 920,
|
|
month: '2026-04'
|
|
});
|
|
assert.notEqual(result, null);
|
|
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);
|
|
});
|
|
});
|