feat(linkedin-studio): RE-R3b — trend lifecycle (re-score on re-capture · status · seen-log) [skip-docs]

The lifecycle layer over the trend store: what happens to a trend AFTER first capture.
- re-score on re-capture (last-wins; addTrend duplicate branch, score the one mutable
  field; provenance + lifecycle untouched; no false-merge via JSON compare). Reverses
  R3a's first-sight D3 — that R3a test reconciled to the new behaviour.
- status new/acted/skipped (effectiveStatus/setStatus + act/skip/reset CLI verbs);
  rankForBrief EXCLUDES handled trends (a work queue, not an archive).
- seen-log surfacedCount/lastSurfacedAt (markSurfaced, per-day idempotent); the brief
  CLI records surfacing on the store AFTER the pure render, unless --no-mark.
- render: entry id in backticks (copy-paste for act/skip) + · sett Nx prior-day hint.
- schema v3→v4 (additive lossless); the R3a migration block reconciled to the bump,
  the new R3b block committed against SCHEMA_VERSION (breaks the reconcile cycle).

score.ts + item.ts untouched (re-score reuses the R3a capture path). RED-first (two
phase: 16 logic-RED + 4 stub-RED). Gate: Section 16k (6 emitters), TRENDS_TESTS_FLOOR
146→171, ASSERT_BASELINE_FLOOR 99→105. trends 171/171, gate 120/0/0, hook suite 139/139.

Plan: docs/research-engine/{brief,plan}-re-r3b.md (light-Voyage hardened @ c40b937).

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 01:08:43 +02:00
commit b185db9a12
10 changed files with 661 additions and 44 deletions

View file

@ -16,7 +16,7 @@
import { join, dirname } from "node:path";
import { defaultStorePath } from "./store.js";
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). */
@ -80,6 +80,8 @@ export function rankForBrief(
const entries: BriefEntry[] = [];
for (const trend of store.trends) {
// RE-R3b (A3): acted/skipped are handled — drop from the work queue (the brief is a queue, not an archive).
if (effectiveStatus(trend) !== "new") continue;
const have = new Set(trend.topics.map((t) => t.toLowerCase()));
const matchedPillars: string[] = [];
for (let i = 0; i < pillars.length; i++) {
@ -144,10 +146,20 @@ function scoreToken(e: BriefEntry): string {
return s ? ` · ${s.priority} (${s.mode})` : "";
}
/**
* ` · sett Nx` when surfacedCount>=2, else "" the seen-log saturation HINT (RE-R3b). The count
* is PRIOR-DAY: the brief renders before the CLI records today's surfacing, so it reads "shown on
* N prior distinct days". Not the saturation SCORING of slice (b) nor the day-over-day diff of (d).
*/
function surfacedToken(e: BriefEntry): string {
const c = e.trend.surfacedCount;
return c && c >= 2 ? ` · sett ${c}x` : "";
}
function renderTopEntry(e: BriefEntry, n: number): string[] {
const lines = [
`### ${n}. ${e.trend.title}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · Pillarer: ${e.matchedPillars.join(", ")}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
];
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
lines.push(`- 🔗 ${e.trend.url}`);
@ -156,7 +168,7 @@ function renderTopEntry(e: BriefEntry, n: number): string[] {
}
function renderBulletEntry(e: BriefEntry): string {
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · 🔗 ${e.trend.url}`;
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
}
/**
@ -172,7 +184,7 @@ export function renderBrief(ranking: BriefRanking): string {
lines.push(`date: ${ranking.today}`);
lines.push(`summary: ${briefSummary(ranking)}`);
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
lines.push("---");
lines.push("");
@ -205,6 +217,15 @@ export function renderBrief(ranking: BriefRanking): string {
return lines.join("\n") + "\n";
}
/**
* The ids of the entries renderBrief actually shows: topMatches singleMatches the first 5
* olderMatched (mirroring the render's .slice(0,5)). The brief CLI feeds these to markSurfaced so
* the seen-log records exactly what the operator saw. Pure (RE-R3b).
*/
export function surfacedIds(ranking: BriefRanking): string[] {
return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id);
}
/**
* Default brief directory under the per-user data dir, DERIVED from
* defaultStorePath() so root resolution lives in exactly one place: