feat(linkedin-studio): honest newsletter distribution + profile-SEO + outreach pipeline

Step 17 (Wave 4 S4) of the audit remediation. Applies research/03 §D5 + the
two S2 residual fixes folded in. No new commands/agents (counts stay 27/19).

Newsletter (commands/newsletter.md): new "Distribution channel" section after
Step 10 teaching the HONEST native-newsletter mechanics — bypasses organic feed
ranking via ONE deduplicated notification per subscriber per edition (NOT a
three-touchpoint blast); the mass invite fires once → ~1-2K follower floor
(wait until you can spend it); realistic cold-start 0-100 subs months 1-3;
discloses non-export / no-canonical / no-read-analytics / per-subscriber decay;
explicit below-vs-above-floor decision rule. Sourced to research/03 D5.

Profile (commands/profile.md): new "Profile SEO" section — headline as the
highest-weight search field + a per-section keyword-target table
(headline/about/experience/skills/featured), consistency-over-stuffing rule.

Outreach (commands/outreach.md): Step 8c persists the pipeline board to tracked
state via the new recordOutreachContact mutation (mirrors Step 16's
recordFirstHourPlan): additive last_outreach_date/outreach_active scalars + a
non-R-initial ## Outreach Pipeline section in state-updater.mjs +
config/state-file.template.md + --record-outreach CLI branch. +7 tests
(state-updater 26→33, full hook suite 83→90).

Residual 1 (growth-playbook:216): 9:16 "distribution boost" → 4:5/1:1 guidance
(9:16 mobile-only opt-in; "immersive distribution" = uncorroborated heuristic).
Residual 2 (video-strategy-guide:300): "3-second test determines 70% retention"
→ "front-load value for muted autoplay" (three-second hook is folklore, not a
LinkedIn signal).

Verify: grep checks 1-5 pass; test-runner.sh exit 0 (stat-consistency green);
state-updater 33/33. [skip-docs] — tre-doc + version bump deferred to Step 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-30 07:07:41 +02:00
commit 0b4e1bd097
8 changed files with 284 additions and 3 deletions

View file

@ -1,6 +1,6 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan } from '../state-updater.mjs';
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact } from '../state-updater.mjs';
const SAMPLE_STATE = `---
last_post_date: "2026-04-05"
@ -365,3 +365,77 @@ describe('recordFirstHourPlan', () => {
assert.ok(result.changes.length > 0);
});
});
describe('recordOutreachContact', () => {
const CONTACT_OPTS = {
contactDate: '2026-05-30 14:00',
track: 'collab',
partner: '@bigvoice',
stage: 'pitched',
nextAction: 'follow up if no reply',
dueDate: '2026-06-06'
};
test('creates a non-R-initial Outreach Pipeline section when absent', () => {
// SAMPLE_STATE has no ## Outreach Pipeline section → must be created (additive)
assert.ok(!SAMPLE_STATE.includes('## Outreach Pipeline'));
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
assert.notEqual(result, null);
assert.ok(result.content.includes('## Outreach Pipeline'));
});
test('records track, partner, stage, next action and due in the entry', () => {
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
assert.notEqual(result, null);
assert.ok(result.content.includes('[2026-05-30 14:00]'));
assert.ok(result.content.includes('@bigvoice'));
assert.ok(result.content.includes('collab'));
assert.ok(result.content.includes('pitched'));
assert.ok(result.content.includes('follow up if no reply'));
assert.ok(result.content.includes('2026-06-06'));
});
test('is additive — inserts last_outreach_date when the field is absent', () => {
assert.ok(!/^last_outreach_date:/m.test(SAMPLE_STATE));
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
assert.notEqual(result, null);
assert.match(result.content, /^last_outreach_date: "2026-05-30 14:00"$/m);
});
test('updates an existing last_outreach_date without duplicating it', () => {
const withField = SAMPLE_STATE.replace(
'last_post_date: "2026-04-05"',
'last_post_date: "2026-04-05"\nlast_outreach_date: null'
);
const result = recordOutreachContact(withField, { ...CONTACT_OPTS, contactDate: '2026-05-31 09:00' });
assert.notEqual(result, null);
const hits = result.content.match(/^last_outreach_date:/gm) || [];
assert.equal(hits.length, 1, 'field must not be duplicated');
assert.match(result.content, /^last_outreach_date: "2026-05-31 09:00"$/m);
});
test('leaves existing fields and sections untouched (round-trip)', () => {
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_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 optional fields', () => {
const result = recordOutreachContact(SAMPLE_STATE, {
contactDate: '2026-05-30 14:00',
partner: 'minimal-contact'
});
assert.notEqual(result, null);
assert.ok(result.content.includes('## Outreach Pipeline'));
assert.ok(result.content.includes('minimal-contact'));
});
test('returns a changes array describing what changed', () => {
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
assert.notEqual(result, null);
assert.ok(Array.isArray(result.changes));
assert.ok(result.changes.length > 0);
});
});

View file

@ -259,6 +259,63 @@ export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', ta
return { content, changes };
}
/**
* Record an outreach contact / pipeline entry deterministically. Additive: an
* absent scalar is inserted (never required up front), an absent section is
* created, and no existing field is touched. Mirrors recordFirstHourPlan:
* scalar replace + newest-first section append. The section name is
* deliberately non-`R`-initial ("Outreach …") so it falls outside
* pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
*
* @param {string} stateContent - Full state file content
* @param {{ contactDate: string, track?: string, partner?: string, stage?: string, nextAction?: string, dueDate?: string }} opts
* @returns {{ content: string, changes: string[] } | null}
*/
export function recordOutreachContact(stateContent, { contactDate, track = '', partner = '', stage = '', nextAction = '', dueDate = '' }) {
let content = stateContent;
const changes = [];
// 1. last_outreach_date — replace in place, else insert after last_firsthour_date
// if present, else after last_post_date (additive — never required up front)
if (/^last_outreach_date: .*/m.test(content)) {
content = replaceField(content, 'last_outreach_date', `"${contactDate}"`);
} else if (/^last_firsthour_date: .*/m.test(content)) {
content = content.replace(/^(last_firsthour_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`);
} else if (/^last_post_date: .*/m.test(content)) {
content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`);
}
changes.push(`last_outreach_date → ${contactDate}`);
// 2. outreach_active flag — only touch if the field is declared (additive)
if (/^outreach_active: .*/m.test(content)) {
content = replaceField(content, 'outreach_active', 'true');
changes.push('outreach_active → true');
}
// 3. Build the pipeline entry block
const fmt = (v) => (v && String(v).trim() ? String(v).trim() : '_(none)_');
const heading = `### [${contactDate}] ${partner || '(contact)'}${track ? `${track}` : ''}`.trimEnd();
const entry = [
heading,
`- **Stage:** ${fmt(stage)}`,
`- **Next action:** ${fmt(nextAction)}`,
`- **Due:** ${fmt(dueDate)}`,
''
].join('\n');
// 4. Append to ## Outreach Pipeline (newest first); create the section if absent (additive)
if (/^## Outreach Pipeline\b/m.test(content)) {
content = content.replace(/^(## Outreach Pipeline\n\n?)/m, `$1${entry}\n`);
} else {
const trimmed = content.replace(/\s*$/, '');
content = `${trimmed}\n\n## Outreach Pipeline\n\n<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->\n<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->\n\n${entry}\n`;
}
changes.push(`Outreach Pipeline += ${contactDate} "${partner || '(contact)'}"`);
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
@ -311,11 +368,22 @@ if (import.meta.url === `file://${process.argv[1]}`) {
draftComments: splitList(getArg('--comments')),
plan: splitList(getArg('--plan'))
}));
} else if (args.includes('--record-outreach')) {
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
writeState(content => recordOutreachContact(content, {
contactDate: getArg('--date') || new Date().toISOString().slice(0, 16).replace('T', ' '),
track: getArg('--track') || '',
partner: getArg('--partner') || '',
stage: getArg('--stage') || '',
nextAction: getArg('--next') || '',
dueDate: getArg('--due') || ''
}));
} 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"');
console.log(' node state-updater.mjs --record-outreach --date "YYYY-MM-DD HH:MM" --track collab --partner "@name" --stage pitched --next "follow up" --due YYYY-MM-DD');
}
}