feat(linkedin-studio): RE-R2b — dated morning-brief artifact + session-start surfacing [skip-docs]

The visible layer of R2. Pure brief.ts: rankForBrief (pillar-overlap -> recency over
the store; publishedAt ?? capturedAt freshness, 7d window; total-order sort), renderBrief
(dated Markdown + hook-surfaceable summary frontmatter), briefSummary (one summary source),
defaultBriefDir (derived from defaultStorePath). CLI `brief` writes
<data>/trends/morning-brief/YYYY-MM-DD.md; session-start surfaces the latest zero-tsx
(latestMorningBrief). Wired into trend-spotter Step 4.6 (scan->capture->brief->surfaced).
No store-schema/scoring change; no scheduler (R3).

25 new trends tests (21 brief.test + 4 cli brief, RED-first) + 3 hook tests (morning-brief
surfacing). trends 104/104 (floor 104), hook-suite 139/139, gate FAIL=0 (ASSERT floor 94,
Section 16i: cli brief-handler + trend-spotter brief-pointer + session-start surfacing
greps), tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 13:12:54 +02:00
commit fa7551070e
9 changed files with 712 additions and 10 deletions

View file

@ -10,12 +10,15 @@
* 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]
*
* The capture agent (research-engine) folds freshly-polled trends into the store via
* `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput
* addTrend), and reasons over accumulated history via `query`/`list`. `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.
* addTrend), and reasons over 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.
*
* `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
@ -27,7 +30,8 @@
* Exit code: 0 on success, 2 on usage error (incl. unparseable stdin / bad flag).
*/
import { readFileSync } from "node:fs";
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
addTrend,
@ -41,6 +45,7 @@ import {
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";
function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {};
@ -78,7 +83,8 @@ function usage(msg: string): never {
" status [--store <path>] [--json]\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",
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]",
);
process.exit(2);
}
@ -262,6 +268,32 @@ function main(): void {
return;
}
if (command === "brief") {
const pillars = splitTopics(flags.pillars);
let freshDays = 7;
if (flags["fresh-days"] && flags["fresh-days"] !== "true") {
const n = Number.parseInt(flags["fresh-days"], 10);
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
freshDays = n;
}
// A bare `--out` yields the string "true" (parseFlags); the guard falls back to
// 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 md = renderBrief(ranking);
const path = join(outDir, `${day}.md`);
mkdirSync(outDir, { recursive: true });
writeFileSync(path, md, "utf8");
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));
return;
}
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh)`);
return;
}
usage(command ? `unknown command: ${command}` : "no command given");
}