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

@ -43,7 +43,11 @@
# with a non-vacuity self-test) in Section 16g; the trends-capture wiring guard (RE-R2a:
# scripts/trends/src/cli.ts dispatches `capture` AND agents/trend-spotter.md references the
# capture CLI 'src/cli.ts capture', with a non-vacuity self-test) in Section 16h; the
# assertion-count anti-erosion floor (SC6) in Section 18. All are live below (Sections 818).
# trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`,
# agents/trend-spotter.md references the brief CLI 'src/cli.ts brief', AND
# hooks/scripts/session-start.mjs surfaces it via 'latestMorningBrief', with a non-vacuity
# self-test) in Section 16i; the assertion-count anti-erosion floor (SC6) in Section 18. All
# are live below (Sections 818).
#
# Usage: bash scripts/test-runner.sh
# bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`.
@ -694,7 +698,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then
TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" )
TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
TRENDS_TESTS_FLOOR=79 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt)
TRENDS_TESTS_FLOOR=104 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief)
if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then
pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)"
else
@ -1115,6 +1119,57 @@ fi
echo ""
# --- Section 16i: Trends Brief Wiring (research-engine RE-R2b) ---
echo "--- Trends Brief Wiring ---"
# RE-R2b makes the store VISIBLE: a `brief` CLI ranks the store by pillar-overlap + recency and
# writes a dated Markdown file the SessionStart hook surfaces. Three literals must hold, grepped
# EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
# (1) cli.ts dispatches the `brief` subcommand, by the literal 'command === "brief"';
# (2) agents/trend-spotter.md generates the brief after capture, by the literal 'src/cli.ts brief';
# (3) hooks/scripts/session-start.mjs surfaces it, by the literal 'latestMorningBrief' (the
# surfacing is wired, not merely documented).
# Non-vacuity self-test mirrors Section 16h: the wiring predicate must accept a probe carrying the
# brief-pointer literal and reject one without it. Placed after Section 16h / before Section 18
# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts
# toward ASSERT_BASELINE_FLOOR.
BRIEF_HANDLER_LIT='command === "brief"'
BRIEF_WIRE_LIT='src/cli.ts brief'
BRIEF_SURFACE_LIT='latestMorningBrief'
I16_SELFTEST_OK=1
if ! echo 'after capture, run src/cli.ts brief --pillars to write the dated brief' | grep -qF "$BRIEF_WIRE_LIT"; then
I16_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired brief-pointer probe was not detected"
fi
if echo 'the agent renders the morning brief itself' | grep -qF "$BRIEF_WIRE_LIT"; then
I16_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the brief pointer"
fi
if [ "$I16_SELFTEST_OK" -eq 1 ]; then
pass "trends-brief self-test: brief-pointer predicate detects wiring, rejects the under-wired form"
else
fail "trends-brief self-test failed — the brief-wiring lint is vacuous or over-eager"
fi
if grep -qF "$BRIEF_HANDLER_LIT" scripts/trends/src/cli.ts; then
pass "cli.ts dispatches the brief subcommand ('$BRIEF_HANDLER_LIT')"
else
fail "cli.ts has no brief handler — add a '$BRIEF_HANDLER_LIT' branch (RE-R2b morning brief)"
fi
if grep -qF "$BRIEF_WIRE_LIT" agents/trend-spotter.md; then
pass "trend-spotter.md references the brief CLI ('$BRIEF_WIRE_LIT') as the post-capture surfacing step"
else
fail "trend-spotter.md does not reference the brief CLI — wire Step 4.6 to a '$BRIEF_WIRE_LIT' call (RE-R2b)"
fi
if grep -qF "$BRIEF_SURFACE_LIT" hooks/scripts/session-start.mjs; then
pass "session-start.mjs surfaces the morning brief ('$BRIEF_SURFACE_LIT')"
else
fail "session-start.mjs does not surface the brief — add the '$BRIEF_SURFACE_LIT' reader (RE-R2b hull 4)"
fi
echo ""
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
@ -1128,12 +1183,14 @@ echo ""
# +3 for RE-R1's three UNCONDITIONAL Section-16g checks (trends-scorer self-test + score.ts
# both-modes weight-set grep + trend-spotter scorer-pointer grep) = 87; +3 for RE-R2a's three
# UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep +
# trend-spotter capture-pointer grep) = 90.
# trend-spotter capture-pointer grep) = 90; +4 for RE-R2b's four UNCONDITIONAL Section-16i checks
# (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep +
# session-start surfacing grep) = 94.
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
# clone). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_FLOOR=90
ASSERT_BASELINE_FLOOR=94
TOTAL_CHECKS=$((PASS + FAIL))
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"

View file

@ -69,11 +69,32 @@ node --import tsx src/cli.ts query --topics "agents,engineering" [--json]
# Time-scoped history — newest first, optionally windowed/capped
node --import tsx src/cli.ts list [--since 2026-06-01] [--limit 10] [--json]
# Dated morning brief — rank the store by pillar-overlap then recency, write a dated
# Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config).
node --import tsx src/cli.ts brief --pillars "agents,engineering" \
[--fresh-days 7] [--out <dir>] [--store <path>] [--json]
```
Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend
never appends a duplicate, it only unions any new topics in.
## Morning brief (RE-R2b)
`brief` is the dated, surfaced read over the store (distinct from `query`/`list`, which are
interactive dumps). It ranks the store against the user's pillars — overlap desc, then
`publishedAt ?? capturedAt` recency — buckets into top (2+ pillars), single (1 pillar), and
older (matched but outside the freshness window, default 7 days), and writes:
```
${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/morning-brief/YYYY-MM-DD.md
```
The file's YAML frontmatter carries a single-line `summary` the SessionStart hook surfaces
verbatim (zero-tsx — it reads the Markdown, never the TS CLI). Ranking uses only persisted
fields; a persisted relevance score, an autonomous nightly trigger, and a seen-log freshness
model are later slices.
## Tests
```bash

201
scripts/trends/src/brief.ts Normal file
View file

@ -0,0 +1,201 @@
/**
* The dated morning-brief layer (research-engine §5, RE-R2b the visible layer).
*
* Pure read-only view over the persistent trend store: rank the accumulated,
* publish-dated signals against the user's content pillars (overlap), filter to a
* freshness window, and render a dated Markdown artifact a later session surfaces.
* No fs, no clock, no AI, no network `today` and `pillars` are injected by the
* caller (the CLI edge), exactly like the store's `capturedAt`. Determinism is the
* contract: same (store, pillars, today, freshDays) -> byte-identical output.
*
* Ranking uses ONLY persisted fields (pillar overlap + publishedAt/capturedAt
* recency). A persisted relevance/saturation score, an autonomous trigger, and a
* seen-log freshness model are all later slices (R3); this module ships the
* deterministic read the surfacing needs and nothing more.
*/
import { join, dirname } from "node:path";
import { defaultStorePath } 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;
/** One ranked trend in the brief, with its pillar overlap + freshness. */
export interface BriefEntry {
trend: TrendRecord;
/** How many of the user's pillars the trend's topics matched. */
overlap: number;
/** The matched pillar names, in pillar order, original case preserved. */
matchedPillars: string[];
/** publishedAt ?? capturedAt — the date freshness + ordering use. */
effectiveDate: string;
/** Whole days from effectiveDate to the injected `today` (negative if future). */
ageDays: number;
}
/** The full ranking the brief renders from. */
export interface BriefRanking {
today: string;
freshDays: number;
totals: { trends: number; matched: number; fresh: number };
/** overlap >= 2 AND fresh. */
topMatches: BriefEntry[];
/** overlap === 1 AND fresh. */
singleMatches: BriefEntry[];
/** overlap >= 1 AND NOT fresh. */
olderMatched: BriefEntry[];
}
export interface RankOptions {
/** Freshness window in days (effectiveDate within N days of today). Default 7. */
freshDays?: number;
}
/**
* Whole days from `effectiveDate` to `today` (floor). Local to this module NOT
* imported from cli.ts's daysBetween: cli.ts imports brief.ts, so importing back
* would invert the dependency direction. brief.ts stays a leaf the CLI composes.
*/
function ageDaysBetween(effectiveDate: string, today: string): number {
return Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000);
}
/**
* Rank the store against the user's pillars. Off-pillar trends (overlap 0) are
* dropped; the rest bucket into top (>=2 & fresh), single (==1 & fresh), and older
* (>=1 & stale). Each bucket is a TOTAL order: overlap desc, then effectiveDate
* desc (freshest first), then title asc, then url asc so the output is fixed
* regardless of store insertion order.
*/
export function rankForBrief(
store: TrendStore,
pillars: string[],
today: string,
opts: RankOptions = {},
): BriefRanking {
const freshDays = opts.freshDays ?? 7;
const wantedLower = pillars.map((p) => p.toLowerCase());
const entries: BriefEntry[] = [];
for (const trend of store.trends) {
const have = new Set(trend.topics.map((t) => t.toLowerCase()));
const matchedPillars: string[] = [];
for (let i = 0; i < pillars.length; i++) {
if (have.has(wantedLower[i])) matchedPillars.push(pillars[i]);
}
const overlap = matchedPillars.length;
if (overlap === 0) continue; // off-pillar noise
const effectiveDate = trend.publishedAt ?? trend.capturedAt;
entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) });
}
const cmp = (a: BriefEntry, b: BriefEntry): number =>
b.overlap - a.overlap ||
b.effectiveDate.localeCompare(a.effectiveDate) ||
a.trend.title.localeCompare(b.trend.title) ||
a.trend.url.localeCompare(b.trend.url);
const isFresh = (e: BriefEntry): boolean => e.ageDays <= freshDays;
const topMatches = entries.filter((e) => e.overlap >= 2 && isFresh(e)).sort(cmp);
const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp);
const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded)
return {
today,
freshDays,
totals: { trends: store.trends.length, matched: entries.length, fresh: topMatches.length + singleMatches.length },
topMatches,
singleMatches,
olderMatched,
};
}
/**
* The single source of the brief's one-line summary used by renderBrief (the
* frontmatter line the SessionStart hook surfaces verbatim) AND the CLI --json.
* 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 {
const fresh = ranking.totals.fresh;
if (fresh > 0) {
const top = ranking.topMatches[0] ?? ranking.singleMatches[0];
const pillar = top.matchedPillars[0];
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar} · ${top.ageDays}d).`;
}
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
}
function renderTopEntry(e: BriefEntry, n: number): string[] {
const lines = [
`### ${n}. ${e.trend.title}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d) · Pillarer: ${e.matchedPillars.join(", ")}`,
];
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
lines.push(`- 🔗 ${e.trend.url}`);
lines.push("");
return lines;
}
function renderBulletEntry(e: BriefEntry): string {
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d) · 🔗 ${e.trend.url}`;
}
/**
* Render the full dated Markdown artifact: YAML frontmatter (date, the shared
* 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 {
const { totals } = ranking;
const lines: string[] = [];
lines.push("---");
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: pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
lines.push("---");
lines.push("");
lines.push(`# Morgen-brief — ${ranking.today}`);
lines.push("");
lines.push(
`**${totals.fresh} ferske signaler** (publisert ≤${ranking.freshDays} dager) matcher temaene dine, av ${totals.trends} i lager.`,
);
lines.push("");
lines.push("## 🎯 Topp-treff (2+ pillarer)");
if (ranking.topMatches.length === 0) {
lines.push("_Ingen i dag._", "");
} else {
ranking.topMatches.forEach((e, i) => lines.push(...renderTopEntry(e, i + 1)));
}
lines.push("## 📌 Enkelt-treff (1 pillar)");
if (ranking.singleMatches.length === 0) lines.push("_Ingen i dag._");
else ranking.singleMatches.forEach((e) => lines.push(renderBulletEntry(e)));
lines.push("");
lines.push(`## 💤 Eldre i lager (matcher, men >${ranking.freshDays}d) — ${ranking.olderMatched.length} stk`);
ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e)));
lines.push("");
lines.push("---");
lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");
return lines.join("\n") + "\n";
}
/**
* Default brief directory under the per-user data dir, DERIVED from
* defaultStorePath() so root resolution lives in exactly one place:
* <root>/trends/trends.json -> <root>/trends/morning-brief. Colocated with the
* store the brief reads. Pure path computation, no fs.
*/
export function defaultBriefDir(): string {
return join(dirname(defaultStorePath()), "morning-brief");
}

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");
}

View file

@ -0,0 +1,173 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import {
rankForBrief,
renderBrief,
briefSummary,
defaultBriefDir,
BRIEF_SCHEMA_VERSION,
} from "../src/brief.js";
import type { TrendRecord, TrendStore } from "../src/types.js";
const TODAY = "2026-06-24";
function mkTrend(
p: { title: string; url: string; topics: string[]; capturedAt: string; publishedAt?: string; source?: string; summary?: string },
): TrendRecord {
return {
id: p.title + "|" + p.url,
title: p.title,
url: p.url,
source: p.source ?? "tavily",
capturedAt: p.capturedAt,
...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
topics: p.topics,
...(p.summary !== undefined ? { summary: p.summary } : {}),
};
}
function mkStore(trends: TrendRecord[]): TrendStore {
return { schemaVersion: 2, trends };
}
describe("rankForBrief — grouping (SC1)", () => {
const pillars = ["AI", "gov"];
const store = mkStore([
mkTrend({ title: "T1 top", url: "https://e/1", topics: ["ai", "gov", "x"], publishedAt: "2026-06-22", capturedAt: "2026-06-23" }),
mkTrend({ title: "T2 single", url: "https://e/2", topics: ["AI"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
mkTrend({ title: "T3 older1", url: "https://e/3", topics: ["gov"], publishedAt: "2026-06-01", capturedAt: "2026-06-01" }),
mkTrend({ title: "T4 noise", url: "https://e/4", topics: ["unrelated"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
mkTrend({ title: "T5 older2", url: "https://e/5", topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
]);
const r = rankForBrief(store, pillars, TODAY);
test("topMatches = overlap>=2 & fresh only", () => {
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["T1 top"]);
});
test("singleMatches = overlap===1 & fresh only", () => {
assert.deepEqual(r.singleMatches.map((e) => e.trend.title), ["T2 single"]);
});
test("olderMatched = overlap>=1 & stale, sorted overlap desc", () => {
assert.deepEqual(r.olderMatched.map((e) => e.trend.title), ["T5 older2", "T3 older1"]);
});
test("overlap===0 excluded everywhere", () => {
const all = [...r.topMatches, ...r.singleMatches, ...r.olderMatched].map((e) => e.trend.title);
assert.ok(!all.includes("T4 noise"));
});
test("totals reflect trends/matched/fresh", () => {
assert.deepEqual(r.totals, { trends: 5, matched: 4, fresh: 2 });
});
test("matchedPillars preserve pillar case (case-insensitive match)", () => {
assert.deepEqual(r.topMatches[0].matchedPillars, ["AI", "gov"]);
});
});
describe("rankForBrief — within-group total order (SC1)", () => {
test("effectiveDate desc orders before title", () => {
const store = mkStore([
mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
]);
const r = rankForBrief(store, ["a", "b"], TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Alpha", "Bravo"]);
});
test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => {
const store = mkStore([
mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
]);
const r = rankForBrief(store, ["a", "b"], TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
});
});
describe("rankForBrief — freshness (SC2)", () => {
const pillars = ["a"];
test("effectiveDate = publishedAt when present (fresh despite old capturedAt)", () => {
const r = rankForBrief(mkStore([mkTrend({ title: "P", url: "https://e/p", topics: ["a"], publishedAt: "2026-06-22", capturedAt: "2026-01-01" })]), pillars, TODAY);
assert.equal(r.singleMatches.length, 1);
assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22");
assert.equal(r.olderMatched.length, 0);
});
test("fallback to capturedAt when publishedAt absent", () => {
const r = rankForBrief(mkStore([mkTrend({ title: "C", url: "https://e/c", topics: ["a"], capturedAt: "2026-06-22" })]), pillars, TODAY);
assert.equal(r.singleMatches.length, 1);
assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22");
});
test("stale when capturedAt old and no publishedAt", () => {
const r = rankForBrief(mkStore([mkTrend({ title: "S", url: "https://e/s", topics: ["a"], capturedAt: "2026-01-01" })]), pillars, TODAY);
assert.equal(r.olderMatched.length, 1);
assert.equal(r.singleMatches.length, 0);
});
test("boundary: ageDays === freshDays is fresh (<=)", () => {
const r = rankForBrief(mkStore([mkTrend({ title: "B", url: "https://e/bd", topics: ["a"], publishedAt: "2026-06-17", capturedAt: "2026-06-17" })]), pillars, TODAY, { freshDays: 7 });
assert.equal(r.singleMatches.length, 1, "7d with freshDays 7 must be fresh");
assert.equal(r.singleMatches[0].ageDays, 7);
});
test("freshDays configurable: 10d fresh at 14, stale at 7", () => {
const t = mkTrend({ title: "X", url: "https://e/x", topics: ["a"], publishedAt: "2026-06-14", capturedAt: "2026-06-14" });
assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 14 }).singleMatches.length, 1);
assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 7 }).olderMatched.length, 1);
});
});
describe("renderBrief + briefSummary (SC3)", () => {
const pillars = ["AI", "gov"];
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", summary: "A short summary" }),
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
mkTrend({ title: "Gamma", url: "https://e/g", topics: ["gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
]);
const r = rankForBrief(store, pillars, TODAY);
const md = renderBrief(r);
test("starts with YAML frontmatter", () => {
assert.ok(md.startsWith("---\n"), "brief must open with YAML frontmatter");
});
test("frontmatter carries date, store, schemaVersion", () => {
assert.match(md, /\ndate: 2026-06-24\n/);
assert.match(md, new RegExp("\\nschemaVersion: " + BRIEF_SCHEMA_VERSION + "\\n"));
assert.match(md, /\nstore:/);
});
test("frontmatter summary === briefSummary(ranking); single line, no quote/newline", () => {
const summary = briefSummary(r);
assert.ok(!summary.includes('"'), "summary must not contain a double-quote");
assert.ok(!summary.includes("\n"), "summary must be a single line");
const m = md.match(/^summary: (.*)$/m);
assert.ok(m, "frontmatter has a summary line");
assert.equal(m![1], summary);
});
test("summary names the top entry when fresh matches exist", () => {
assert.ok(briefSummary(r).includes("Alpha"));
});
test("body has the three section markers", () => {
assert.ok(md.includes("Topp-treff"), "top section");
assert.ok(md.includes("Enkelt-treff"), "single section");
assert.ok(md.includes("Eldre i lager"), "older section");
});
test("deterministic: identical input -> identical bytes", () => {
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
});
test("empty ranking renders a valid no-fresh brief", () => {
const empty = rankForBrief(mkStore([]), pillars, TODAY);
const emd = renderBrief(empty);
assert.ok(emd.startsWith("---\n"));
assert.ok(briefSummary(empty).startsWith("Ingen ferske"), "no-fresh summary line");
const m = emd.match(/^summary: (.*)$/m);
assert.equal(m![1], briefSummary(empty));
});
});
describe("defaultBriefDir", () => {
test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
const prev = process.env.LINKEDIN_STUDIO_DATA;
process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-brief-root";
try {
assert.equal(defaultBriefDir(), join("/tmp/lis-brief-root", "trends", "morning-brief"));
} finally {
if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
else process.env.LINKEDIN_STUDIO_DATA = prev;
}
});
});

View file

@ -2,7 +2,7 @@ import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@ -159,3 +159,94 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
});
});
});
describe("trends CLI — brief subcommand (RE-R2b / Step 3)", () => {
// brief is flag-driven (reads the store, not stdin). spawn with an env-overridable
// LINKEDIN_STUDIO_DATA so defaultBriefDir() resolves into a temp root (never the real HOME).
function runBrief(args: string[], env: Record<string, string> = {}): { status: number | null; stdout: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "brief", ...args], {
input: "",
encoding: "utf8",
cwd: trendsDir,
env: { ...process.env, ...env },
});
return { status: res.status, stdout: res.stdout };
}
const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
function seedStore(trends: unknown[]): string {
const store = join(mkdtempSync(join(tmpdir(), "trends-brief-")), "trends.json");
writeFileSync(store, JSON.stringify({ schemaVersion: 2, trends }));
return store;
}
test("happy: writes a dated brief, --json carries path/date/totals/summary", () => {
const store = seedStore([
{ id: "a", title: "Fresh Match", url: "https://e/a", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai", "gov"] },
]);
const out = mkdtempSync(join(tmpdir(), "brief-out-"));
try {
const { status, stdout } = runBrief(["--pillars", "ai,gov", "--store", store, "--out", out, "--json"]);
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.match(summary.path, /\d{4}-\d{2}-\d{2}\.md$/);
assert.match(summary.date, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(summary.totals.trends, 1);
assert.equal(summary.totals.fresh, 1);
assert.ok(existsSync(summary.path), "the dated brief file is written");
const md = readFileSync(summary.path, "utf8");
const m = md.match(/^summary: (.*)$/m);
assert.ok(m, "the brief frontmatter has a summary line");
assert.equal(m![1], summary.summary, "--json summary equals the file frontmatter summary (one source)");
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
}
});
test("bad invocation: --fresh-days non-numeric -> exit 2", () => {
const store = seedStore([]);
try {
const { status } = runBrief(["--pillars", "ai", "--store", store, "--fresh-days", "xyz"]);
assert.equal(status, 2);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("empty --pillars -> exit 0 + a no-match brief is still written", () => {
const store = seedStore([
{ id: "a", title: "X", url: "https://e/x", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"] },
]);
const out = mkdtempSync(join(tmpdir(), "brief-out-"));
try {
const { status, stdout } = runBrief(["--store", store, "--out", out, "--json"]);
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.equal(summary.totals.matched, 0, "no pillars -> nothing matched");
assert.ok(existsSync(summary.path), "a dated no-match brief is still written");
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
}
});
test("bare --out (no value) -> falls back to defaultBriefDir, never ./true", () => {
const store = seedStore([]);
const dataRoot = mkdtempSync(join(tmpdir(), "brief-data-"));
try {
// bare --out --json: parseFlags yields out:"true"; the !== "true" guard must
// fall back to defaultBriefDir() = <LINKEDIN_STUDIO_DATA>/trends/morning-brief.
const { status, stdout } = runBrief(["--pillars", "ai", "--store", store, "--json", "--out"], { LINKEDIN_STUDIO_DATA: dataRoot });
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.ok(
summary.path.startsWith(join(dataRoot, "trends", "morning-brief")),
"bare --out must fall back to defaultBriefDir under LINKEDIN_STUDIO_DATA, not ./true",
);
assert.ok(!existsSync(join(trendsDir, "true")), "no ./true dir was created");
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
rmSync(dataRoot, { recursive: true, force: true });
}
});
});