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:
parent
c40b937856
commit
b185db9a12
10 changed files with 661 additions and 44 deletions
|
|
@ -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 <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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue