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:
parent
29229c0b01
commit
3ae8adb6ff
6 changed files with 272 additions and 4 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue