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

@ -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;
}