diff --git a/agents/trend-spotter.md b/agents/trend-spotter.md index 7358cd2..8802b5d 100644 --- a/agents/trend-spotter.md +++ b/agents/trend-spotter.md @@ -319,6 +319,13 @@ One `capture` call folds the whole batch and reports 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. +**Re-capture refreshes the score; the operator drives the lifecycle.** Re-capturing a trend already +in the store never duplicates it — its topics union in and its relevance `score` is **refreshed** +(the newer judgment wins, since the timing dimension decays). The operator marks a trend `acted` +(written about) or `skipped` with `act`/`skip --id ` (the id is shown in the brief and via +`list --json`); the morning brief then **excludes** handled trends so the queue surfaces only +unresolved work, and `reset --id` returns one to the queue. + **Step 4.6: Write the dated morning brief (surfacing)** After capturing, render today's dated morning brief over the store so the **next session surfaces diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index eea991b..a1ee764 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -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 8–18). # # 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" diff --git a/scripts/trends/README.md b/scripts/trends/README.md index 2f1fe4a..df43796 100644 --- a/scripts/trends/README.md +++ b/scripts/trends/README.md @@ -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 1–10 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 ] [--store ] [--json] + [--fresh-days 7] [--out ] [--no-mark] [--store ] [--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 # wrote about it +node --import tsx src/cli.ts skip --id # decided to pass on it +node --import tsx src/cli.ts reset --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 `· ()` 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 `· ()` 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 diff --git a/scripts/trends/src/brief.ts b/scripts/trends/src/brief.ts index 5614857..d2fc1c4 100644 --- a/scripts/trends/src/brief.ts +++ b/scripts/trends/src/brief.ts @@ -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: diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts index fbdfbb6..8094c61 100644 --- a/scripts/trends/src/cli.ts +++ b/scripts/trends/src/cli.ts @@ -7,10 +7,11 @@ * node --import tsx src/cli.ts query --topics [--store ] [--json] * node --import tsx src/cli.ts list [--since ] [--limit ] [--store ] [--json] * node --import tsx src/cli.ts status [--store ] [--json] + * node --import tsx src/cli.ts act|skip|reset --id [--store ] * echo '' | node --import tsx src/cli.ts normalize * echo '' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] * echo '' | node --import tsx src/cli.ts capture [--store ] [--json] - * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--out ] [--store ] [--json] + * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--out ] [--no-mark] [--store ] [--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 { const out: Record = {}; @@ -84,10 +91,11 @@ function usage(msg: string): never { " query --topics [--store ] [--json]\n" + " list [--since ] [--limit ] [--store ] [--json]\n" + " status [--store ] [--json]\n" + + " act|skip|reset --id [--store ]\n" + " normalize < raw-item-or-batch.json\n" + " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + " capture [--store ] [--json] < raw-item-or-batch.json\n" + - " brief [--pillars ] [--fresh-days N] [--out ] [--store ] [--json]", + " brief [--pillars ] [--fresh-days N] [--out ] [--no-mark] [--store ] [--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 `); + 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; } diff --git a/scripts/trends/src/store.ts b/scripts/trends/src/store.ts index f1cd4ad..d1c42cf 100644 --- a/scripts/trends/src/store.ts +++ b/scripts/trends/src/store.ts @@ -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; - // 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 diff --git a/scripts/trends/src/types.ts b/scripts/trends/src/types.ts index 63af140..53e6f9f 100644 --- a/scripts/trends/src/types.ts +++ b/scripts/trends/src/types.ts @@ -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; diff --git a/scripts/trends/tests/brief.test.ts b/scripts/trends/tests/brief.test.ts index f945b7a..5756086 100644 --- a/scripts/trends/tests/brief.test.ts +++ b/scripts/trends/tests/brief.test.ts @@ -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"); + }); +}); diff --git a/scripts/trends/tests/cli.test.ts b/scripts/trends/tests/cli.test.ts index ee7dbca..881817c 100644 --- a/scripts/trends/tests/cli.test.ts +++ b/scripts/trends/tests/cli.test.ts @@ -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> => + 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 }); + } + }); +}); diff --git a/scripts/trends/tests/store.test.ts b/scripts/trends/tests/store.test.ts index 66cb01d..788fd68 100644 --- a/scripts/trends/tests/store.test.ts +++ b/scripts/trends/tests/store.test.ts @@ -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).status = "acted"; + (store.trends[0] as Record).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).status, "acted", "status must NOT reset on re-score"); + assert.equal((res.store.trends[0] as Record).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"); + }); + }); });