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

@ -343,6 +343,112 @@ export function recordOutreachContact(stateContent, { contactDate, track = '', p
return { content, changes };
}
/**
* Record 2-3 "do next" directives deterministically the measurementcreation
* contract. A recommendation that only ever lands in chat dies there; a directive
* written HERE is read by the create surfaces at their Step 0, so the next post is
* actually shaped by the last report.
*
* Lifetime is REPLACE-BY-SOURCE plus an age floor, not append-forever:
* - each writer (report / analyze / ab-test / 48h-monitor) owns its own slot, so a
* new report supersedes the previous report's directives but never evicts an
* A/B verdict that landed in between;
* - rows older than `maxAgeDays` are dropped on every write regardless of source,
* so a writer that goes quiet cannot leave stale directives steering drafts
* forever (pruneContentHistory's bounded-section pattern);
* - at most 3 rows per write this is a steering signal, not a backlog.
*
* Additive by contract, like recordFirstHourPlan/recordOutreachContact: a missing
* scalar is inserted, a missing section created, no existing field touched. The
* section name is deliberately non-`R`-initial ("Do-Next …") so it falls outside
* pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
*
* @param {string} stateContent - Full state file content
* @param {{ recordDate: string, source?: string, directives?: Array<string|{directive: string, evidence?: string}>, maxAgeDays?: number, today?: Date }} opts
* @returns {{ content: string, changes: string[] } | null}
*/
export function recordDoNext(stateContent, { recordDate, source = 'report', directives = [], maxAgeDays = 60, today = new Date() }) {
let content = stateContent;
const changes = [];
const MAX_PER_WRITE = 3;
const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() - maxAgeDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
// 1. Build this write's rows (capped; string and {directive, evidence} both accepted)
const rows = directives.slice(0, MAX_PER_WRITE).map((d) => {
const text = (typeof d === 'string' ? d : d && d.directive) || '';
const evidence = (typeof d === 'string' ? '' : (d && d.evidence) || '').trim();
return `- [${recordDate}] (${source}) ${String(text).trim()} — evidence: ${evidence || '_(none)_'}`;
}).filter((r) => !/\) +— evidence:/.test(r)); // drop rows with an empty directive
// 2. Rebuild the existing section body: drop this source's rows (superseded) and
// every row past the age floor (any source). Non-row lines (format comments,
// blank lines) are kept verbatim.
// No /m flag — same trap pruneContentHistory documents: with /m the `$` alternative
// in the lookahead matches the end of EVERY line, so the lazy body capture stops
// after the first line and older rows below it are never scanned (never superseded,
// never pruned). Without /m, `$` is end-of-string and the capture spans the section.
const sectionRe = /(## Do-Next Directives\n\n?)([\s\S]*?)(?=\n## |$)/;
const existing = content.match(sectionRe);
let dropped = 0;
if (existing) {
// The section's leading `<!-- … -->` format comments are documentation, not data:
// they stay directly under the heading, above the rows (a row prepended above them
// would push the format contract to the bottom of the section every write).
const lines = existing[2].split('\n');
let cut = 0;
while (cut < lines.length && !/^- \[/.test(lines[cut]) && (lines[cut].trim() === '' || lines[cut].trim().startsWith('<!--'))) cut++;
const preamble = lines.slice(0, cut).join('\n').replace(/\s+$/, '');
const kept = lines.slice(cut).filter((line) => {
if (line.trim() === '') return false; // rebuilt below, never accumulated
const row = line.match(/^- \[(\d{4}-\d{2}-\d{2})[^\]]*\] \(([^)]*)\)/);
if (!row) return true; // unexpected user text: keep verbatim
if (row[2] === source) { dropped++; return false; } // superseded by this write
if (row[1] < cutoffStr) { dropped++; return false; } // past the age floor
return true;
});
// Newest first: this write's rows go directly under the heading + comments.
const parts = [];
if (preamble) parts.push(preamble, '');
parts.push(...rows, ...kept);
const body = `${parts.join('\n')}\n`;
// Replacement FUNCTION, not string: rows embed untrusted directive/evidence text —
// `$&`/`$1`/`$$` must be inserted verbatim (same class as the other section writes).
content = content.replace(sectionRe, () => `${existing[1]}${body}`);
} else if (rows.length) {
const trimmed = content.replace(/\s*$/, '');
const header = '## Do-Next Directives\n\n<!-- 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. -->\n<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->\n\n';
content = `${trimmed}\n\n${header}${rows.join('\n')}\n`;
}
if (rows.length === 0 && dropped === 0) return null;
// 3. last_donext_date — only when something was actually recorded. Replace in place,
// else insert after the nearest existing date anchor (additive). Report the change
// only inside the branch that writes it: with no anchor the scalar is not inserted.
if (rows.length) {
if (/^last_donext_date: .*/m.test(content)) {
content = replaceField(content, 'last_donext_date', `"${recordDate}"`);
changes.push(`last_donext_date → ${recordDate}`);
} else {
const anchor = ['last_outreach_date', 'last_firsthour_date', 'last_post_date'].find((f) => new RegExp(`^${f}: .*`, 'm').test(content));
if (anchor) {
content = content.replace(new RegExp(`^(${anchor}: .*)$`, 'm'), (m) => `${m}\nlast_donext_date: "${recordDate}"`); // function, not string: keeps recordDate `$`-safe by construction
changes.push(`last_donext_date → ${recordDate}`);
}
}
changes.push(`Do-Next Directives ← ${rows.length} from ${source} (${recordDate})`);
}
if (dropped) changes.push(`Do-Next Directives: ${dropped} superseded/expired row(s) dropped`);
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
@ -405,6 +511,19 @@ if (import.meta.url === `file://${process.argv[1]}`) {
nextAction: getArg('--next') || '',
dueDate: getArg('--due') || ''
}));
} else if (args.includes('--record-do-next')) {
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
// Rows are `;`-separated; each row is `directive :: evidence` (evidence optional).
const parseDirectives = (s) => (s ? s.split(';').map((row) => {
const [directive, ...rest] = row.split('::');
return { directive: (directive || '').trim(), evidence: rest.join('::').trim() };
}).filter((d) => d.directive) : []);
writeState(content => recordDoNext(content, {
recordDate: getArg('--date') || new Date().toISOString().slice(0, 10),
source: getArg('--source') || 'report',
directives: parseDirectives(getArg('--directives')),
maxAgeDays: parseInt(getArg('--max-age') || '60', 10)
}));
} 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');
@ -412,5 +531,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
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');
console.log(' node state-updater.mjs --record-do-next --date YYYY-MM-DD --source report --directives "do X :: evidence;do Y :: evidence" [--max-age 60]');
}
}