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:

View file

@ -7,10 +7,11 @@
* node --import tsx src/cli.ts query --topics <a,b> [--store <path>] [--json]
* node --import tsx src/cli.ts list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]
* node --import tsx src/cli.ts status [--store <path>] [--json]
* node --import tsx src/cli.ts act|skip|reset --id <id> [--store <path>]
* echo '<raw item|batch>' | node --import tsx src/cli.ts normalize
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]
*
* The capture agent (research-engine) folds freshly-polled trends into the store via
* `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput
@ -20,8 +21,10 @@
* accumulated history via `query`/`list`. `brief` (RE-R2b)
* renders a dated, pillar-ranked morning brief over the store to a Markdown file the
* SessionStart hook surfaces. `add` is the MANUAL single-trend path (raw flags, no
* normalization, publish-date-free). The polling + relevance-scoring itself lives
* upstream; this is the deterministic store.
* normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status
* (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent
* `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score
* (timing decays). The polling + relevance-scoring itself lives upstream; this is the deterministic store.
*
* `normalize` + `score` (RE-R1) and `capture` (RE-R2a) are the deterministic
* research-engine seam: all read their JSON PAYLOAD FROM STDIN (so they do not overload
@ -30,7 +33,8 @@
* `capture` normalizes + folds each valid item into the store (persisting `publishedAt`),
* reporting content-invalid items in the summary `errors[]`, never via the exit code.
*
* Exit code: 0 on success, 2 on usage error (incl. unparseable stdin / bad flag).
* Exit code: 0 on success, 2 on usage error or a not-found id (act/skip/reset). A wrong --id is an
* argument-class error; capture's content-invalid items stay in errors[] (never via the exit code).
*/
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
@ -41,14 +45,17 @@ import {
defaultStorePath,
history,
loadStore,
markSurfaced,
newestCaptureDate,
queryByTopic,
saveStore,
setStatus,
} from "./store.js";
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 } from "./brief.js";
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir, surfacedIds } from "./brief.js";
function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {};
@ -84,10 +91,11 @@ function usage(msg: string): never {
" query --topics <a,b> [--store <path>] [--json]\n" +
" list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" +
" status [--store <path>] [--json]\n" +
" act|skip|reset --id <id> [--store <path>]\n" +
" normalize < raw-item-or-batch.json\n" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]",
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]",
);
process.exit(2);
}
@ -211,6 +219,21 @@ function main(): void {
return;
}
if (command === "act" || command === "skip" || command === "reset") {
const id = flags.id;
if (!id || id === "true") usage(`${command} needs --id <id>`);
const status: TrendStatus = command === "act" ? "acted" : command === "skip" ? "skipped" : "new";
const store = loadStore(storePath);
const res = setStatus(store, id, status);
if (!res.found) {
console.error(`error: no trend with id: ${id}`);
process.exit(2);
}
saveStore(storePath, store);
console.log(`Marked ${id} ${status}`);
return;
}
if (command === "normalize") {
const payload = readStdinJson();
const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload);
@ -249,7 +272,7 @@ function main(): void {
const { items, errors } = normalizeItems(raw);
const store = loadStore(storePath);
// Tally derived from AddResult {added, merged} (no `duplicates` field): a fold is
// `added` (new), else `merged` (existing gained topics), else a plain `duplicate`.
// `added` (new), else `merged` (existing gained topics and/or a refreshed score), else a plain `duplicate`.
let added = 0;
let merged = 0;
let duplicates = 0;
@ -283,17 +306,24 @@ function main(): void {
// defaultBriefDir() so it never writes to ./true.
const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir();
const day = today(); // one wall-clock read for both the ranking and the filename
const ranking = rankForBrief(loadStore(storePath), pillars, day, { freshDays });
const store = loadStore(storePath); // hoisted: also needed for the surfacing write below
const ranking = rankForBrief(store, pillars, day, { freshDays });
const md = renderBrief(ranking);
const path = join(outDir, `${day}.md`);
mkdirSync(outDir, { recursive: true });
writeFileSync(path, md, "utf8");
// RE-R3b: record surfacing on the store AFTER the pure render (per-day idempotent), unless --no-mark.
// The handled (acted/skipped) records were filtered from the ranking but remain in `store`, so the
// resave preserves them; only the surfaced ids' surfacedCount/lastSurfacedAt change.
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
if (asJson) {
console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary }, null, 2));
console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary, marked }, null, 2));
return;
}
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh)`);
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)`);
return;
}

View file

@ -18,7 +18,7 @@ import { homedir } from "node:os";
import { createHash } from "node:crypto";
import { SCHEMA_VERSION } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus } from "./types.js";
import type { TrendScore } from "./score.js";
export { SCHEMA_VERSION } from "./types.js";
@ -41,7 +41,7 @@ export interface AddResult {
store: TrendStore;
/** true iff a new trend was appended (false = duplicate title+url). */
added: boolean;
/** true iff an existing duplicate gained new topic tags via union. */
/** true iff an existing duplicate was mutated — topic tags unioned and/or its score refreshed (RE-R3b). */
merged: boolean;
}
@ -79,11 +79,12 @@ export function emptyStore(): TrendStore {
export function loadStore(path: string): TrendStore {
if (!existsSync(path)) return emptyStore();
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>;
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3 are
// all purely additive-optional (an old record is already a valid v3 record that simply
// lacks the optional publishedAt [v2] / score [v3]), so the migration is the version
// stamp alone — records pass through untouched (lossless + idempotent for any
// well-formed store; a new optional field survives JSON.stringify on resave).
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3→v4 are
// all purely additive-optional (an old record is already a valid v4 record that simply
// lacks the optional publishedAt [v2] / score [v3] / status+surfacedCount+lastSurfacedAt
// [v4]), so the migration is the version stamp alone — records pass through untouched
// (lossless + idempotent for any well-formed store; new optional fields survive
// JSON.stringify on resave).
// A string / NaN / absent version coerces to the current version (never crashes); the
// non-array `trends` coercion below is unchanged and out of the losslessness claim.
const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION;
@ -127,7 +128,16 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
if (existing) {
const { topics, changed } = unionTopics(existing.topics, input.topics);
existing.topics = topics;
return { store, added: false, merged: changed };
let mutated = changed;
// RE-R3b: re-score on re-capture (last-wins). `score` is the ONE mutable field — a fresh
// judgment (timing decays) replaces the stored one; the JSON compare avoids a false-merge
// on an identical re-score. Provenance (source/capturedAt/publishedAt) and lifecycle
// (status/surfacedCount/lastSurfacedAt) are untouched.
if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) {
existing.score = input.score;
mutated = true;
}
return { store, added: false, merged: mutated };
}
const trend: TrendRecord = {
id,
@ -144,6 +154,53 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
return { store, added: true, merged: false };
}
// ── RE-R3b lifecycle helpers (the trend's life AFTER first capture) ──
/** The record's lifecycle status, defaulting absent → "new" (the single reader of that convention). Pure. */
export function effectiveStatus(t: TrendRecord): TrendStatus {
return t.status ?? "new";
}
/**
* Set a trend's lifecycle status by id (the act/skip/reset verbs). Mutates the matched
* record in place and returns the same store; an unknown id is a no-op reported as
* { found: false } (never throws). Pure (no fs).
*/
export function setStatus(
store: TrendStore,
id: string,
status: TrendStatus,
): { store: TrendStore; found: boolean } {
const t = store.trends.find((x) => x.id === id);
if (!t) return { store, found: false };
t.status = status;
return { store, found: true };
}
/**
* Record that the given trends were surfaced in a brief on `today` (the seen-log, B4).
* PER-DAY IDEMPOTENT: a record already surfaced on `today` is skipped, so re-running the
* same day's brief does not double-count. Increments surfacedCount (absent 0) and stamps
* lastSurfacedAt; returns how many records were actually incremented. Pure `today` is
* injected by the caller (the CLI edge), like the store's capturedAt.
*/
export function markSurfaced(
store: TrendStore,
ids: string[],
today: string,
): { store: TrendStore; marked: number } {
const wanted = new Set(ids);
let marked = 0;
for (const t of store.trends) {
if (!wanted.has(t.id)) continue;
if (t.lastSurfacedAt === today) continue; // per-day idempotent
t.surfacedCount = (t.surfacedCount ?? 0) + 1;
t.lastSurfacedAt = today;
marked++;
}
return { store, marked };
}
/**
* Trends whose topics overlap the query, ranked by overlap (desc) then recency
* (capturedAt desc). Topic matching is case-insensitive. Non-matches are

View file

@ -26,6 +26,9 @@
import type { TrendScore } from "./score.js";
/** The lifecycle state of a trend (RE-R3b). Absent on a record ⇒ "new" (see effectiveStatus). */
export type TrendStatus = "new" | "acted" | "skipped";
export interface TrendRecord {
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
id: string;
@ -54,8 +57,25 @@ export interface TrendRecord {
* computed once at first sight by the store's single scorer owner. First-sight,
* never updated on re-capture (re-score pairs with the R3b status slice). Absent on
* pre-R3a records and on the score-free `add` manual path (key omitted).
*
* RE-R3b makes `score` the one MUTABLE field: a re-capture carrying a fresh judgment
* refreshes it (last-wins, timing decays), via addTrend's duplicate branch.
*/
score?: TrendScore;
/**
* The trend's lifecycle status (RE-R3b). Absent "new" (effectiveStatus). Set only by
* the act/skip/reset CLI verbs, never on capture a freshly-captured trend is implicitly
* new. The morning brief excludes anything not "new" (a work queue, not an archive).
*/
status?: TrendStatus;
/**
* The seen-log count (RE-R3b, B4): distinct days this trend has appeared in a generated
* brief. Absent 0. Incremented (per-day-idempotent) by the brief CLI after the pure
* ranking the temporal foundation slices (c)+(b) read.
*/
surfacedCount?: number;
/** ISO date of the most recent surfacing (RE-R3b). Absent ⇒ never. The per-day idempotency key. */
lastSurfacedAt?: string;
}
export interface TrendStore {
@ -70,4 +90,4 @@ export interface TrendQueryHit {
topicOverlap: number;
}
export const SCHEMA_VERSION = 3;
export const SCHEMA_VERSION = 4;