feat(linkedin-studio): RE-R3a — persist relevance score on the store record + rank the morning brief on it [skip-docs]

R3 slice 1 (research-deepening). Stop discarding the relevance judgment the
trend-spotter already computes: persist a 4-field TrendScore {mode, dimensions,
composite, priority} on TrendRecord (schema v2->v3, additive lossless migrate),
computed by the existing score.ts composite()+band() (one owner, no new arithmetic),
threaded item->store; then rankForBrief sorts each bucket composite-first (sentinel
-1 for unscored) and renderBrief surfaces "· <priority> (<mode>)" per body entry
(briefSummary shows the band only). First-sight only; mode-blind ranking with the mode
shown so the operator can disambiguate instruments.

- score.ts: TrendScore + requiredDimensions(mode) (ordered) + scoreEnvelope (composes
  composite+band; throws on bad dim by contract)
- types.ts: SCHEMA_VERSION 2->3; TrendRecord.score?
- store.ts: TrendInput.score?; addTrend persists first-sight (duplicate keeps it);
  migrate comment v1->v2->v3 (logic unchanged, JSON.stringify preserves the field)
- item.ts: TrendItem.score?; normalizeItem validates (non-array score/dimensions + the
  mode's five dims in [1,10]) -> structured error never throw, carries validated dims;
  itemToInput -> scoreEnvelope (no throw on the capture path; direct call throws by contract)
- brief.ts: composite-primary comparator; band+mode render; exact ranking: descriptor
- cli.ts: capture persists score via itemToInput (doc-only); add/score paths unchanged
- agents/trend-spotter.md Step 4.5: capture batch carries the Step-2 dimensions
- gate: TRENDS_TESTS_FLOOR 104->146; new unconditional Section 16j; ASSERT floor 94->99

Tests: trends 146/146 (RED two-phase: logic-RED store/brief/cli; stub-first then
assertion-RED score/item). Gate green (Passed 114 / Failed 0; 113 checks >= 99).
Hook suite 139/139 untouched. Counts 27/19/29 unchanged. No new source file/agent/command.

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 14:05:27 +02:00
commit e169c78710
14 changed files with 829 additions and 40 deletions

View file

@ -285,21 +285,36 @@ For every trend that cleared the relevance filter (Step 2) — not only the ones
final digest — fold it into the persistent trend store, so the next session reasons over it final digest — fold it into the persistent trend store, so the next session reasons over it
instead of re-discovering it. Build ONE raw-item batch (the same trends you just scored) and pipe instead of re-discovering it. Build ONE raw-item batch (the same trends you just scored) and pipe
it through `capture`: it normalizes each item, dedupes on normalized title+URL, unions topics on it through `capture`: it normalizes each item, dedupes on normalized title+URL, unions topics on
re-capture (so re-capturing an existing trend just enriches the tags), and persists the source's re-capture (so re-capturing an existing trend just enriches the tags), persists the source's
`publishedAt` for later freshness ranking — one call, not one per trend: `publishedAt` for later freshness ranking, and — when you carry the score (below) — persists the
relevance assessment so the morning brief ranks on it — one call, not one per trend:
```bash ```bash
cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \
echo '[ echo '[
{"source":"<tavily|websearch|manual|…>","title":"<verbatim headline>","url":"<source url>", {"source":"<tavily|websearch|manual|…>","title":"<verbatim headline>","url":"<source url>",
"topics":["<pillar-tag1>","<pillar-tag2>"],"publishedAt":"<YYYY-MM-DD if known>", "topics":["<pillar-tag1>","<pillar-tag2>"],"publishedAt":"<YYYY-MM-DD if known>",
"summary":"<one-line what-happened>"} "summary":"<one-line what-happened>",
"score":{"mode":"kortform","dimensions":{"pillar":N,"audience":N,"timing":N,"angle":N,"authority":N}}}
]' | node --import tsx src/cli.ts capture ]' | node --import tsx src/cli.ts capture
``` ```
`source` is the tool you actually fetched with (**Research Routing**); `publishedAt` is the `source` is the tool you actually fetched with (**Research Routing**); `publishedAt` is the
source's own publish date — omit the key when unknown (the store's `capturedAt` is set source's own publish date — omit the key when unknown (the store's `capturedAt` is set
automatically and stays distinct from it). One `capture` call folds the whole batch and reports automatically and stays distinct from it).
**Carry the Step-2 scores — do not discard them.** You already scored each candidate's five
dimensions 110 in **Relevance Scoring** (Step 2); fold those same numbers into the capture batch
as the item's `"score"`, so the store persists the relevance assessment and the morning brief
ranks on its composite (the store computes the composite + band itself — supply only the judgment).
Use `"mode":"kortform"` by default; use `"mode":"long-form"` with the long-form dimension names
(`pillar`, `depth`, `angle`, `authority`, `currency`) when the caller is producing a chronicle /
newsletter / series edition (e.g. invoked from `/linkedin:newsletter`). The `"dimensions"` keys are
the rubric's, the `"topics"` are the user's pillars — nothing vendor- or sector-specific is baked
in. Omit the `"score"` key when you genuinely did not score an item; an out-of-range or malformed
score is reported in `errors[]` (the valid items still persist) and never crashes the run.
One `capture` call folds the whole batch and reports
`{added, merged, duplicates, errors}`; content-invalid items land in `errors[]`, never failing the `{added, merged, duplicates, errors}`; content-invalid items land in `errors[]`, never failing the
run. Skip this step silently if the store has no deps installed (an adopter without the trends run. Skip this step silently if the store has no deps installed (an adopter without the trends
store) — the digest still compiles, just without persistence. store) — the digest still compiles, just without persistence.

View file

@ -46,7 +46,11 @@
# trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`, # 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 # 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 # 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 # self-test) in Section 16i; the trends-score wiring guard (RE-R3a: scripts/trends/src/score.ts
# exports the 'export interface TrendScore' persist envelope, scripts/trends/src/types.ts carries
# 'score?: TrendScore' on the record, agents/trend-spotter.md carries the judgment via '"dimensions"',
# AND scripts/trends/src/brief.ts ranks on 'score?.composite', with a non-vacuity self-test) in
# Section 16j; the assertion-count anti-erosion floor (SC6) in Section 18. All
# are live below (Sections 818). # are live below (Sections 818).
# #
# Usage: bash scripts/test-runner.sh # Usage: bash scripts/test-runner.sh
@ -698,7 +702,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_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_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) TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
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) TRENDS_TESTS_FLOOR=146 # 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) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank)
if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then 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)" pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)"
else else
@ -1170,6 +1174,66 @@ fi
echo "" echo ""
# --- Section 16j: Trends Score Wiring (research-engine RE-R3a) ---
echo "--- Trends Score Wiring ---"
# RE-R3a persists the relevance score the trend-spotter agent already computes and ranks the
# morning brief on its composite. Four literals must hold, grepped EXACT (grep -F),
# deps-absent-safe (pure grep, no tsx):
# (1) score.ts exports the persist envelope, by the literal 'export interface TrendScore';
# (2) types.ts carries it on the record, by the literal 'score?: TrendScore';
# (3) agents/trend-spotter.md carries the judgment in the capture batch, by 'dimensions'
# (verified absent pre-R3a -> the grep is non-vacuous);
# (4) brief.ts ranks on the composite, by the literal 'score?.composite' (the payoff is wired,
# not merely doc'd).
# Non-vacuity self-test mirrors Section 16i: the rank predicate must accept a probe carrying the
# composite-rank literal and reject one without it. Placed after Section 16i / before Section 18
# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts
# toward ASSERT_BASELINE_FLOOR.
SCORE_IFACE_LIT='export interface TrendScore'
SCORE_TYPE_LIT='score?: TrendScore'
SCORE_DIMS_LIT='"dimensions"'
SCORE_RANK_LIT='score?.composite'
I16J_SELFTEST_OK=1
if ! echo 'rankForBrief sorts on (b.trend.score?.composite ?? -1) first' | grep -qF "$SCORE_RANK_LIT"; then
I16J_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired composite-rank probe was not detected"
fi
if echo 'the brief ranks on pillar overlap only' | grep -qF "$SCORE_RANK_LIT"; then
I16J_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the composite-rank pointer"
fi
if [ "$I16J_SELFTEST_OK" -eq 1 ]; then
pass "trends-score self-test: composite-rank predicate detects wiring, rejects the under-wired form"
else
fail "trends-score self-test failed — the score-wiring lint is vacuous or over-eager"
fi
if grep -qF "$SCORE_IFACE_LIT" scripts/trends/src/score.ts; then
pass "score.ts exports the persist envelope ('$SCORE_IFACE_LIT')"
else
fail "score.ts has no TrendScore envelope — add '$SCORE_IFACE_LIT' (RE-R3a persist)"
fi
if grep -qF "$SCORE_TYPE_LIT" scripts/trends/src/types.ts; then
pass "types.ts carries the score on the record ('$SCORE_TYPE_LIT')"
else
fail "types.ts does not carry the score — add '$SCORE_TYPE_LIT' to TrendRecord (RE-R3a schema v3)"
fi
if grep -qF "$SCORE_DIMS_LIT" agents/trend-spotter.md; then
pass "trend-spotter.md carries the judgment in the capture batch ($SCORE_DIMS_LIT)"
else
fail "trend-spotter.md does not carry the judgment — add the per-item $SCORE_DIMS_LIT to Step 4.5 (RE-R3a wiring)"
fi
if grep -qF "$SCORE_RANK_LIT" scripts/trends/src/brief.ts; then
pass "brief.ts ranks on the composite ('$SCORE_RANK_LIT')"
else
fail "brief.ts does not rank on the composite — add the '$SCORE_RANK_LIT' comparator term (RE-R3a payoff)"
fi
echo ""
# --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped # 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 # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
@ -1185,12 +1249,14 @@ echo ""
# UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep + # UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep +
# trend-spotter capture-pointer grep) = 90; +4 for RE-R2b's four UNCONDITIONAL Section-16i checks # 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 + # (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep +
# session-start surfacing grep) = 94. # session-start surfacing grep) = 94; +5 for RE-R3a's five UNCONDITIONAL Section-16j checks
# (trends-score self-test + score.ts TrendScore-iface grep + types.ts score-field grep +
# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99.
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # 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 # 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 # 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. # clone). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_FLOOR=94 ASSERT_BASELINE_FLOOR=99
TOTAL_CHECKS=$((PASS + FAIL)) TOTAL_CHECKS=$((PASS + FAIL))
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"

View file

@ -39,22 +39,32 @@ interface TrendRecord {
publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled
topics: string[]; // query tags; unioned across re-captures topics: string[]; // query tags; unioned across re-captures
summary?: string; // optional, verbatim summary?: string; // optional, verbatim
score?: TrendScore; // optional persisted relevance (RE-R3a): { mode, dimensions, composite, priority } — first-sight, never re-scored
} }
``` ```
Fields (relevance score, first-mover timing, status) can be added in a later `score` is the persisted relevance envelope (RE-R3a): a capture **item** carries the
slice without breaking the shape. agent's **judgment**`{ mode, dimensions }` (the five 110 dimension scores) — and the
store turns that into the persisted `TrendScore` `{ mode, dimensions, composite, priority }`,
computing the composite + band once via the single scorer owner (`src/score.ts`). It is
set **first-sight** (never updated on re-capture); the score-free `add` manual path omits it.
The morning brief ranks each bucket on `composite` first (schema v3). Further fields
(first-mover timing, status) can still be added in a later slice without breaking the shape.
## CLI ## CLI
```bash ```bash
# Capture freshly-polled trends — the NORMALIZING BATCH path (the research agent's path): # Capture freshly-polled trends — the NORMALIZING BATCH path (the research agent's path):
# raw items on stdin → validate+normalize each → dedupe on title+url → union topics on # raw items on stdin → validate+normalize each → dedupe on title+url → union topics on
# re-capture → persist the source's publishedAt. Content-invalid items are reported in the # re-capture → persist the source's publishedAt → persist the relevance score (when carried).
# summary errors[], never fail the run; the summary is {added, duplicates, merged, errors}. # Content-invalid items (incl. a malformed/out-of-range score) are reported in the summary
# errors[], never fail the run; the summary is {added, duplicates, merged, errors}.
# An item's "score" carries the agent's judgment (mode + the five 110 dimensions); the store
# computes the composite + band and persists the full TrendScore first-sight.
echo '[{"source":"tavily","title":"Agentic workflows hit production", echo '[{"source":"tavily","title":"Agentic workflows hit production",
"url":"https://example.com/agentic","topics":["agents","engineering"], "url":"https://example.com/agentic","topics":["agents","engineering"],
"publishedAt":"2026-06-20","summary":"Teams ship multi-step agents past the demo stage."}]' \ "publishedAt":"2026-06-20","summary":"Teams ship multi-step agents past the demo stage.",
"score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]' \
| node --import tsx src/cli.ts capture [--store <path>] [--json] | node --import tsx src/cli.ts capture [--store <path>] [--json]
# Add a SINGLE trend MANUALLY — raw flags, no normalization, publish-date-free: # Add a SINGLE trend MANUALLY — raw flags, no normalization, publish-date-free:
@ -91,9 +101,10 @@ ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/morning-brief/YYYY
``` ```
The file's YAML frontmatter carries a single-line `summary` the SessionStart hook surfaces 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 verbatim (zero-tsx — it reads the Markdown, never the TS CLI). As of RE-R3a the brief ranks
fields; a persisted relevance score, an autonomous nightly trigger, and a seen-log freshness each bucket on the persisted relevance **composite first** (then pillar-overlap, then recency);
model are later slices. a scored entry shows `· <priority> (<mode>)` and the summary names the top entry's band. An
autonomous nightly trigger and a seen-log freshness model remain later slices.
## Tests ## Tests

View file

@ -91,7 +91,14 @@ export function rankForBrief(
entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) }); entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) });
} }
// Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance
// composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted
// sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts
// cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are
// unchanged; composite only re-orders WITHIN a bucket. The existing overlap → effectiveDate
// → title → url chain still gives a total order (the (title,url) pair is the unique dedupe id).
const cmp = (a: BriefEntry, b: BriefEntry): number => const cmp = (a: BriefEntry, b: BriefEntry): number =>
(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) ||
b.overlap - a.overlap || b.overlap - a.overlap ||
b.effectiveDate.localeCompare(a.effectiveDate) || b.effectiveDate.localeCompare(a.effectiveDate) ||
a.trend.title.localeCompare(b.trend.title) || a.trend.title.localeCompare(b.trend.title) ||
@ -124,15 +131,23 @@ export function briefSummary(ranking: BriefRanking): string {
if (fresh > 0) { if (fresh > 0) {
const top = ranking.topMatches[0] ?? ranking.singleMatches[0]; const top = ranking.topMatches[0] ?? ranking.singleMatches[0];
const pillar = top.matchedPillars[0]; const pillar = top.matchedPillars[0];
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar} · ${top.ageDays}d).`; // Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean.
const band = top.trend.score ? ` · ${top.trend.score.priority}` : "";
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band} · ${top.ageDays}d).`;
} }
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`; return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
} }
/** ` · <priority> (<mode>)` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */
function scoreToken(e: BriefEntry): string {
const s = e.trend.score;
return s ? ` · ${s.priority} (${s.mode})` : "";
}
function renderTopEntry(e: BriefEntry, n: number): string[] { function renderTopEntry(e: BriefEntry, n: number): string[] {
const lines = [ const lines = [
`### ${n}. ${e.trend.title}`, `### ${n}. ${e.trend.title}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d) · Pillarer: ${e.matchedPillars.join(", ")}`, `- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · Pillarer: ${e.matchedPillars.join(", ")}`,
]; ];
if (e.trend.summary) lines.push(`- ${e.trend.summary}`); if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
lines.push(`- 🔗 ${e.trend.url}`); lines.push(`- 🔗 ${e.trend.url}`);
@ -141,7 +156,7 @@ function renderTopEntry(e: BriefEntry, n: number): string[] {
} }
function renderBulletEntry(e: BriefEntry): string { function renderBulletEntry(e: BriefEntry): string {
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d) · 🔗 ${e.trend.url}`; return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · 🔗 ${e.trend.url}`;
} }
/** /**
@ -157,7 +172,7 @@ export function renderBrief(ranking: BriefRanking): string {
lines.push(`date: ${ranking.today}`); lines.push(`date: ${ranking.today}`);
lines.push(`summary: ${briefSummary(ranking)}`); lines.push(`summary: ${briefSummary(ranking)}`);
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`); 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(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`); lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
lines.push("---"); lines.push("---");
lines.push(""); lines.push("");

View file

@ -14,7 +14,10 @@
* *
* The capture agent (research-engine) folds freshly-polled trends into the store via * The capture agent (research-engine) folds freshly-polled trends into the store via
* `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput * `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput
* addTrend), and reasons over accumulated history via `query`/`list`. `brief` (RE-R2b) * addTrend) which, when an item carries the agent's five judgment scores (RE-R3a),
* persists an optional relevance `score` (the deterministically-computed composite + band,
* one owner) first-sight on the record so the morning brief ranks on it 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 * 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 * SessionStart hook surfaces. `add` is the MANUAL single-trend path (raw flags, no
* normalization, publish-date-free). The polling + relevance-scoring itself lives * normalization, publish-date-free). The polling + relevance-scoring itself lives

View file

@ -18,6 +18,8 @@
import { normalizeField } from "./store.js"; import { normalizeField } from "./store.js";
import type { TrendInput } from "./store.js"; import type { TrendInput } from "./store.js";
import { requiredDimensions, scoreEnvelope } from "./score.js";
import type { ScoreMode, DimensionScores } from "./score.js";
export interface TrendItem { export interface TrendItem {
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */ /** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
@ -36,6 +38,13 @@ export interface TrendItem {
topics: string[]; topics: string[];
/** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */ /** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */
summary?: string; summary?: string;
/**
* The agent's relevance JUDGMENT (RE-R3a): the mode + the five 110 dimension scores
* NOT a precomputed composite (the store computes that, one owner). Validated by
* normalizeItem; turned into the persisted envelope by itemToInputscoreEnvelope.
* Absent/invalid -> the key is omitted.
*/
score?: { mode: ScoreMode; dimensions: DimensionScores };
} }
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] }; export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
@ -63,6 +72,40 @@ function isNonEmptyString(v: unknown): v is string {
return typeof v === "string" && v.trim().length > 0; return typeof v === "string" && v.trim().length > 0;
} }
/** A plain (non-array, non-null) object. */
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
const SCORE_MODES = ["kortform", "long-form"] as const;
/**
* Validate a raw `score` structurally never throws (returns a reason on failure). The
* mode must be known; `dimensions` must be a non-array object carrying every key the mode
* requires (requiredDimensions) as a number in [1,10]. On success returns the VALIDATED
* envelope (the validated dimensions object, not the raw one).
*/
function validateScore(
raw: unknown,
): { ok: true; score: { mode: ScoreMode; dimensions: DimensionScores } } | { ok: false; reason: string } {
if (!isPlainObject(raw)) return { ok: false, reason: "score must be an object" };
const mode = raw.mode;
if (typeof mode !== "string" || !(SCORE_MODES as readonly string[]).includes(mode)) {
return { ok: false, reason: `mode must be one of ${SCORE_MODES.join(", ")} (got ${String(mode)})` };
}
const dims = raw.dimensions;
if (!isPlainObject(dims)) return { ok: false, reason: "dimensions must be an object" };
const validated: DimensionScores = {};
for (const key of requiredDimensions(mode as ScoreMode)) {
const value = dims[key];
if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) {
return { ok: false, reason: `dimension "${key}" must be a number in [1,10] (got ${String(value)})` };
}
validated[key] = value;
}
return { ok: true, score: { mode: mode as ScoreMode, dimensions: validated } };
}
/** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */ /** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */
function normalizeTopics(raw: unknown): string[] { function normalizeTopics(raw: unknown): string[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
@ -105,6 +148,13 @@ export function normalizeItem(raw: unknown): NormalizeResult {
} }
} }
let score: { mode: ScoreMode; dimensions: DimensionScores } | undefined;
if (r.score !== undefined && r.score !== null) {
const res = validateScore(r.score);
if (!res.ok) errors.push(`invalid score: ${res.reason}`);
else score = res.score;
}
if (errors.length > 0) return { ok: false, errors }; if (errors.length > 0) return { ok: false, errors };
const item: TrendItem = { const item: TrendItem = {
@ -114,6 +164,7 @@ export function normalizeItem(raw: unknown): NormalizeResult {
topics: normalizeTopics(r.topics), topics: normalizeTopics(r.topics),
...(publishedAt !== undefined ? { publishedAt } : {}), ...(publishedAt !== undefined ? { publishedAt } : {}),
...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}), ...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
...(score !== undefined ? { score } : {}),
}; };
return { ok: true, item }; return { ok: true, item };
} }
@ -123,8 +174,11 @@ export function normalizeItem(raw: unknown): NormalizeResult {
* Pure: injects `capturedAt` (the store's "when WE saw it", supplied by the caller * Pure: injects `capturedAt` (the store's "when WE saw it", supplied by the caller
* never derived here) and carries the rest verbatim. Does NOT re-validate (the item is * never derived here) and carries the rest verbatim. Does NOT re-validate (the item is
* already validated by normalizeItem) and does NOT derive an `id` (the store owns id via * already validated by normalizeItem) and does NOT derive an `id` (the store owns id via
* addTrendtrendId). `publishedAt`/`summary` are carried only when present (key omitted * addTrendtrendId). `publishedAt`/`summary`/`score` are carried only when present (key
* otherwise), mirroring the store's conditional-spread idiom. * omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned
* into the persisted envelope here (judgment composite, via scoreEnvelope). On the capture
* path the dims are pre-validated by normalizeItem, so scoreEnvelopecomposite cannot throw;
* called DIRECTLY with bad dims it throws by contract (defense-in-depth SC2).
*/ */
export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
return { return {
@ -135,6 +189,7 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
topics: [...item.topics], topics: [...item.topics],
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}), ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
...(item.summary !== undefined ? { summary: item.summary } : {}), ...(item.summary !== undefined ? { summary: item.summary } : {}),
...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}),
}; };
} }

View file

@ -96,6 +96,38 @@ export function band(composite: number): Band {
return toBand(BANDS[BANDS.length - 1]); return toBand(BANDS[BANDS.length - 1]);
} }
/**
* The persist-ready relevance envelope (RE-R3a): the agent's judgment (mode + the five
* dimension scores) plus the deterministically-derived composite + priority band. Lives
* in score.ts (the score domain owns it); types.ts imports it (one-way score.ts imports
* nothing internal, so no cycle).
*/
export interface TrendScore {
mode: ScoreMode;
dimensions: DimensionScores;
composite: number;
priority: Priority;
}
/**
* The mode's five dimension keys, in SSOT weight-literal order. `normalizeItem` consumes
* this as a membership set; score.test pins the order so a silent SSOT reorder fails.
*/
export function requiredDimensions(mode: ScoreMode): string[] {
return Object.keys(WEIGHTS[mode]);
}
/**
* Compose the persist-ready envelope from the agent's judgment: the composite is
* `composite(dimensions, mode)` and the priority is `band(composite).priority` the
* existing pure functions stay the single owners (no new arithmetic). Throws via
* `composite` on an out-of-range dimension (its contract).
*/
export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore {
const c = composite(dimensions, mode);
return { mode, dimensions, composite: c, priority: band(c).priority };
}
export interface TriageOptions { export interface TriageOptions {
mode: ScoreMode; mode: ScoreMode;
threshold: number; threshold: number;

View file

@ -19,6 +19,7 @@ import { createHash } from "node:crypto";
import { SCHEMA_VERSION } from "./types.js"; import { SCHEMA_VERSION } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit } from "./types.js"; import type { TrendStore, TrendRecord, TrendQueryHit } from "./types.js";
import type { TrendScore } from "./score.js";
export { SCHEMA_VERSION } from "./types.js"; export { SCHEMA_VERSION } from "./types.js";
@ -32,6 +33,8 @@ export interface TrendInput {
publishedAt?: string; publishedAt?: string;
topics: string[]; topics: string[];
summary?: string; summary?: string;
/** The persisted relevance envelope (RE-R3a), if the caller computed one. First-sight, never updated on re-capture. */
score?: TrendScore;
} }
export interface AddResult { export interface AddResult {
@ -76,10 +79,11 @@ export function emptyStore(): TrendStore {
export function loadStore(path: string): TrendStore { export function loadStore(path: string): TrendStore {
if (!existsSync(path)) return emptyStore(); if (!existsSync(path)) return emptyStore();
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>; const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>;
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2 is // Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3 are
// purely additive-optional (an old record is already a valid v2 record that simply // all purely additive-optional (an old record is already a valid v3 record that simply
// lacks the optional publishedAt), so the migration is the version stamp alone — // lacks the optional publishedAt [v2] / score [v3]), so the migration is the version
// records pass through untouched (lossless + idempotent for any well-formed store). // stamp alone — records pass through untouched (lossless + idempotent for any
// well-formed store; a new optional field survives JSON.stringify on resave).
// A string / NaN / absent version coerces to the current version (never crashes); the // 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. // non-array `trends` coercion below is unchanged and out of the losslessness claim.
const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION; const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION;
@ -134,6 +138,7 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}), ...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}),
topics: [...input.topics], topics: [...input.topics],
...(input.summary !== undefined ? { summary: input.summary } : {}), ...(input.summary !== undefined ? { summary: input.summary } : {}),
...(input.score !== undefined ? { score: input.score } : {}),
}; };
store.trends.push(trend); store.trends.push(trend);
return { store, added: true, merged: false }; return { store, added: true, merged: false };

View file

@ -19,10 +19,13 @@
* a typed store in the per-user data dir (`${LINKEDIN_STUDIO_DATA}`), so the * a typed store in the per-user data dir (`${LINKEDIN_STUDIO_DATA}`), so the
* trend history survives plugin upgrades/reinstalls via the M0 data-path seam. * trend history survives plugin upgrades/reinstalls via the M0 data-path seam.
* The minimal core here (title, url, source, capturedAt, topics, optional * The minimal core here (title, url, source, capturedAt, topics, optional
* summary) can gain fields (relevance score, first-mover timing, status) in a * summary) can gain fields (first-mover timing, status) in a later slice without
* later slice without breaking the shape. * breaking the shape the relevance `score` field (RE-R3a) is the first such
* realized addition.
*/ */
import type { TrendScore } from "./score.js";
export interface TrendRecord { export interface TrendRecord {
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */ /** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
id: string; id: string;
@ -45,6 +48,14 @@ export interface TrendRecord {
topics: string[]; topics: string[];
/** Optional short summary of the trend, stored VERBATIM. */ /** Optional short summary of the trend, stored VERBATIM. */
summary?: string; summary?: string;
/**
* The persisted relevance assessment (RE-R3a): the agent's judgment (mode + the
* five 110 dimension scores) plus the deterministically-derived composite + band,
* 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).
*/
score?: TrendScore;
} }
export interface TrendStore { export interface TrendStore {
@ -59,4 +70,4 @@ export interface TrendQueryHit {
topicOverlap: number; topicOverlap: number;
} }
export const SCHEMA_VERSION = 2; export const SCHEMA_VERSION = 3;

View file

@ -13,8 +13,24 @@ import type { TrendRecord, TrendStore } from "../src/types.js";
const TODAY = "2026-06-24"; const TODAY = "2026-06-24";
type TestScore = {
mode: "kortform" | "long-form";
dimensions: Record<string, number>;
composite: number;
priority: "Immediate" | "High" | "Medium" | "Low" | "Skip";
};
function mkTrend( function mkTrend(
p: { title: string; url: string; topics: string[]; capturedAt: string; publishedAt?: string; source?: string; summary?: string }, p: {
title: string;
url: string;
topics: string[];
capturedAt: string;
publishedAt?: string;
source?: string;
summary?: string;
score?: TestScore;
},
): TrendRecord { ): TrendRecord {
return { return {
id: p.title + "|" + p.url, id: p.title + "|" + p.url,
@ -25,8 +41,14 @@ function mkTrend(
...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}), ...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
topics: p.topics, topics: p.topics,
...(p.summary !== undefined ? { summary: p.summary } : {}), ...(p.summary !== undefined ? { summary: p.summary } : {}),
...(p.score !== undefined ? { score: p.score } : {}),
}; };
} }
/** A composite-bearing score for the rank tests (mode/priority kept consistent for render asserts). */
function mkScore(composite: number, priority: TestScore["priority"], mode: TestScore["mode"] = "kortform"): TestScore {
return { mode, dimensions: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }, composite, priority };
}
function mkStore(trends: TrendRecord[]): TrendStore { function mkStore(trends: TrendRecord[]): TrendStore {
return { schemaVersion: 2, trends }; return { schemaVersion: 2, trends };
} }
@ -159,6 +181,161 @@ describe("renderBrief + briefSummary (SC3)", () => {
}); });
}); });
describe("rankForBrief — composite primary within bucket (RE-R3a / SC5)", () => {
const pillars = ["a", "b"];
test("higher composite sorts first at the same overlap + freshness", () => {
const store = mkStore([
mkTrend({ title: "Low", url: "https://e/low", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
mkTrend({ title: "High", url: "https://e/high", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["High", "Low"], "composite 9 before composite 6");
});
test("an unscored record sorts after every scored record in its bucket (sentinel -1)", () => {
const store = mkStore([
mkTrend({ title: "Unscored", url: "https://e/u", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
mkTrend({ title: "Scored low", url: "https://e/s", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(2.0, "Low") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Scored low", "Unscored"], "any scored beats unscored");
});
test("both-unscored same-title/diff-url pair falls back to url asc (total order intact)", () => {
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, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
});
test("composite overrides effectiveDate within the bucket (composite is the leading key)", () => {
const store = mkStore([
mkTrend({ title: "Fresher lower", url: "https://e/fl", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(5.0, "Medium") }),
mkTrend({ title: "Older higher", url: "https://e/oh", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Older higher", "Fresher lower"]);
});
test("deterministic: identical scored input -> identical ranking order", () => {
const trends = [
mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
];
const r1 = rankForBrief(mkStore(trends), pillars, TODAY);
const r2 = rankForBrief(mkStore(trends), pillars, TODAY);
assert.deepEqual(r1.topMatches.map((e) => e.trend.title), r2.topMatches.map((e) => e.trend.title));
});
});
describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
const pillars = ["AI", "gov"];
test("scored top-entry meta line is the full pinned shape (· <priority> (<mode>) between age and Pillarer)", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Immediate (kortform) · Pillarer: AI, gov"),
"scored top-entry meta line must carry · <priority> (<mode>) between (<age>d) and · Pillarer",
);
});
test("unscored top-entry meta line is UNCHANGED (no token)", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Pillarer: AI, gov"),
"unscored top-entry meta line must be unchanged",
);
});
test("scored bullet (single match) is the full pinned shape (· <priority> (<mode>) before · 🔗)", () => {
const store = mkStore([
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · High (kortform) · 🔗 https://e/b"),
"scored bullet must carry · <priority> (<mode>) before · 🔗",
);
});
test("unscored bullet (single match) is UNCHANGED (no token)", () => {
const store = mkStore([
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · 🔗 https://e/b"),
"unscored bullet must be unchanged",
);
});
test("briefSummary names the band (no mode) on a scored top", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const s = briefSummary(rankForBrief(store, pillars, TODAY));
assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 2d)."), `summary should carry the band: ${s}`);
assert.ok(!s.includes("kortform"), "summary must not carry the mode");
});
test("briefSummary omits the band token on an unscored top", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
]);
const s = briefSummary(rankForBrief(store, pillars, TODAY));
assert.ok(s.includes("Topp: «Alpha» (AI · 2d)."), `unscored summary should omit the band: ${s}`);
});
test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => {
const store = mkStore([
mkTrend({ title: "«Quoted» take", url: "https://e/q", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const s = briefSummary(rankForBrief(store, pillars, TODAY));
assert.ok(!s.includes('"'), "summary must not contain a double-quote");
assert.ok(!s.includes("\n"), "summary must be a single line");
assert.ok(s.includes("Immediate"), "summary still carries the band");
});
test("single-pillar unscored top -> summary renders with no · <priority> token, one line", () => {
const store = mkStore([
mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
]);
const s = briefSummary(rankForBrief(store, ["AI"], TODAY));
assert.ok(!s.includes("· ·"), "no empty priority slot");
assert.ok(s.includes("Topp: «Solo» (AI · 2d)."), `single-pillar unscored summary: ${s}`);
assert.ok(!s.includes("\n"));
});
test("ranking: descriptor equals the exact pinned string", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 }));
assert.ok(
md.includes("ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays 7"),
"the ranking descriptor must match the pinned RE-R3a string verbatim",
);
});
test("deterministic: identical scored input -> identical bytes", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
});
});
describe("defaultBriefDir", () => { describe("defaultBriefDir", () => {
test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => { test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
const prev = process.env.LINKEDIN_STUDIO_DATA; const prev = process.env.LINKEDIN_STUDIO_DATA;

View file

@ -6,6 +6,8 @@ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "no
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { SCHEMA_VERSION } from "../src/types.js";
// Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the // Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the
// `tsx` loader resolve regardless of the runner's cwd. // `tsx` loader resolve regardless of the runner's cwd.
const trendsDir = fileURLToPath(new URL("..", import.meta.url)); const trendsDir = fileURLToPath(new URL("..", import.meta.url));
@ -97,7 +99,7 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
"tally must sum to the input size", "tally must sum to the input size",
); );
const persisted = JSON.parse(readFileSync(store, "utf8")); const persisted = JSON.parse(readFileSync(store, "utf8"));
assert.equal(persisted.schemaVersion, 2); assert.equal(persisted.schemaVersion, SCHEMA_VERSION);
assert.equal(persisted.trends.length, 1); assert.equal(persisted.trends.length, 1);
assert.equal(persisted.trends[0].publishedAt, "2026-06-20"); assert.equal(persisted.trends[0].publishedAt, "2026-06-20");
assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/); assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/);
@ -157,6 +159,73 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
const { status } = run(["capture"], ""); const { status } = run(["capture"], "");
assert.equal(status, 2); assert.equal(status, 2);
}); });
// ── RE-R3a: capture persists the computed relevance score (SC7) ──
test("a valid per-item score -> record carries the computed composite/priority (read back via list --json)", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{
source: "tavily",
title: "Scored capture",
url: "https://example.com/sc",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 } },
},
]);
const cap = run(["capture", "--store", store, "--json"], batch);
assert.equal(cap.status, 0);
const summary = JSON.parse(cap.stdout);
assert.equal(summary.added, 1);
assert.equal(summary.errors.length, 0);
const ls = run(["list", "--store", store, "--json"], "");
assert.equal(ls.status, 0);
const rows = JSON.parse(ls.stdout);
assert.equal(rows.length, 1);
// 9*.30 + 8*.25 + 9*.20 + 7*.15 + 6*.10 = 8.15 -> round1 8.1 (8.15*10 = 81.4999… in IEEE-754) -> Immediate
assert.equal(rows[0].score.composite, 8.1);
assert.equal(rows[0].score.priority, "Immediate");
assert.equal(rows[0].score.mode, "kortform");
assert.deepEqual(rows[0].score.dimensions, { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 });
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("a batch with one bad score (timing:99) -> that item in errors[], the valid one added, exit 0", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{
source: "tavily",
title: "Good scored",
url: "https://example.com/good",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 } },
},
{
source: "tavily",
title: "Bad scored",
url: "https://example.com/bad",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 } },
},
]);
const { status, stdout } = run(["capture", "--store", store, "--json"], batch);
assert.equal(status, 0, "a bad score must not fail the run");
const summary = JSON.parse(stdout);
assert.equal(summary.added, 1, "the valid scored item is added");
assert.equal(summary.errors.length, 1, "the bad-score item lands in errors[]");
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
2,
"tally must sum to the input size",
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
}); });
}); });

View file

@ -2,7 +2,9 @@ import { describe, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js"; import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js";
import type { TrendItem } from "../src/item.js";
import { normalizeField } from "../src/store.js"; import { normalizeField } from "../src/store.js";
import { scoreEnvelope } from "../src/score.js";
describe("trends item normalizer (RE-R1 / B1)", () => { describe("trends item normalizer (RE-R1 / B1)", () => {
describe("normalizeItem — well-formed", () => { describe("normalizeItem — well-formed", () => {
@ -224,4 +226,115 @@ describe("trends item normalizer (RE-R1 / B1)", () => {
assert.equal(input.capturedAt, "2026-06-24"); assert.equal(input.capturedAt, "2026-06-24");
}); });
}); });
describe("normalizeItem — score validation (RE-R3a / SC2)", () => {
const base = {
source: "tavily",
title: "Scored item",
url: "https://example.com/s",
topics: ["ai"],
};
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
test("a valid kortform score -> carried with the validated dims", () => {
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: validDims } });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.score, { mode: "kortform", dimensions: validDims });
});
test("a valid long-form score -> carried with its five dims", () => {
const longDims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
const res = normalizeItem({ ...base, score: { mode: "long-form", dimensions: longDims } });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.score, { mode: "long-form", dimensions: longDims });
});
test("absent score -> key omitted", () => {
const res = normalizeItem(base);
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal("score" in res.item, false);
});
test("a bad mode -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: { mode: "bogus", dimensions: validDims } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")), res.errors.join("; "));
});
test("a missing dimension -> structured error (no throw)", () => {
const { authority, ...missing } = validDims;
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: missing } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("a dimension out of [1,10] (0 or 11) -> structured error (no throw)", () => {
const lo = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 0 } } });
assert.equal(lo.ok, false);
const hi = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 11 } } });
assert.equal(hi.ok, false);
});
test("a non-object score -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: "high" });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("an array dimensions -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: [9, 8, 9, 7, 6] } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("an array score -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: [] });
assert.equal(res.ok, false);
});
});
describe("itemToInput — score bridge + the throw contract (RE-R3a / SC2)", () => {
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
test("a scored item -> input.score equals scoreEnvelope(mode, dimensions)", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: validDims },
};
const input = itemToInput(item, "2026-06-24");
assert.deepEqual(input.score, scoreEnvelope("kortform", validDims));
});
test("an unscored item -> no score key", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
};
const input = itemToInput(item, "2026-06-24") as Record<string, unknown>;
assert.equal("score" in input, false);
});
test("called directly with an out-of-range dim -> throws by contract (defense-in-depth)", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: { ...validDims, timing: 99 } },
};
assert.throws(() => itemToInput(item, "2026-06-24"));
});
});
}); });

View file

@ -1,7 +1,15 @@
import { describe, test } from "node:test"; import { describe, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { KORTFORM_WEIGHTS, LONG_FORM_WEIGHTS, composite, band, triage } from "../src/score.js"; import {
KORTFORM_WEIGHTS,
LONG_FORM_WEIGHTS,
composite,
band,
triage,
requiredDimensions,
scoreEnvelope,
} from "../src/score.js";
const r1 = (x: number) => Math.round(x * 10) / 10; const r1 = (x: number) => Math.round(x * 10) / 10;
const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0); const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0);
@ -142,4 +150,42 @@ describe("trends scorer (RE-R1 / B2)", () => {
assert.deepEqual(dropped, []); assert.deepEqual(dropped, []);
}); });
}); });
describe("requiredDimensions (RE-R3a / SC1)", () => {
test("kortform -> the five keys in SSOT weight order", () => {
assert.deepEqual(requiredDimensions("kortform"), ["pillar", "audience", "timing", "angle", "authority"]);
});
test("long-form -> the five keys in SSOT weight order", () => {
assert.deepEqual(requiredDimensions("long-form"), ["pillar", "depth", "angle", "authority", "currency"]);
});
test("order is pinned to the SSOT weight literals (a silent reorder fails)", () => {
assert.deepEqual(requiredDimensions("kortform"), Object.keys(KORTFORM_WEIGHTS));
assert.deepEqual(requiredDimensions("long-form"), Object.keys(LONG_FORM_WEIGHTS));
});
});
describe("scoreEnvelope (RE-R3a / SC1)", () => {
test("composes composite()+band() — composite/priority equal the existing functions (one owner)", () => {
const dims = { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 };
const env = scoreEnvelope("kortform", dims);
assert.equal(env.mode, "kortform");
assert.deepEqual(env.dimensions, dims);
assert.equal(env.composite, composite(dims, "kortform"));
assert.equal(env.priority, band(composite(dims, "kortform")).priority);
});
test("long-form envelope composes the long-form composite/band", () => {
const dims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
const env = scoreEnvelope("long-form", dims);
assert.equal(env.composite, composite(dims, "long-form"));
assert.equal(env.priority, band(composite(dims, "long-form")).priority);
});
test("a bad dimension makes scoreEnvelope throw (via composite — defense-in-depth contract)", () => {
const dims = { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 };
assert.throws(() => scoreEnvelope("kortform", dims));
});
});
}); });

View file

@ -275,6 +275,67 @@ describe("trends store", () => {
assert.equal(res2.merged, true, "topic union still reported"); assert.equal(res2.merged, true, "topic union still reported");
assert.equal("publishedAt" in res2.store.trends[0], false, "no back-fill of first-sight provenance"); assert.equal("publishedAt" in res2.store.trends[0], false, "no back-fill of first-sight provenance");
}); });
// ── RE-R3a: relevance score first-sight persistence (SC3) ──
const kortScore = {
mode: "kortform" as const,
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate" as const,
};
test("RED: persists score on a new record when present (first-sight)", () => {
const res = addTrend(emptyStore(), {
title: "Scored trend",
url: "https://example.com/sc",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
score: kortScore,
});
assert.deepEqual(res.store.trends[0].score, kortScore);
});
test("regression guard: omits score when absent (no undefined-valued key)", () => {
const res = addTrend(emptyStore(), {
title: "Unscored trend",
url: "https://example.com/us",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
});
assert.equal("score" in res.store.trends[0], false);
});
test("RED: re-capture keeps the first sighting's score (no overwrite), unions topics", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Same scored trend",
url: "https://example.com/ssc",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["a"],
score: kortScore,
}).store;
const lowerScore = {
mode: "kortform" as const,
dimensions: { pillar: 6, audience: 5, timing: 6, angle: 5, authority: 5 },
composite: 5.6,
priority: "Medium" as const,
};
const res2 = addTrend(store, {
title: "Same scored trend",
url: "https://example.com/ssc",
source: "gemini",
capturedAt: "2026-06-20",
topics: ["a", "b"],
score: lowerScore,
});
assert.equal(res2.added, false);
assert.equal(res2.merged, true);
assert.deepEqual(res2.store.trends[0].score, kortScore, "first-sight score kept (D3)");
assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]);
});
}); });
describe("queryByTopic", () => { describe("queryByTopic", () => {
@ -429,7 +490,7 @@ describe("trends store", () => {
}); });
withFixture(v1, (path) => { withFixture(v1, (path) => {
const s = loadStore(path); const s = loadStore(path);
assert.equal(s.schemaVersion, 2, "v1 store must migrate to v2"); assert.equal(s.schemaVersion, SCHEMA_VERSION, "v1 store must migrate to the current version");
assert.equal(s.trends.length, 1); assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old trend"); assert.equal(s.trends[0].title, "Old trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01"); assert.equal(s.trends[0].capturedAt, "2026-05-01");
@ -438,22 +499,22 @@ describe("trends store", () => {
}); });
}); });
test("RED: round-trip loadStore→saveStore writes schemaVersion:2 to disk", () => { test("RED: round-trip loadStore→saveStore writes the current schemaVersion to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 1, trends: [] }), (path) => { withFixture(JSON.stringify({ schemaVersion: 1, trends: [] }), (path) => {
saveStore(path, loadStore(path)); saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8")); const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, 2); assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
}); });
}); });
test("RED: a non-numeric schemaVersion is coerced to v2 (old code passes the string through)", () => { test("RED: a non-numeric schemaVersion is coerced to the current version (old code passes the string through)", () => {
withFixture(JSON.stringify({ schemaVersion: "weird", trends: [] }), (path) => { withFixture(JSON.stringify({ schemaVersion: "weird", trends: [] }), (path) => {
assert.equal(loadStore(path).schemaVersion, 2); assert.equal(loadStore(path).schemaVersion, SCHEMA_VERSION);
}); });
}); });
// ── GREEN-only regression guards: old code already returns the current version ── // ── GREEN-only regression guards: old code already returns the current version ──
test("regression guard: a v2 store loads as v2, idempotent (records + publishedAt intact)", () => { test("regression guard: a v2 store loads idempotent (records + publishedAt intact)", () => {
const v2 = JSON.stringify({ const v2 = JSON.stringify({
schemaVersion: 2, schemaVersion: 2,
trends: [ trends: [
@ -470,7 +531,7 @@ describe("trends store", () => {
}); });
withFixture(v2, (path) => { withFixture(v2, (path) => {
const s = loadStore(path); const s = loadStore(path);
assert.equal(s.schemaVersion, 2); assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.equal(s.trends[0].publishedAt, "2026-05-30"); assert.equal(s.trends[0].publishedAt, "2026-05-30");
}); });
}); });
@ -493,4 +554,114 @@ describe("trends store", () => {
}); });
}); });
}); });
describe("schema migration (RE-R3a / score v2→v3)", () => {
const withFixture = (contents: string, fn: (path: string) => void) => {
const dir = tmp();
const path = join(dir, "trends.json");
try {
writeFileSync(path, contents, "utf8");
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
// ── genuinely RED: pre-bump SCHEMA_VERSION=2 → loadStore(v2).schemaVersion===2 ≠ 3 ──
test("RED: a v2 store (no score) loads stamped as v3, records intact, no score invented", () => {
const v2 = JSON.stringify({
schemaVersion: 2,
trends: [
{
id: "abc123",
title: "Old dated trend",
url: "https://example.com/o",
source: "tavily",
capturedAt: "2026-05-01",
topics: ["ai"],
publishedAt: "2026-04-30",
},
],
});
withFixture(v2, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3");
assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old dated trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01");
assert.equal(s.trends[0].publishedAt, "2026-04-30");
assert.deepEqual(s.trends[0].topics, ["ai"]);
assert.equal("score" in s.trends[0], false, "migration must not invent a score");
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:3 to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 2, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, 3);
});
});
test("RED: a v3 store with score on records loads idempotent", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{
id: "x",
title: "Scored",
url: "https://example.com/s",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
score: {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
},
},
],
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 3);
assert.deepEqual(s.trends[0].score, {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
});
});
});
test("RED: a v3 store's score survives load → save → load (field preservation)", () => {
const score = {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
};
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{
id: "x",
title: "Survivor",
url: "https://example.com/sv",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
score,
},
],
});
withFixture(v3, (path) => {
const first = loadStore(path);
saveStore(path, first);
const second = loadStore(path);
assert.deepEqual(second.trends[0].score, score, "score must survive a load+resave (no field stripping)");
});
});
});
}); });