feat(linkedin-studio): N15 — do-next-kontrakt (recordDoNext + skrivere/lesere + forrige-utgave-kalibrering) [skip-docs]

Måling som ikke endrer neste utgave er teater: report/analyze/ab-test/48h-monitor
endte alle i chat (grep do.next = 0 i hele repoet). N15 gjør kanalen til en kontrakt.

- recordDoNext i hooks/scripts/state-updater.mjs (TDD, 17 nye tester): seksjonen
  Do-Next Directives, rader "[dato] (kilde) direktiv — evidence: peker".
  Levetid = erstatt-per-kilde + 60-dagers aldersgulv, maks 3 per skriv, additiv
  scalar last_donext_date, $-sikre replacement-funksjoner. CLI: --record-do-next.
- Skrivere: report Step 7b, analyze Step 6b, ab-test 2c.6 (kun Adopt),
  post-feedback-monitor (kun 48h-sjekken); analytics-interpreter emitterer nå
  anbefalinger på do-next-form (imperativ + evidens).
- Lesere: post/quick/batch/create Step 0 + newsletter Step 1, som i tillegg
  kalibrerer på forrige utgaves MÅLTE tall via queue-id-match (queue.json →
  analytics/posts) med eksplisitt honest-miss — aldri estimert.
- Guards: test-runner Section 16v (15 ubetingede sjekker, floor 198 → 213),
  $-safety-batteriet dekker recordDoNext (coverage-gate fanget den nye eksporten).

Suiter: test-runner 232/0, hooks 191/0, trends 300/0, brain 134/0, editions 72/0,
specifics-bank 45/0, contract-gate 33/0, tests 35/0, render 60/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 15:23:35 +02:00
commit b45fdad911
16 changed files with 713 additions and 6 deletions

View file

@ -1,6 +1,6 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact } from '../state-updater.mjs';
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact, recordDoNext } from '../state-updater.mjs';
const SAMPLE_STATE = `---
last_post_date: "2026-04-05"
@ -693,3 +693,218 @@ describe('recordOutreachContact', () => {
assert.ok(result.content.includes('[2026-05-30 14:00]'));
});
});
// Mirrors the ## Do-Next Directives block config/state-file.template.md ships
// (own fixture rather than an edit to TEMPLATE_STATE, so the recordOutreachContact
// "entry lands inside the last section" assertion keeps testing exactly what it
// tested before). Every template-initialized state file therefore hits the
// section-APPEND branch; SAMPLE_STATE (no section) exercises section-CREATE.
const DONEXT_TEMPLATE_STATE = `---
last_post_date: "2026-04-05"
last_post_topic: "AI strategy"
follower_count: 850
---
# LinkedIn Session State
## Recent Posts
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
## Do-Next Directives
<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. -->
<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->
`;
describe('recordDoNext', () => {
// "Now" for the write tests: days — not months — after the 2026-05-3x record dates,
// so the age floor is NOT what those assertions end up measuring. (Reusing
// FIXED_TODAY here made the supersede test pass for the wrong reason: every row
// was already past the 60-day floor, so "replaced" and "expired" were
// indistinguishable.) The prune tests below deliberately use FIXED_TODAY with
// dates computed relative to it.
const DONEXT_TODAY = new Date('2026-06-10T00:00:00Z');
const REPORT_OPTS = {
recordDate: '2026-05-30',
source: 'report',
directives: [
{ directive: 'Double down on AI-agent posts', evidence: 'weekly 2026-W22: 3 posts avg 2400 impressions vs 1200 baseline' },
{ directive: 'Move Tuesday posts to 08:00', evidence: 'weekly 2026-W22: Tuesday -40% impressions' }
],
today: DONEXT_TODAY
};
test('creates a non-R-initial Do-Next Directives section when absent', () => {
assert.ok(!SAMPLE_STATE.includes('## Do-Next Directives'));
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.ok(result.content.includes('## Do-Next Directives'));
});
test('records date, source tag, directive text and evidence pointer in each row', () => {
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.match(result.content, /^- \[2026-05-30\] \(report\) Double down on AI-agent posts — evidence: weekly 2026-W22: 3 posts avg 2400 impressions vs 1200 baseline$/m);
assert.match(result.content, /^- \[2026-05-30\] \(report\) Move Tuesday posts to 08:00 — evidence: weekly 2026-W22: Tuesday -40% impressions$/m);
});
test('accepts a plain-string directive and marks the missing evidence pointer as none', () => {
const result = recordDoNext(SAMPLE_STATE, {
recordDate: '2026-05-30',
source: 'report',
directives: ['Ship one carousel this week'],
today: DONEXT_TODAY
});
assert.notEqual(result, null);
assert.match(result.content, /^- \[2026-05-30\] \(report\) Ship one carousel this week — evidence: _\(none\)_$/m);
});
test('caps a single write at 3 directives (2-3 is the contract; a report dump is not a backlog)', () => {
const result = recordDoNext(SAMPLE_STATE, {
recordDate: '2026-05-30',
source: 'report',
directives: ['one', 'two', 'three', 'four', 'five'],
today: DONEXT_TODAY
});
assert.notEqual(result, null);
const rows = result.content.match(/^- \[2026-05-30\] \(report\)/gm) || [];
assert.equal(rows.length, 3, 'at most 3 rows per write');
assert.ok(!result.content.includes('(report) four'));
});
test('replaces the SAME source on the next write — directives have a lifetime, they do not accumulate', () => {
const first = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
const second = recordDoNext(first.content, {
recordDate: '2026-06-06',
source: 'report',
directives: ['Test a poll format'],
today: DONEXT_TODAY
});
assert.notEqual(second, null);
assert.ok(!second.content.includes('Double down on AI-agent posts'), 'superseded report directive must be gone');
assert.ok(!second.content.includes('Move Tuesday posts to 08:00'), 'superseded report directive must be gone');
assert.match(second.content, /^- \[2026-06-06\] \(report\) Test a poll format /m);
});
test('leaves OTHER sources untouched when one source rewrites its slot', () => {
const withAb = recordDoNext(SAMPLE_STATE, {
recordDate: '2026-05-28',
source: 'ab-test',
directives: [{ directive: 'Adopt question hooks', evidence: 'test hook-form: B +38% comments' }],
today: DONEXT_TODAY
});
const withReport = recordDoNext(withAb.content, REPORT_OPTS);
assert.notEqual(withReport, null);
assert.ok(withReport.content.includes('(ab-test) Adopt question hooks'), 'a report write must not evict the A/B slot');
assert.ok(withReport.content.includes('(report) Double down on AI-agent posts'));
});
test('prunes rows older than maxAgeDays regardless of source (bounded section)', () => {
const stale = daysBeforeFixedToday(90);
const fresh = daysBeforeFixedToday(10);
const seeded = SAMPLE_STATE.replace(
'## Milestone Log',
`## Do-Next Directives\n\n- [${stale}] (48h-monitor) Old learning — evidence: post 2026-01\n- [${fresh}] (48h-monitor) Recent learning — evidence: post 2026-12\n\n## Milestone Log`
);
const result = recordDoNext(seeded, { ...REPORT_OPTS, recordDate: daysBeforeFixedToday(0), maxAgeDays: 60, today: FIXED_TODAY });
assert.notEqual(result, null);
assert.ok(!result.content.includes('Old learning'), 'row past the age window must be dropped');
assert.ok(result.content.includes('Recent learning'), 'row inside the age window must survive');
});
test('prunes stale rows even when the write itself carries no directives', () => {
const stale = daysBeforeFixedToday(120);
const seeded = SAMPLE_STATE.replace(
'## Milestone Log',
`## Do-Next Directives\n\n- [${stale}] (report) Ancient directive — evidence: none\n\n## Milestone Log`
);
const result = recordDoNext(seeded, { recordDate: daysBeforeFixedToday(0), source: 'report', directives: [], maxAgeDays: 60, today: FIXED_TODAY });
assert.notEqual(result, null);
assert.ok(!result.content.includes('Ancient directive'));
});
test('returns null when there is nothing to record and nothing to prune', () => {
const result = recordDoNext(SAMPLE_STATE, { recordDate: '2026-05-30', source: 'report', directives: [], today: FIXED_TODAY });
assert.equal(result, null);
});
test('is additive — inserts last_donext_date when the field is absent', () => {
assert.ok(!/^last_donext_date:/m.test(SAMPLE_STATE));
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.match(result.content, /^last_donext_date: "2026-05-30"$/m);
});
test('updates an existing last_donext_date without duplicating it', () => {
const withField = SAMPLE_STATE.replace(
'last_post_date: "2026-04-05"',
'last_post_date: "2026-04-05"\nlast_donext_date: null'
);
const result = recordDoNext(withField, { ...REPORT_OPTS, recordDate: '2026-06-06' });
assert.notEqual(result, null);
const hits = result.content.match(/^last_donext_date:/gm) || [];
assert.equal(hits.length, 1, 'field must not be duplicated');
assert.match(result.content, /^last_donext_date: "2026-06-06"$/m);
});
test('no-anchor fall-through: scalar not written, not reported; section still written', () => {
assert.ok(!/^last_post_date:/m.test(NO_ANCHOR_STATE));
const result = recordDoNext(NO_ANCHOR_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.ok(!/^last_donext_date:/m.test(result.content), 'scalar must not be inserted without an anchor');
assert.ok(!result.changes.some((c) => /last_donext_date/.test(c)), 'no date-scalar change entry without an anchor');
assert.ok(result.changes.some((c) => /^Do-Next Directives/.test(c)), 'section write must still be recorded');
assert.ok(result.content.includes('(report) Double down on AI-agent posts'));
});
test('writes into the pre-existing section (production path) without duplicating it or dropping its format comments', () => {
assert.ok(DONEXT_TEMPLATE_STATE.includes('## Do-Next Directives'));
const result = recordDoNext(DONEXT_TEMPLATE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
const headings = result.content.match(/^## Do-Next Directives$/gm) || [];
assert.equal(headings.length, 1, 'section must not be duplicated');
assert.ok(result.content.includes('<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->'), 'template format comment survives');
const section = result.content.slice(result.content.indexOf('## Do-Next Directives'));
assert.ok(section.includes('(report) Double down on AI-agent posts'), 'rows must land inside the section');
});
test('records $-bearing directive/evidence verbatim — special replacement patterns are not interpreted', () => {
const result = recordDoNext(DONEXT_TEMPLATE_STATE, {
recordDate: '2026-05-30',
source: 'report',
directives: [{ directive: 'Price the $&-offer at $99', evidence: 'ref $1, spend $$40' }],
today: DONEXT_TODAY
});
assert.notEqual(result, null);
assert.ok(result.content.includes('Price the $&-offer at $99'), 'directive with $-tokens verbatim');
assert.ok(result.content.includes('ref $1, spend $$40'), 'evidence with $-tokens verbatim');
const headings = result.content.match(/^## Do-Next Directives$/gm) || [];
assert.equal(headings.length, 1, 'section must not be re-injected by a $1/$& expansion');
});
test('leaves existing fields and sections untouched (round-trip)', () => {
const result = recordDoNext(SAMPLE_STATE, REPORT_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..."'));
assert.ok(result.content.includes('## Milestone Log'));
});
test('returns a changes array describing what changed', () => {
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.ok(Array.isArray(result.changes));
assert.ok(result.changes.some((c) => /Do-Next Directives/.test(c)));
});
test('a do-next section does not break pruneContentHistory (non-R-initial section boundary)', () => {
// pruneContentHistory captures "## Recent Posts ... (?=\n## [^R])": the do-next
// heading must terminate that window, not extend it into the directive rows.
const withDoNext = recordDoNext(SAMPLE_STATE, REPORT_OPTS).content;
const pruned = pruneContentHistory(withDoNext, 30, FIXED_TODAY);
assert.notEqual(pruned, null, 'old Recent Posts entries must still be prunable');
assert.ok(pruned.content.includes('(report) Double down on AI-agent posts'), 'directives must survive a content-history prune');
});
});