feat(linkedin-studio): RE-R3e — brief history + day-over-day diff (surfaced: frontmatter + Nytt siden sist) [skip-docs]

Closes hull #7 ("ingen brief-historikk"). Each morning brief now records the
trend ids it showed into its own YAML frontmatter (surfaced: <id-csv> =
surfacedIds(ranking)) and renders a day-over-day diff against the most recent
prior brief — a "## Nytt siden sist (<prior-date>)" section that leads the
ranked list, plus a " N nye siden sist." marker on the one-line summary the
SessionStart hook surfaces (no hook change).

- brief.ts: BRIEF_SCHEMA_VERSION 1->2 (artifact frontmatter gained surfaced:;
  the store's SCHEMA_VERSION stays 4 — no store field). Three PURE helpers
  (diffSurfaced / parseSurfacedFrontmatter / selectPriorBriefFile) + the
  surfaced: emit + the section + the summary marker. No fs/clock in brief.ts.
- cli.ts: the brief handler discovers the prior dated file (existsSync-guarded
  readdirSync -> selectPriorBriefFile, strict < today so a same-day re-run is
  byte-identical), parses its surfaced: line, computes the diff, threads it into
  renderBrief AND the shared briefSummary(ranking, diff) (one-source: file
  frontmatter == --json summary, cli.test one-source invariant). --json gains a
  diff:{priorDate,added,carried,dropped} counts object; the console line appends
  the delta. Any fs error degrades to the empty-prior (first-brief) path.

TDD two-phase: stubs -> 17 value-RED (no module-not-found) -> GREEN. Trends suite
216 -> 245 (brief +27, cli +2), 0 fail. New unconditional gate Section 16n (6
checks); ASSERT_BASELINE_FLOOR 117 -> 123; TRENDS_TESTS_FLOOR -> 245. Full gate
FAIL=0; hook suite 139/139 + R3c schedule/run-daily green untouched. Behavioural:
real two-day rename-real-write diff + same-day byte-identity confirmed. Counts
29/19/27 unchanged; no version bump (additive, v0.5.2 dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 14:40:09 +02:00
commit 5b51b4baeb
7 changed files with 462 additions and 18 deletions

View file

@ -20,7 +20,7 @@ import { defaultStorePath, effectiveStatus } from "./store.js";
import type { TrendStore, TrendRecord } from "./types.js";
/** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */
export const BRIEF_SCHEMA_VERSION = 1;
export const BRIEF_SCHEMA_VERSION = 2;
/**
* The live temporal overlay (RE-R3d): two derived signals first-mover (recent AND
@ -201,7 +201,12 @@ export function rankForBrief(
* One line, no embedded double-quote, no newline, so the hook's extractYaml regex
* (^summary: *"?([^"\n]*)"?) captures it whole.
*/
export function briefSummary(ranking: BriefRanking): string {
export function briefSummary(ranking: BriefRanking, diff?: BriefDiff): string {
// RE-R3e: the day-over-day delta marker — appended only when a prior brief exists AND
// something is new (suppressed on the first brief / when nothing changed). Digits + ASCII
// + a period: no `"`/`\n`, so the hook's ^summary: regex still captures the line whole.
const delta =
diff && diff.priorDate !== null && diff.added.length > 0 ? ` ${diff.added.length} nye siden sist.` : "";
const fresh = ranking.totals.fresh;
if (fresh > 0) {
const top = ranking.topMatches[0] ?? ranking.singleMatches[0];
@ -210,9 +215,9 @@ export function briefSummary(ranking: BriefRanking): string {
const band = top.trend.score ? ` · ${top.trend.score.priority}` : "";
// RE-R3d: surface the first-mover marker on the one-line headline ("act now, you're early").
const fm = top.temporal.firstMover ? " · 🥇 først ute" : "";
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).`;
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).${delta}`;
}
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).${delta}`;
}
/** ` · <priority> (<mode>)` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */
@ -256,15 +261,21 @@ function renderBulletEntry(e: BriefEntry): string {
* summary, store stats, ranking descriptor, schemaVersion) + a three-section body.
* All three section headers are always emitted (stable structure determinism).
*/
export function renderBrief(ranking: BriefRanking): string {
export function renderBrief(
ranking: BriefRanking,
diff: BriefDiff = { priorDate: null, added: [], carried: [], dropped: [] },
): string {
const { totals } = ranking;
const lines: string[] = [];
lines.push("---");
lines.push(`date: ${ranking.today}`);
lines.push(`summary: ${briefSummary(ranking)}`);
lines.push(`summary: ${briefSummary(ranking, diff)}`);
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
// RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always
// emitted (even blank for an empty store); independent of --no-mark (a property of the render).
lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`);
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
lines.push("---");
lines.push("");
@ -275,6 +286,27 @@ export function renderBrief(ranking: BriefRanking): string {
);
lines.push("");
// RE-R3e: the day-over-day delta leads, then the full ranked list below.
lines.push(diff.priorDate !== null ? `## 🆕 Nytt siden sist (${diff.priorDate})` : "## 🆕 Nytt siden sist");
if (diff.priorDate === null) {
lines.push(diff.added.length > 0 ? "_Første brief — alt nedenfor er nytt._" : "_Første brief._", "");
} else if (diff.added.length === 0) {
lines.push(
`_Ingenting nytt siden ${diff.priorDate}._`,
`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`,
"",
);
} else {
const byId = new Map(
[...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched].map((e) => [e.trend.id, e]),
);
for (const id of diff.added) {
const e = byId.get(id);
if (e) lines.push(renderBulletEntry(e));
}
lines.push(`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, "");
}
lines.push("## 🎯 Topp-treff (2+ pillarer)");
if (ranking.topMatches.length === 0) {
lines.push("_Ingen i dag._", "");
@ -306,6 +338,61 @@ export function surfacedIds(ranking: BriefRanking): string[] {
return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id);
}
/**
* RE-R3e the day-over-day diff of a brief's surfaced cohort against the most recent
* prior brief. `priorDate` null no prior (the first brief / a fresh data dir).
* `added`/`carried`/`dropped` are the three DISJOINT partitions of the symmetric set
* difference (an id is in exactly one), each order-stable. Pure: the id lists + the
* prior date are injected by the CLI edge (no fs/clock here like `today`/`pillars`).
*/
export interface BriefDiff {
priorDate: string | null;
added: string[];
carried: string[];
dropped: string[];
}
/** The symmetric set difference of today's surfaced ids against the prior brief's. Pure (no fs/clock). */
export function diffSurfaced(currentIds: string[], priorIds: string[], priorDate: string | null): BriefDiff {
const prior = new Set(priorIds);
const cur = new Set(currentIds);
return {
priorDate,
added: currentIds.filter((id) => !prior.has(id)),
carried: currentIds.filter((id) => prior.has(id)),
dropped: priorIds.filter((id) => !cur.has(id)),
};
}
/**
* Extract a brief's `surfaced:` id list from its full text via one line-anchored regex
* (the hook's `extractYaml` idiom). Absent / blank / malformed [] (a pre-R3e or
* hand-edited brief degrades to "empty prior"); never throws. Ids are comma-free hex.
*/
export function parseSurfacedFrontmatter(md: string): string[] {
const m = md.match(/^surfaced: *([^\n]*)/m);
if (!m) return [];
return m[1]
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
/**
* The most recent prior brief's filename: the lexicographically greatest `YYYY-MM-DD.md`
* strictly < `${today}.md` (ISO dates sort = date order), else null. Excludes today + any
* future-dated file, so a same-day re-run diffs against the true previous day (determinism).
*/
export function selectPriorBriefFile(filenames: string[], today: string): string | null {
const todayFile = `${today}.md`;
return (
filenames
.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f) && f < todayFile)
.sort()
.pop() ?? null
);
}
/**
* Default brief directory under the per-user data dir, DERIVED from
* defaultStorePath() so root resolution lives in exactly one place:

View file

@ -48,7 +48,7 @@
* `schedule` adds no new exit code: an autonomy install never RUNS the system mutation.
*/
import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
@ -68,7 +68,16 @@ import type { TrendStatus } from "./types.js";
import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
import { triage } from "./score.js";
import type { ScoreMode } from "./score.js";
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir, surfacedIds } from "./brief.js";
import {
rankForBrief,
renderBrief,
briefSummary,
defaultBriefDir,
surfacedIds,
diffSurfaced,
parseSurfacedFrontmatter,
selectPriorBriefFile,
} from "./brief.js";
import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js";
import type { ScheduleSpec } from "./schedule.js";
@ -337,7 +346,26 @@ function main(): void {
const day = today(); // one wall-clock read for both the ranking and the filename
const store = loadStore(storePath); // hoisted: also needed for the surfacing write below
const ranking = rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt });
const md = renderBrief(ranking);
// RE-R3e: discover the most recent prior brief in outDir and diff today's surfaced cohort
// against its `surfaced:` line. Any fs error (no dir, unreadable file) degrades to the
// empty-prior (first-brief) path. The dir read lives HERE (the edge) — brief.ts stays fs-free.
const todayIds = surfacedIds(ranking);
let priorIds: string[] = [];
let priorDate: string | null = null;
try {
if (existsSync(outDir)) {
const priorFile = selectPriorBriefFile(readdirSync(outDir), day);
if (priorFile) {
priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8"));
priorDate = priorFile.slice(0, 10);
}
}
} catch {
priorIds = [];
priorDate = null;
}
const diff = diffSurfaced(todayIds, priorIds, priorDate);
const md = renderBrief(ranking, diff);
const path = join(outDir, `${day}.md`);
mkdirSync(outDir, { recursive: true });
writeFileSync(path, md, "utf8");
@ -347,12 +375,33 @@ function main(): void {
const mark = flags["no-mark"] !== "true";
const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0;
if (mark) saveStore(storePath, store);
const summary = briefSummary(ranking); // SAME source the frontmatter carries
const summary = briefSummary(ranking, diff); // SAME source the frontmatter carries (RE-R3e: incl. the delta marker)
if (asJson) {
console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary, marked }, null, 2));
console.log(
JSON.stringify(
{
path,
date: ranking.today,
totals: ranking.totals,
summary,
marked,
diff: {
priorDate: diff.priorDate,
added: diff.added.length,
carried: diff.carried.length,
dropped: diff.dropped.length,
},
},
null,
2,
),
);
return;
}
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)`);
const deltaNote = diff.added.length > 0 && diff.priorDate !== null ? `, ${diff.added.length} nye siden sist` : "";
console.log(
`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)${deltaNote}`,
);
return;
}