feat(linkedin-studio): RE-R3b — trend lifecycle (re-score on re-capture · status · seen-log) [skip-docs]

The lifecycle layer over the trend store: what happens to a trend AFTER first capture.
- re-score on re-capture (last-wins; addTrend duplicate branch, score the one mutable
  field; provenance + lifecycle untouched; no false-merge via JSON compare). Reverses
  R3a's first-sight D3 — that R3a test reconciled to the new behaviour.
- status new/acted/skipped (effectiveStatus/setStatus + act/skip/reset CLI verbs);
  rankForBrief EXCLUDES handled trends (a work queue, not an archive).
- seen-log surfacedCount/lastSurfacedAt (markSurfaced, per-day idempotent); the brief
  CLI records surfacing on the store AFTER the pure render, unless --no-mark.
- render: entry id in backticks (copy-paste for act/skip) + · sett Nx prior-day hint.
- schema v3→v4 (additive lossless); the R3a migration block reconciled to the bump,
  the new R3b block committed against SCHEMA_VERSION (breaks the reconcile cycle).

score.ts + item.ts untouched (re-score reuses the R3a capture path). RED-first (two
phase: 16 logic-RED + 4 stub-RED). Gate: Section 16k (6 emitters), TRENDS_TESTS_FLOOR
146→171, ASSERT_BASELINE_FLOOR 99→105. trends 171/171, gate 120/0/0, hook suite 139/139.

Plan: docs/research-engine/{brief,plan}-re-r3b.md (light-Voyage hardened @ c40b937).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 01:08:43 +02:00
commit b185db9a12
10 changed files with 661 additions and 44 deletions

View file

@ -50,7 +50,11 @@
# 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
# Section 16j; the trends-lifecycle wiring guard (RE-R3b: scripts/trends/src/types.ts declares
# 'export type TrendStatus' AND carries 'surfacedCount', scripts/trends/src/store.ts owns
# 'export function markSurfaced', scripts/trends/src/brief.ts excludes handled via 'effectiveStatus',
# AND scripts/trends/src/cli.ts exposes 'command === "act"', with a non-vacuity self-test) in
# Section 16k; the assertion-count anti-erosion floor (SC6) in Section 18. All
# are live below (Sections 818).
#
# Usage: bash scripts/test-runner.sh
@ -702,7 +706,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=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)
TRENDS_TESTS_FLOOR=171 # 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) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log)
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
@ -1234,6 +1238,72 @@ fi
echo ""
# --- Section 16k: Trends Lifecycle Wiring (research-engine RE-R3b) ---
echo "--- Trends Lifecycle Wiring ---"
# RE-R3b adds the trend lifecycle: re-score on re-capture, a status (new/acted/skipped) the brief
# EXCLUDES, and a seen-log (surfacedCount/lastSurfacedAt) the brief records. Five literals must hold,
# grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
# (1) types.ts declares the status type, by 'export type TrendStatus';
# (2) types.ts carries the seen-log field, by 'surfacedCount';
# (3) store.ts owns the seen-log writer, by 'export function markSurfaced';
# (4) brief.ts excludes handled trends, by 'effectiveStatus' (the filter is wired, not doc'd);
# (5) cli.ts exposes the lifecycle verb, by 'command === "act"'.
# Non-vacuity self-test mirrors Section 16j: the brief-filter predicate must accept a probe carrying
# the effectiveStatus pointer and reject one without it. Placed after Section 16j / before Section 18
# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward
# ASSERT_BASELINE_FLOOR.
LIFECYCLE_STATUS_LIT='export type TrendStatus'
LIFECYCLE_SEEN_LIT='surfacedCount'
LIFECYCLE_MARK_LIT='export function markSurfaced'
LIFECYCLE_FILTER_LIT='effectiveStatus'
LIFECYCLE_VERB_LIT='command === "act"'
I16K_SELFTEST_OK=1
if ! echo 'rankForBrief drops a trend when effectiveStatus(trend) !== "new"' | grep -qF "$LIFECYCLE_FILTER_LIT"; then
I16K_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired status-filter probe was not detected"
fi
if echo 'the brief shows every record regardless of status' | grep -qF "$LIFECYCLE_FILTER_LIT"; then
I16K_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the status-filter pointer"
fi
if [ "$I16K_SELFTEST_OK" -eq 1 ]; then
pass "trends-lifecycle self-test: status-filter predicate detects wiring, rejects the unfiltered form"
else
fail "trends-lifecycle self-test failed — the lifecycle-wiring lint is vacuous or over-eager"
fi
if grep -qF "$LIFECYCLE_STATUS_LIT" scripts/trends/src/types.ts; then
pass "types.ts declares the lifecycle status type ('$LIFECYCLE_STATUS_LIT')"
else
fail "types.ts has no TrendStatus — add '$LIFECYCLE_STATUS_LIT' (RE-R3b lifecycle)"
fi
if grep -qF "$LIFECYCLE_SEEN_LIT" scripts/trends/src/types.ts; then
pass "types.ts carries the seen-log field ('$LIFECYCLE_SEEN_LIT')"
else
fail "types.ts does not carry the seen-log — add '$LIFECYCLE_SEEN_LIT' to TrendRecord (RE-R3b schema v4)"
fi
if grep -qF "$LIFECYCLE_MARK_LIT" scripts/trends/src/store.ts; then
pass "store.ts owns the seen-log writer ('$LIFECYCLE_MARK_LIT')"
else
fail "store.ts has no markSurfaced — add '$LIFECYCLE_MARK_LIT' (RE-R3b seen-log)"
fi
if grep -qF "$LIFECYCLE_FILTER_LIT" scripts/trends/src/brief.ts; then
pass "brief.ts excludes handled trends ('$LIFECYCLE_FILTER_LIT')"
else
fail "brief.ts does not exclude handled trends — wire '$LIFECYCLE_FILTER_LIT' into rankForBrief (RE-R3b)"
fi
if grep -qF "$LIFECYCLE_VERB_LIT" scripts/trends/src/cli.ts; then
pass "cli.ts exposes the lifecycle verb ('$LIFECYCLE_VERB_LIT')"
else
fail "cli.ts has no act/skip/reset — add '$LIFECYCLE_VERB_LIT' (RE-R3b lifecycle verbs)"
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
@ -1256,7 +1326,7 @@ echo ""
# 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=99
ASSERT_BASELINE_FLOOR=105
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

@ -39,7 +39,10 @@ interface TrendRecord {
publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled
topics: string[]; // query tags; unioned across re-captures
summary?: string; // optional, verbatim
score?: TrendScore; // optional persisted relevance (RE-R3a): { mode, dimensions, composite, priority } — first-sight, never re-scored
score?: TrendScore; // persisted relevance (RE-R3a): { mode, dimensions, composite, priority } — REFRESHED on re-capture (RE-R3b, last-wins)
status?: TrendStatus; // lifecycle (RE-R3b): "new" | "acted" | "skipped"; absent ⇒ "new"; the brief excludes non-new
surfacedCount?: number; // seen-log (RE-R3b): distinct days surfaced in a brief; absent ⇒ 0; per-day idempotent
lastSurfacedAt?: string; // seen-log (RE-R3b): ISO date of the most recent surfacing
}
```
@ -47,9 +50,15 @@ interface TrendRecord {
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.
**refreshed on re-capture** (RE-R3b, last-wins — the timing dimension decays, so the newer
judgment supersedes the stored one; `score` is the one mutable field, provenance stays
first-sight); the score-free `add` manual path omits it. The morning brief ranks each bucket
on `composite` first (schema v4).
The **lifecycle** fields (RE-R3b) are the trend's life after first capture: `status` is set by
the `act`/`skip`/`reset` verbs (a freshly-captured trend is implicitly `new`), and the seen-log
`surfacedCount`/`lastSurfacedAt` is recorded by `brief` (per-day idempotent) so the loop can avoid
re-surfacing handled work.
## CLI
@ -80,10 +89,17 @@ 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).
# Dated morning brief — rank the store by composite then pillar-overlap then recency, write a
# dated Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config).
# The brief EXCLUDES acted/skipped trends and RECORDS surfacing on the store (per-day idempotent)
# unless --no-mark. 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]
[--fresh-days 7] [--out <dir>] [--no-mark] [--store <path>] [--json]
# Lifecycle — mark a trend handled so the brief stops re-surfacing it (id shown in the brief / list --json):
node --import tsx src/cli.ts act --id <id> # wrote about it
node --import tsx src/cli.ts skip --id <id> # decided to pass on it
node --import tsx src/cli.ts reset --id <id> # return it to the queue
```
Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend
@ -103,8 +119,13 @@ ${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
verbatim (zero-tsx — it reads the Markdown, never the TS CLI). As of RE-R3a the brief ranks
each bucket on the persisted relevance **composite first** (then pillar-overlap, then recency);
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.
a scored entry shows `· <priority> (<mode>)` and the summary names the top entry's band.
As of **RE-R3b** the brief is a **work queue**: it **excludes** `acted`/`skipped` trends, shows
each entry's `id` in backticks (copy-paste-ready for `act`/`skip --id`), flags a re-surfaced item
with `· sett Nx` (prior-day count, ≥2), and — unless `--no-mark`**records surfacing** on the
store (`surfacedCount`/`lastSurfacedAt`, per-day idempotent) after the pure render. An autonomous
nightly trigger and a brief-history diff remain later slices.
## Tests

View file

@ -16,7 +16,7 @@
import { join, dirname } from "node:path";
import { defaultStorePath } from "./store.js";
import { defaultStorePath, effectiveStatus } from "./store.js";
import type { TrendStore, TrendRecord } from "./types.js";
/** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */
@ -80,6 +80,8 @@ export function rankForBrief(
const entries: BriefEntry[] = [];
for (const trend of store.trends) {
// RE-R3b (A3): acted/skipped are handled — drop from the work queue (the brief is a queue, not an archive).
if (effectiveStatus(trend) !== "new") continue;
const have = new Set(trend.topics.map((t) => t.toLowerCase()));
const matchedPillars: string[] = [];
for (let i = 0; i < pillars.length; i++) {
@ -144,10 +146,20 @@ function scoreToken(e: BriefEntry): string {
return s ? ` · ${s.priority} (${s.mode})` : "";
}
/**
* ` · sett Nx` when surfacedCount>=2, else "" the seen-log saturation HINT (RE-R3b). The count
* is PRIOR-DAY: the brief renders before the CLI records today's surfacing, so it reads "shown on
* N prior distinct days". Not the saturation SCORING of slice (b) nor the day-over-day diff of (d).
*/
function surfacedToken(e: BriefEntry): string {
const c = e.trend.surfacedCount;
return c && c >= 2 ? ` · sett ${c}x` : "";
}
function renderTopEntry(e: BriefEntry, n: number): string[] {
const lines = [
`### ${n}. ${e.trend.title}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · Pillarer: ${e.matchedPillars.join(", ")}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
];
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
lines.push(`- 🔗 ${e.trend.url}`);
@ -156,7 +168,7 @@ function renderTopEntry(e: BriefEntry, n: number): string[] {
}
function renderBulletEntry(e: BriefEntry): string {
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · 🔗 ${e.trend.url}`;
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
}
/**
@ -172,7 +184,7 @@ export function renderBrief(ranking: BriefRanking): string {
lines.push(`date: ${ranking.today}`);
lines.push(`summary: ${briefSummary(ranking)}`);
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
lines.push("---");
lines.push("");
@ -205,6 +217,15 @@ export function renderBrief(ranking: BriefRanking): string {
return lines.join("\n") + "\n";
}
/**
* The ids of the entries renderBrief actually shows: topMatches singleMatches the first 5
* olderMatched (mirroring the render's .slice(0,5)). The brief CLI feeds these to markSurfaced so
* the seen-log records exactly what the operator saw. Pure (RE-R3b).
*/
export function surfacedIds(ranking: BriefRanking): string[] {
return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id);
}
/**
* Default brief directory under the per-user data dir, DERIVED from
* defaultStorePath() so root resolution lives in exactly one place:

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import {
renderBrief,
briefSummary,
defaultBriefDir,
surfacedIds,
BRIEF_SCHEMA_VERSION,
} from "../src/brief.js";
import type { TrendRecord, TrendStore } from "../src/types.js";
@ -30,6 +31,9 @@ function mkTrend(
source?: string;
summary?: string;
score?: TestScore;
status?: "new" | "acted" | "skipped";
surfacedCount?: number;
lastSurfacedAt?: string;
},
): TrendRecord {
return {
@ -42,6 +46,9 @@ function mkTrend(
topics: p.topics,
...(p.summary !== undefined ? { summary: p.summary } : {}),
...(p.score !== undefined ? { score: p.score } : {}),
...(p.status !== undefined ? { status: p.status } : {}),
...(p.surfacedCount !== undefined ? { surfacedCount: p.surfacedCount } : {}),
...(p.lastSurfacedAt !== undefined ? { lastSurfacedAt: p.lastSurfacedAt } : {}),
};
}
@ -348,3 +355,80 @@ describe("defaultBriefDir", () => {
}
});
});
describe("RE-R3b — exclude acted/skipped (A3)", () => {
const pillars = ["AI", "gov"];
const store = mkStore([
mkTrend({ title: "New top", url: "https://e/n", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
mkTrend({ title: "Acted top", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }),
mkTrend({ title: "Skipped single", url: "https://e/s", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }),
]);
const r = rankForBrief(store, pillars, TODAY);
test("RED: acted/skipped are dropped from every bucket", () => {
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["New top"]);
assert.deepEqual(r.singleMatches.map((e) => e.trend.title), []);
assert.deepEqual(r.olderMatched.map((e) => e.trend.title), []);
});
test("RED: totals.trends counts the full inventory (incl. handled); matched is post-filter", () => {
assert.equal(r.totals.trends, 3, "full store count");
assert.equal(r.totals.matched, 1, "only the new record is matched");
});
test("RED: a store whose only matches are handled → no fresh + the empty summary", () => {
const s = mkStore([
mkTrend({ title: "A", url: "https://e/aa", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }),
mkTrend({ title: "B", url: "https://e/bb", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }),
]);
const rr = rankForBrief(s, pillars, TODAY);
assert.equal(rr.totals.fresh, 0);
assert.match(briefSummary(rr), /Ingen ferske tema-signaler/);
});
});
describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => {
const pillars = ["AI", "gov"];
test("RED: a top entry carries the id in backticks (copy-paste-ready for act/skip)", () => {
const s = mkStore([mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" })]);
const md = renderBrief(rankForBrief(s, pillars, TODAY));
assert.ok(md.includes("· `Top|https://e/t`"), "top entry meta line must end with the id in backticks");
});
test("RED: a single-match bullet carries the id in backticks", () => {
const s = mkStore([mkTrend({ title: "Single", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" })]);
const md = renderBrief(rankForBrief(s, pillars, TODAY));
assert.ok(md.includes("· `Single|https://e/sg`"), "bullet must end with the id in backticks");
});
test("RED: · sett Nx appears only when surfacedCount >= 2 (prior-day count)", () => {
const s = mkStore([
mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 3 }),
mkTrend({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1 }),
]);
const md = renderBrief(rankForBrief(s, pillars, TODAY));
assert.ok(md.includes("· sett 3x"), "surfacedCount 3 → · sett 3x");
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker");
});
test("RED: the ranking: descriptor ends with '; excludes acted/skipped'", () => {
const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY));
assert.match(
md,
/\nranking: composite desc, then pillar-overlap desc, then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\n/,
);
});
});
describe("RE-R3b — surfacedIds (D7, Phase B)", () => {
test("RED: surfacedIds = topMatches singleMatches olderMatched.slice(0,5)", () => {
const olders = Array.from({ length: 7 }, (_, i) =>
mkTrend({ title: "O" + i, url: "https://e/o" + i, topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
);
const s = mkStore([
mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
mkTrend({ title: "Sg", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
...olders,
]);
const r = rankForBrief(s, ["AI", "gov"], TODAY);
const expected = [...r.topMatches, ...r.singleMatches, ...r.olderMatched.slice(0, 5)].map((e) => e.trend.id);
assert.deepEqual(surfacedIds(r), expected);
assert.equal(surfacedIds(r).length, 1 + 1 + 5, "older capped at 5");
});
});

View file

@ -319,3 +319,123 @@ describe("trends CLI — brief subcommand (RE-R2b / Step 3)", () => {
}
});
});
describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)", () => {
// One temp dir per fixture holds both the store file and the brief out dir, so the
// real per-user data dir (defaultBriefDir) is never touched.
const fixture = () => {
const dir = mkdtempSync(join(tmpdir(), "trends-r3b-"));
return { dir, store: join(dir, "trends.json"), out: join(dir, "briefs") };
};
const listJson = (store: string): Array<Record<string, any>> =>
JSON.parse(run(["list", "--store", store, "--json"], "").stdout);
const seedScored = (store: string, title: string, url: string, timing = 9): void => {
const batch = JSON.stringify([
{
source: "tavily",
title,
url,
topics: ["ai", "gov"],
publishedAt: "2026-06-23",
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing, angle: 7, authority: 6 } },
},
]);
run(["capture", "--store", store], batch);
};
test("RED: act --id → acted; skip → skipped; reset → new (read back via list --json)", () => {
const { dir, store } = fixture();
try {
seedScored(store, "Lifecycle", "https://e/lc");
const id = listJson(store)[0].id;
assert.equal(run(["act", "--id", id, "--store", store], "").status, 0);
assert.equal(listJson(store)[0].status, "acted");
run(["skip", "--id", id, "--store", store], "");
assert.equal(listJson(store)[0].status, "skipped");
run(["reset", "--id", id, "--store", store], "");
assert.equal(listJson(store)[0].status, "new");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: an unknown id → exit 2 + store unchanged; a missing --id → exit 2", () => {
const { dir, store } = fixture();
try {
seedScored(store, "X", "https://e/x");
const before = readFileSync(store, "utf8");
assert.equal(run(["act", "--id", "nope", "--store", store], "").status, 2);
assert.equal(readFileSync(store, "utf8"), before, "store untouched on a not-found id");
assert.equal(run(["skip", "--store", store], "").status, 2, "missing --id → exit 2");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: brief records surfacing (surfacedCount + lastSurfacedAt + marked); a second same-day run is idempotent", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Surf", "https://e/surf");
const o1 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o1.marked, 1, "first brief marks 1");
const rec = listJson(store)[0];
assert.equal(rec.surfacedCount, 1);
assert.match(rec.lastSurfacedAt, /^\d{4}-\d{2}-\d{2}$/);
const o2 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o2.marked, 0, "second same-day brief is idempotent");
assert.equal(listJson(store)[0].surfacedCount, 1, "count unchanged on the second same-day run");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: brief --no-mark writes no surfacedCount", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Dry", "https://e/dry");
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--no-mark", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o.marked, 0);
assert.equal("surfacedCount" in listJson(store)[0], false, "--no-mark must not write surfacedCount");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: the brief .md omits an acted record", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Handled", "https://e/handled");
const id = listJson(store)[0].id;
run(["act", "--id", id, "--store", store], "");
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
const md = readFileSync(o.path, "utf8");
assert.ok(!md.includes("Handled"), "an acted record must not appear in the brief");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: a re-capture with a changed score reports merged:1 + refreshes the composite", () => {
const { dir, store } = fixture();
try {
seedScored(store, "Re", "https://e/re", 9);
const c1 = listJson(store)[0].score.composite;
const batch = JSON.stringify([
{
source: "tavily",
title: "Re",
url: "https://e/re",
topics: ["ai", "gov"],
publishedAt: "2026-06-23",
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 2, angle: 7, authority: 6 } },
},
]);
const o = JSON.parse(run(["capture", "--store", store, "--json"], batch).stdout);
assert.equal(o.merged, 1, "a changed score on a duplicate → merged");
const c2 = listJson(store)[0].score.composite;
assert.ok(c2 < c1, "a lower timing → lower composite (re-score last-wins)");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -14,6 +14,9 @@ import {
queryByTopic,
history,
newestCaptureDate,
effectiveStatus,
setStatus,
markSurfaced,
} from "../src/store.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendStore } from "../src/types.js";
@ -307,7 +310,7 @@ describe("trends store", () => {
assert.equal("score" in res.store.trends[0], false);
});
test("RED: re-capture keeps the first sighting's score (no overwrite), unions topics", () => {
test("re-capture REFRESHES the score (last-wins, RE-R3b reverses R3a's D3), unions topics", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Same scored trend",
@ -333,7 +336,7 @@ describe("trends store", () => {
});
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].score, lowerScore, "score refreshed last-wins (RE-R3b reverses R3a's D3)");
assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]);
});
});
@ -567,8 +570,9 @@ describe("trends store", () => {
}
};
// ── 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", () => {
// RE-R3a v2→v3 block, reconciled to the v4 bump (RE-R3b): the stamped-version assertions track
// SCHEMA_VERSION (a v2 store now migrates to the current version), the v2 INPUT fixture stays literal.
test("a v2 store (no score) loads stamped as the current version, records intact, no score invented", () => {
const v2 = JSON.stringify({
schemaVersion: 2,
trends: [
@ -585,7 +589,7 @@ describe("trends store", () => {
});
withFixture(v2, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3");
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v2 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old dated trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01");
@ -595,15 +599,15 @@ describe("trends store", () => {
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:3 to disk", () => {
test("round-trip loadStore→saveStore writes the current schemaVersion 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);
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("RED: a v3 store with score on records loads idempotent", () => {
test("a v3 store with score migrates to the current version, score preserved", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
@ -625,7 +629,7 @@ describe("trends store", () => {
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 3);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.deepEqual(s.trends[0].score, {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
@ -664,4 +668,187 @@ describe("trends store", () => {
});
});
});
// ── RE-R3b: re-score on re-capture (last-wins; A2) ──
describe("addTrend — re-score on re-capture (RE-R3b)", () => {
const mkScore = (composite: number, priority: string, timing = 5) => ({
mode: "kortform",
dimensions: { pillar: 5, audience: 5, timing, angle: 5, authority: 5 },
composite,
priority,
});
const seed = (score?: unknown) => ({
title: "Re-scored trend",
url: "https://example.com/rs",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
...(score !== undefined ? { score } : {}),
});
test("RED: a duplicate with a DIFFERENT score replaces the stored score (last-wins), merged:true", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, {
...seed(mkScore(4.0, "Medium", 3)),
capturedAt: "2026-06-08",
topics: ["ai", "rag"],
});
assert.equal(res.added, false);
assert.equal(res.merged, true, "a changed score (or new topics) → merged:true");
assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score must be the fresh one");
assert.equal(res.store.trends[0].source, "tavily", "provenance source unchanged");
assert.equal(res.store.trends[0].capturedAt, "2026-06-01", "provenance capturedAt unchanged (first-sight)");
assert.deepEqual([...res.store.trends[0].topics].sort(), ["ai", "rag"], "topics still unioned");
});
test("RED: a re-capture with a BYTE-IDENTICAL score and no new topics → merged:false (no false-merge)", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, { ...seed(mkScore(8.1, "Immediate", 9)), capturedAt: "2026-06-09" });
assert.equal(res.added, false);
assert.equal(res.merged, false, "identical score + same topics → not a merge");
assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9));
});
test("RED: a duplicate with NO score leaves the stored score unchanged", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, { ...seed(), capturedAt: "2026-06-10" });
assert.equal(res.added, false);
assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9), "no input score → keep the stored one");
});
test("RED: re-scoring an ACTED trend updates the score but never resets status/surfacedCount", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
// Simulate a handled, surfaced record (status/surfacedCount are set by act/markSurfaced, not addTrend).
(store.trends[0] as Record<string, unknown>).status = "acted";
(store.trends[0] as Record<string, unknown>).surfacedCount = 3;
const res = addTrend(store, { ...seed(mkScore(4.0, "Medium", 3)), capturedAt: "2026-06-11" });
assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score refreshed");
assert.equal((res.store.trends[0] as Record<string, unknown>).status, "acted", "status must NOT reset on re-score");
assert.equal((res.store.trends[0] as Record<string, unknown>).surfacedCount, 3, "surfacedCount must NOT change on re-score");
});
});
// ── RE-R3b: schema migration v3→v4 (additive-optional lifecycle fields) ──
describe("schema migration (RE-R3b / lifecycle v3→v4)", () => {
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 });
}
};
const scored = {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
};
// ── genuinely RED while SCHEMA_VERSION=3: loadStore(v3).schemaVersion===3 ≠ 4 (hard-4 device) ──
test("RED: a v3 store (no lifecycle fields) loads stamped as v4, records intact, no field invented", () => {
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: scored },
],
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v3 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.deepEqual(s.trends[0].score, scored, "score intact");
assert.equal("status" in s.trends[0], false, "migration must not invent a status");
assert.equal("surfacedCount" in s.trends[0], false, "migration must not invent a surfacedCount");
assert.equal("lastSurfacedAt" in s.trends[0], false, "migration must not invent a lastSurfacedAt");
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:4 to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 3, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("a v4 store with lifecycle fields loads idempotent", () => {
const v4 = JSON.stringify({
schemaVersion: SCHEMA_VERSION,
trends: [
{ id: "y", title: "Handled", url: "https://example.com/h", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "acted", surfacedCount: 3, lastSurfacedAt: "2026-06-25" },
],
});
withFixture(v4, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.equal(s.trends[0].status, "acted");
assert.equal(s.trends[0].surfacedCount, 3);
assert.equal(s.trends[0].lastSurfacedAt, "2026-06-25");
});
});
test("a v4 store's lifecycle fields survive load → save → load (field preservation)", () => {
const v4 = JSON.stringify({
schemaVersion: 4,
trends: [
{ id: "z", title: "Persist", url: "https://example.com/p", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "skipped", surfacedCount: 2, lastSurfacedAt: "2026-06-24" },
],
});
withFixture(v4, (path) => {
const first = loadStore(path);
saveStore(path, first);
const second = loadStore(path);
assert.equal(second.trends[0].status, "skipped");
assert.equal(second.trends[0].surfacedCount, 2);
assert.equal(second.trends[0].lastSurfacedAt, "2026-06-24");
});
});
});
// ── RE-R3b: lifecycle functions (Phase B — RED against the stubs) ──
describe("lifecycle functions: effectiveStatus / setStatus / markSurfaced (RE-R3b)", () => {
const seedStore = () =>
addTrend(
addTrend(emptyStore(), { title: "A", url: "https://e/a", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"] }).store,
{ title: "B", url: "https://e/b", source: "tavily", capturedAt: "2026-06-02", topics: ["gov"] },
).store;
test("RED: effectiveStatus is the stored status, or 'new' when absent", () => {
assert.equal(effectiveStatus({ status: "acted" } as TrendRecord), "acted");
assert.equal(effectiveStatus({} as TrendRecord), "new");
});
test("RED: setStatus sets a present record's status (found:true); an absent id → found:false", () => {
const store = seedStore();
const idA = store.trends[0].id;
const res = setStatus(store, idA, "acted");
assert.equal(res.found, true);
assert.equal(store.trends[0].status, "acted");
assert.equal(setStatus(store, "missing-id", "skipped").found, false, "absent id → found:false (no throw)");
});
test("RED: markSurfaced increments + sets lastSurfacedAt; per-day idempotent; later day re-increments", () => {
const store = seedStore();
const idA = store.trends[0].id;
const r1 = markSurfaced(store, [idA], "2026-06-25");
assert.equal(r1.marked, 1);
assert.equal(store.trends[0].surfacedCount, 1);
assert.equal(store.trends[0].lastSurfacedAt, "2026-06-25");
assert.equal("surfacedCount" in store.trends[1], false, "an id not in the set is untouched");
const r2 = markSurfaced(store, [idA], "2026-06-25");
assert.equal(r2.marked, 0, "same-day re-mark is idempotent");
assert.equal(store.trends[0].surfacedCount, 1);
const r3 = markSurfaced(store, [idA], "2026-06-26");
assert.equal(r3.marked, 1, "a later day increments again");
assert.equal(store.trends[0].surfacedCount, 2);
assert.equal(store.trends[0].lastSurfacedAt, "2026-06-26");
});
});
});