# Brief — RE-R3b: trend lifecycle — re-score on re-capture · status (acted/skipped) · seen-log

> **Slice:** RE-R3b (research-engine rung-2, R3 slice 2 — the **lifecycle** slice: what happens to a trend
> AFTER first capture). R3 ("deepen the research engine") is an **arc** of 5 open hulls (substrate §1). R3a took
> the **relevance** half of hull 5 (persist the score, rank on it). R3b takes the rest of the *lifecycle* of a
> trend: **(i) re-score on re-capture** (R3a's explicit deferral — hull 3 remainder), **(ii) a status lifecycle**
> `new`/`acted`/`skipped` (hull 5), and **(iii) a seen-log** — `surfacedCount`/`lastSurfacedAt` accumulated on
> each record as the temporal foundation slices (c)+(b) build on (hull 5, B4 dedup-state).
> **Predecessor:** RE-R3a (`score?: TrendScore` persisted first-sight; `rankForBrief` orders on composite;
> `renderBrief` surfaces band+mode) + RE-R2b (`brief.ts` dated artifact + surfacing) + RE-R2a (`capture` bridge).
> R3a §4 deferred this exactly: *"Re-score on re-capture … R3b. R3a is first-sight only (D3). Re-score pairs
> naturally with the seen-log/status slice."* — R3b is that paired slice.
> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (5) (status/lifecycle: acted/skipped) +
> remainder of (3) (status as a schema field) + §B4 (*"freshness window + dedup-state (append-only seen-log →
> don't re-surface the same item)"*). The freshness window already exists (`freshDays`, R2b); R3b adds the
> dedup-state (status as the hard dedup; surfacedCount as the soft signal).
> **TDD-order:** RED before code, **two phases** (light-Voyage BLOCKER fold, inherited from R3a): the re-score +
> migration parts of `store.test`, all of `brief.test`, and `cli.test` are true logic-RED against the pre-edit
> code (inline fixtures / behaviour change / subprocess — no new import); the `setStatus`/`markSurfaced`/
> `effectiveStatus` tests reference not-yet-existing `store.ts` exports, so under Node16 ESM a missing named
> import throws at module-load (not on assertion) — they are RED against **non-throwing stubs** landed first. See
> plan Step 1.

## 1. Operator decision context (2026-06-25)

The research engine is **Tier-1** (operator, 2026-06-23). R1→R3a built the deterministic spine: item-schema +
triage scorer (R1) → capture bridge (R2a) → dated morning brief + surfacing (R2b) → persisted relevance score +
composite ranking (R3a). What the spine still lacks is **memory of a trend's life after first sight**: the score
is frozen at first capture even as timing decays; a trend the operator already wrote about (or deliberately
passed on) **re-tops tomorrow's brief unchanged**; and nothing records that a trend has been *surfaced* N times
without action. The morning brief is meant to be a **work queue**, but today it is amnesiac — it cannot tell a
fresh unhandled signal from one the operator dealt with yesterday.

R3b closes that gap with the **lifecycle layer** the operator chose as slice (a) of the full-R3 build-out
(2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge (a) → (c) → (b) → (d) → (e)"*). It is **the fundament
for everything temporal**: the autonomous trigger (c) must *never automate a loop that re-surfaces handled
items* — so it depends on (a)'s status+seen-log; saturation/first-mover (b) is *only meaningful with accumulated
seen-data* — which (a) starts accumulating. R3b is deliberately first in the sequence: correctness of the
lifecycle model before any automation reads it.

**Architectural decisions — CONFIRMED (operator, AskUserQuestion 2026-06-25; baked into the plan):**
- **A1 — seen-log form = on-record + the brief records surfacing.** Three new optional fields on `TrendRecord`
  (`status`, `surfacedCount`, `lastSurfacedAt`); the `brief` CLI, **after** the pure `rankForBrief` computes the
  ranking, records surfacing on the rendered trends and re-saves the store. `rankForBrief` stays **pure**
  (mutation only at the CLI edge). The store stays the **single source of truth** — no separate `seen-items.md`.
  A `--no-mark` flag gives a side-effect-free dry run. *(This is exactly what slice (c) will automate and slice
  (b) will read.)*
- **A2 — re-score on re-capture = last-score-wins.** On a duplicate capture carrying a fresh `score`, the stored
  `score` is **replaced** by the freshly-computed envelope (composite re-derived by the one owner,
  `composite()`+`band()`). `score` becomes **the one deliberately-mutable field**; provenance (`source`,
  `capturedAt`, first `publishedAt`) stays first-sight. A re-score **does NOT reset status** — an `acted`/`skipped`
  decision sticks. *(Rationale: the Timing dimension decays, so the newer judgment — even a lower one — is the
  truer one; monotone "only if higher" would freeze stale optimism.)*
- **A3 — acted/skipped are EXCLUDED from the brief.** `rankForBrief` drops every record whose effective status
  is not `new` from all three buckets — the brief is a work queue, not an archive. Full history stays available
  via `list`/`query`.

## 2. The gap — grounded in code

- **The score is frozen at first sight, even as timing decays.** `addTrend`'s duplicate branch
  (`store.ts:127-131`) unions topics and returns — it **never touches `score`** (R3a's D3, first-sight only).
  The capture path already carries a fresh score on every re-capture (`item.ts:192` `itemToInput` →
  `scoreEnvelope`; `cli.ts:257` folds it through `addTrend`), so the fresh judgment **reaches `addTrend` and is
  silently discarded** for any trend already in the store. A trend re-polled a week later still ranks on its
  week-old Timing score.
- **A handled trend re-tops the brief unchanged.** `rankForBrief` (`brief.ts:82-92`) iterates **every** store
  record, dropping only off-pillar ones (`overlap === 0`, `:89`). There is no notion of "I already wrote about
  this" — an `acted` trend with a high composite re-sorts to the top of `topMatches` tomorrow exactly as it did
  today. `TrendRecord` has **no `status` field** (`types.ts:29-59`); the doc-comment anticipates it: *"can gain
  fields (…, status) in a later slice"* (`types.ts:22`).
- **Nothing records that a trend has been surfaced.** The brief is a **pure read** (`brief.ts:1-15`: *"No fs, no
  clock, no AI"*); generating it leaves no trace on the store. There is no `surfacedCount`/`lastSurfacedAt` —
  so a future autonomous loop (slice c) has **no way to know** a trend was already shown, and saturation (slice b)
  has **no accumulated signal** to read. B4's dedup-state (`docs/research-engine-concepts.local.md:63`) does not
  exist yet.
- **The CLI has no lifecycle verbs.** `cli.ts` exposes `add`/`query`/`list`/`status`/`normalize`/`score`/
  `capture`/`brief` (`:5-13`) — all capture/read. There is **no way for the operator to mark** a trend `acted`
  or `skipped`.

## 3. Scope — what is IN (RE-R3b)

### S-types — `scripts/trends/src/types.ts` (EDIT)
- **`export type TrendStatus = "new" | "acted" | "skipped";`** — the lifecycle states.
- `TrendRecord` gains **three optional fields** (all absent on pre-R3b records, all additive):
  - **`status?: TrendStatus;`** — lifecycle. **Absent ⇒ `"new"`** (back-compat); set only by `act`/`skip`/`reset`,
    **never on capture** (a freshly-captured trend is implicitly `new`).
  - **`surfacedCount?: number;`** — the seen-log count: how many distinct days this trend has appeared in a
    generated brief. **Absent ⇒ 0.** Incremented (per-day-idempotent) by the `brief` CLI.
  - **`lastSurfacedAt?: string;`** — ISO date of the most recent surfacing. **Absent ⇒ never.** The per-day
    idempotency key (re-running today's brief does not re-increment).
- Doc-comment: mark `status`/`surfacedCount`/`lastSurfacedAt` as the now-realized lifecycle fields the `:22`
  note anticipated.
- **`SCHEMA_VERSION = 3 → 4`** (`types.ts:73`). Additive-optional; the migration is the version-stamp alone
  (below), identical to v1→v2→v3.

### S-store — `scripts/trends/src/store.ts` (EDIT)
- **`export function effectiveStatus(t: TrendRecord): TrendStatus`** — `return t.status ?? "new";`. The single
  reader of the absent-⇒-new convention (pure; consumed by `addTrend` audit, `brief`, and the CLI). Imports
  `TrendStatus` from `./types.js` (type-only).
- **Re-score in `addTrend`'s duplicate branch (`:127-131`, A2):** after the topic union, if `input.score !==
  undefined` **and it differs from `existing.score`** (compared via `JSON.stringify` — the envelope is built in a
  fixed key order by `scoreEnvelope`, so the compare is stable), set `existing.score = input.score` and mark the
  record changed. `AddResult.merged` is **broadened** to *"the existing record was mutated — topics unioned and/or
  score refreshed"*; `merged` is true iff **either** changed (a re-capture with an identical score → `merged:false`,
  no false-positive). `status`/`surfacedCount`/`lastSurfacedAt` are **NOT touched** on re-capture (A2: re-score
  doesn't reset status; surfacing is the brief's job, not capture's). The **new-record** branch (`:132-144`) is
  unchanged — a new record omits all three lifecycle fields (status absent ⇒ new; never surfaced; no input.status
  exists on the capture path).
- **`export function setStatus(store: TrendStore, id: string, status: TrendStatus): { store: TrendStore; found:
  boolean }`** — find the record by `id`; if absent return `{ store, found: false }` (no throw); else set
  `t.status = status` (set **explicitly**, including `"new"` for a `reset`) and return `{ store, found: true }`.
  Mutates in place + returns the same store (the `addTrend` idiom). Pure (no fs).
- **`export function markSurfaced(store: TrendStore, ids: string[], today: string): { store: TrendStore; marked:
  number }`** — for each record whose `id` is in `ids` **and** whose `lastSurfacedAt !== today` (per-day
  idempotent), set `surfacedCount = (surfacedCount ?? 0) + 1` and `lastSurfacedAt = today`; count it. Records
  already surfaced today, or not in `ids`, are untouched. Pure (no fs; `today` injected by the caller, like
  `capturedAt`). Returns the count actually incremented.
- `AddResult` keeps its **2-flag shape** `{ store, added, merged }` (no new flag — `merged` is broadened, not
  joined). `TrendInput` is **unchanged** (no `status`/`surfaced*` input — lifecycle is set post-capture, not
  ingested).
- `loadStore` migrate comment (`:82-88`): extend the enumeration to *"v1→v2→v3→v4 are all purely
  additive-optional"*. **No code change** (`Math.max(onDisk, SCHEMA_VERSION)` `:91` already stamps v4;
  `saveStore` `JSON.stringify` `:99` preserves the three new fields). Only `SCHEMA_VERSION` (in `types.ts`) and
  the comment move.

### S-brief — `scripts/trends/src/brief.ts` (EDIT)
- **`rankForBrief` excludes handled trends (A3):** in the entry loop (`:82-92`), add **`if (effectiveStatus(trend)
  !== "new") continue;`** immediately before the `overlap === 0` check (so acted/skipped never enter any bucket).
  Import `effectiveStatus` from `./store.js` (brief.ts already imports `defaultStorePath` from there — `:19`; the
  edge stays one-way, no cycle). `totals.trends` **still counts the full inventory** (`store.trends.length`,
  `:116`) — honest "of N in store"; `totals.matched`/`fresh` naturally reflect the post-filter `entries`.
- **`renderBrief`/`renderTopEntry`/`renderBulletEntry` surface the trend `id` + a surfaced marker** (so the
  operator can act on an entry, and a re-surfaced item is honest). **Pinned line shapes:**
  - A shared **`surfacedToken(e)`** helper (mirrors `scoreToken`, `:142-145`): ` · sett <surfacedCount>x` when
    `surfacedCount >= 2`, else `""` (only a genuinely re-surfaced item is flagged; this is a saturation **hint**,
    not the saturation **scoring** of slice b). **Semantic (folded — plan-critic #3): the count is PRIOR-DAY** —
    the brief renders from `surfacedCount` **before** the CLI records today's surfacing (the mutation runs after
    `renderBrief`), so `· sett Nx` means *"shown on N prior distinct days"* (today's appearance is recorded but
    not yet counted in this render). The `>= 2` floor therefore means "already shown on ≥2 earlier days". This is
    documented in the README + asserted by a unit test that sets `surfacedCount` directly (the cross-day behaviour
    is exercised by behavioural step §7).
  - Top-entry meta line (`renderTopEntry`, `:150`): append **` · \`<id>\``** at the end (after `Pillarer: …`),
    and `surfacedToken(e)` after the `scoreToken`:
    `- Kilde: <source> · Publisert: <date> (<age>d)<scoreToken><surfacedToken> · Pillarer: <matched> · \`<id>\``
  - Bullet line (`renderBulletEntry`, `:159`): append **` · \`<id>\``** at the end (after `🔗 <url>`), with
    `surfacedToken` after `scoreToken`:
    `- **<title>** — «<matched>» · <date> (<age>d)<scoreToken><surfacedToken> · 🔗 <url> · \`<id>\``
  - The id is rendered in backticks so it is copy-paste-ready for `act --id <id>` / `skip --id <id>`.
- **`export function surfacedIds(ranking: BriefRanking): string[]`** — the ids of the entries `renderBrief`
  **actually shows**: `topMatches ∪ singleMatches ∪ olderMatched.slice(0, 5)` (mirrors the `:199` `.slice(0, 5)`
  older cap), mapped to `e.trend.id`. The CLI feeds this to `markSurfaced` so the seen-log records exactly what
  the operator saw. Pure.
- **`ranking:` frontmatter descriptor (`:175`)** → the **exact** string
  `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N>; excludes
  acted/skipped` (pinned verbatim; `brief.test` asserts byte-for-byte). The trailing `; excludes acted/skipped`
  is the only descriptor change.
- `briefSummary` (`:129-139`) is **unchanged** (the headline still names the top fresh match's band + age; status
  exclusion happens upstream in the ranking, so the summary already reflects only `new` trends). `BRIEF_SCHEMA_
  VERSION` stays **1** (no frontmatter *field* added/removed — `date`/`summary`/`store`/`ranking`/`schemaVersion`
  unchanged; only the `ranking:` *string* and body content change; the surfacing hook still reads `date`+`summary`).

### S-cli — `scripts/trends/src/cli.ts` (EDIT)
- **`act` / `skip` / `reset` subcommands** (set lifecycle status by id):
  - `act --id <id> [--store <path>]` → `setStatus(store, id, "acted")`; `skip …` → `"skipped"`; `reset …` →
    `"new"`. Each: load → setStatus → if `found` save + print `Marked <id> <status>` (exit 0); if **not found**
    print `error: no trend with id: <id>` to stderr + **exit 2**. A missing/`true` `--id` → `usage('<cmd> needs
    --id <id>')` (exit 2). **Exit-code contract broadened (folded — plan-critic #2):** a not-found id is exit 2,
    which the existing contract documents as "usage error". Update the header doc-comment (`cli.ts:33`) to read
    *"0 on success, 2 on usage error or a not-found id (act/skip/reset)"* — a wrong `--id` value is an
    argument-class error, distinct from `capture`'s data-stream items (which stay in `errors[]`, never the exit
    code). A new exit code is **not** introduced (the CLI keeps its two codes).
- **`brief` records surfacing (A1):** **hoist the load** (folded — plan-critic #1 / brief-reviewer #1): replace
  the inline `rankForBrief(loadStore(storePath), …)` (`cli.ts:286`) with **`const store = loadStore(storePath);
  const ranking = rankForBrief(store, pillars, day, { freshDays });`** — `cli.ts:286` does **not** currently bind
  a `store` variable (verified), so the surfacing edit needs this hoist or it references an undefined identifier.
  Then after `writeFileSync(path, md, …)` (`:290`), **unless `--no-mark`**: `markSurfaced(store, surfacedIds
  (ranking), day)` then `saveStore(storePath, store)` — the **hoisted `store`** holds the full inventory, so
  acted/skipped records (filtered from the ranking but still in the store) are preserved on resave; the `.md` is
  rendered from the pure `ranking` **before** the mutation. `const mark = flags["no-mark"] !== "true";` (a bare
  `--no-mark` → `"true"` → mark off). The `--json` output gains a **`marked`** count (trends whose seen-log this
  run incremented; `0` when `--no-mark`). `rankForBrief`/`renderBrief` are untouched — the mutation is purely at
  the edge.
- **`capture` tally comment (`cli.ts:251-252`)** (folded — plan-critic #4): the broadened `AddResult.merged`
  (topics ∪ score-refresh) makes the existing comment *"a fold is … `merged` (existing gained topics)"* stale →
  update it to *"`merged` (existing gained topics and/or a refreshed score)"*. No tally-logic change (the loop
  already counts `res.merged`).
- **Usage + header doc:** add the three new verbs + `[--no-mark]` to the `usage()` block (`:82-91`) and the
  header synopsis (`:5-13`); a one-line header note that `act`/`skip`/`reset` set a trend's lifecycle status, the
  brief excludes handled trends and records surfacing, and re-capture refreshes the score.

### Wiring (D-default — WIRE, mirrors R3a)
- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): Step 4.5 already emits the per-item `score` (R3a);
  re-score is **automatic** (capture re-folds an existing trend with a fresh score → `addTrend` now refreshes it),
  so **no batch-shape change**. Add one prose line: re-capturing a known trend now **refreshes** its relevance
  score (timing decays), and the operator marks trends `acted`/`skipped` via the CLI so the brief stops
  re-surfacing handled work. Domain-general (no vendor/sector tokens). *(If a Section-16k grep targets the agent,
  it must be verified non-vacuous first; the recommended 16k greps target src files only — see gate below.)*
- `scripts/trends/README.md` (EDIT): document the status lifecycle (`new`/`acted`/`skipped` + `act`/`skip`/`reset`),
  the seen-log (`surfacedCount`/`lastSurfacedAt`, per-day idempotent, brief-recorded), re-score-on-recapture
  (last-wins), and the brief's exclude-handled behaviour + `--no-mark`.
- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:705`, currently 146) to the `tests N` line
  reported after Steps 1–6, **append** `+ RE-R3b: lifecycle +N` to the inline breakdown comment. Add
  **Section 16k** ("Trends Lifecycle Wiring", RE-R3b) **after Section 16j's closing block, before Section 18**
  (16j is the last 16x before the anti-erosion Section 18; preserve that order). Mirror 16j's shape:
  **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). Recommended
  **6 emitters** (all on tracked src — no `tsx`): (1) self-test; (2) `export type TrendStatus` in `types.ts`;
  (3) `surfacedCount` in `types.ts` (seen-log field); (4) `export function markSurfaced` in `store.ts` (seen-log
  writer); (5) `effectiveStatus` in `brief.ts` (the brief excludes handled); (6) `command === "act"` in `cli.ts`
  (the lifecycle verb). **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 99 → exactly 105** (`:1259`;
  "live recount" is the safety net; the expected value is the pinned 99 + 6 = 105). Update the header-enumeration
  prose chain by inserting the 16k clause between the 16j clause and the Section-18 clause.

## 4. Non-goals — what is OUT (deferred)

- **Saturation scoring / first-mover-as-a-field** (the quantitative *use* of `surfacedCount`) — **slice (b)**.
  R3b **accumulates** the seen-log and shows a minimal `· sett Nx` hint, but it does **not** compute a saturation
  score, decay the composite by surfacings, or add a first-mover field. (b) reads R3b's accumulated data.
- **Autonomous nightly trigger** (cron/launchd, headless entry — hull 1+6) — **slice (c)**. R3b adds no scheduler;
  it builds the lifecycle (c) will safely automate.
- **Brief history surfacing / diff** ("what's new since yesterday" — hull 7) — **slice (d)**. The seen-log records
  *that* a trend was surfaced; the cross-brief **diff** is (d). R3b's `· sett Nx` is a per-record count, not a
  day-over-day diff.
- **Research-deepening A1–A4** (plan → isolated workers → gap loop → curate) — **slice (e)**, behind the post-(d)
  re-evaluation gate.
- **Mode-segmented ranking / `--mode` filter** — still OUT (R3a non-goal, unchanged).
- **Re-score semantics other than last-wins** (monotone / timing-only refresh) — OUT (A2 chose last-wins).
- **A `status`/`surfaced*` input on the capture/`add` path** — OUT. Lifecycle is set **post-capture** by
  `act`/`skip`/`reset`; capture never ingests a status. `TrendInput` is unchanged.
- **`act`/`skip` by title/url** (deriving the id) — OUT for R3b; `--id` only (the id is shown in the brief +
  `list --json`). A title/url alias is a later ergonomic nice-to-have.
- **Auto-acting on publish** (wiring `act` into `/linkedin:post` / the post-tracking flow) — OUT. R3b ships the
  CLI verbs; auto-marking from the content commands is a separate plugin-surface slice.
- **`BRIEF_SCHEMA_VERSION` bump** — OUT (no frontmatter field changes); Open Q.
- **New source file / new agent / new command** — none. R3b is edits to **four** existing `src/*.ts` (`types`,
  `store`, `brief`, `cli`) + their tests + one agent (prose) + README + gate. `score.ts` + `item.ts` are
  **untouched** (re-score reuses the R3a capture path). Counts stay 27/19/29.

## 5. Boundaries / invariants (must hold)

- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — true logic-RED for
  the re-score + migration parts of `store.test` (existing `addTrend`/`loadStore`, inline fixtures), all of
  `brief.test` (behaviour change to existing `rankForBrief`/`renderBrief`), and `cli.test` (subprocess: `act`/`skip`
  print a usage/unknown-command error today → assertion-RED). Phase B — `setStatus`/`markSurfaced`/`effectiveStatus`
  reference new `store.ts` exports → land non-throwing stubs first (Node16 ESM throws a missing named import at
  module-load), then record value-assertion RED against the stubs. The plan does **not** claim a single
  "everything fails before any code" run.
- **`rankForBrief` stays pure (A1):** no fs, no clock, no env, no AI, **no store mutation**. The status filter is
  a pure read of `effectiveStatus`. The seen-log **write** lives only in the `brief` CLI edge (after the pure
  ranking), guarded by `--no-mark`. `markSurfaced`/`setStatus`/`effectiveStatus`/`surfacedIds` are all pure.
- **One composite owner (unchanged):** re-score reuses the **already-built** capture path
  (`itemToInput`→`scoreEnvelope`→`composite`+`band`); R3b adds **no new arithmetic** and does not touch `score.ts`.
- **Provenance discipline (A2):** `source`, `capturedAt`, and the first `publishedAt` stay **first-sight**; only
  `score` is mutable on re-capture; `status`/`surfacedCount`/`lastSurfacedAt` are mutated only by their own
  owners (`setStatus`/`markSurfaced`), never by `addTrend`.
- **Per-day-idempotent surfacing:** running `brief` twice on the same `today` increments `surfacedCount` **at most
  once** (`markSurfaced` skips records whose `lastSurfacedAt === today`). Re-generating today's brief is a no-op on
  the seen-log. *(This is the determinism guarantee for the autonomous loop: an idempotent daily mark.)*
- **No false-merge on re-capture:** a re-capture with a **byte-identical** score → `merged:false` (the
  `JSON.stringify` compare); only a genuine topic-union or score-change flips `merged`.
- **Determinism (brief):** same `(store, pillars, today, freshDays)` → byte-identical `renderBrief` output (the
  status filter + `surfacedToken` + id are deterministic reads of the store; the composite sort total order from
  R3a holds). The CLI's surfacing mutation is **outside** the pure render.
- **Lossless additive migration (both directions):** a v3 store loads as v4 with records **untouched** (no
  `status`/`surfaced*` invented); round-trip writes `schemaVersion: 4`; a v4 store is idempotent; the three new
  optional fields **survive a load+resave**. Mirrors the R3a v2→v3 proof (`store.test.ts`, `(RE-R3a / score
  v2→v3)` block) with the literals flipped `3`→`4`.
- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and **never shells out to tsx**.
  R3b touches neither the hook nor the frontmatter schema (`BRIEF_SCHEMA_VERSION` stays 1), so surfacing is
  unchanged. The existing hook suite must still pass untouched (regression sanity; R3b adds no hook test).
- **Domain-general:** Section 17 de-niche stays green; the `trend-spotter.md` prose carries only generic
  lifecycle wording (`acted`/`skipped`/"refresh the score"), no vendor/sector tokens.
- **No SSOT change:** `references/trend-scoring-modes.md` untouched (R3b changes no scoring math).
- **No store-query change:** `queryByTopic`/`history`/`newestCaptureDate` untouched. *(The CLI `status`
  subcommand — the staleness reader — is unrelated to the new `TrendStatus` lifecycle type; the name collision is
  pre-existing and not reconciled here.)*
- **Pathguard:** all edits are to **existing** files (no new `.mjs` under `hooks/scripts/`; no new `.ts` — R3b
  adds no source file). `.gitignore` already covers `scripts/trends/{node_modules,build}`.
- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed.

## 6. Success criteria (testable)

- **SC1 (status field + effectiveStatus + setStatus)** — `effectiveStatus({…no status})` is `"new"`;
  `effectiveStatus({…status:"acted"})` is `"acted"`. `setStatus(store, id, "skipped")` on a present id sets the
  record's `status` and returns `{ found:true }`; on an absent id returns `{ found:false }` (no throw, store
  unchanged); a `reset` sets `status:"new"` explicitly.
- **SC2 (re-score last-wins, no false-merge, status/provenance untouched)** — `addTrend(store, dupInput)` where
  `dupInput` has the same title+url and a **different** `score` → the stored `score` is **replaced**, `merged:true`,
  `added:false`, topics still unioned, and `source`/`capturedAt`/`publishedAt`/`status`/`surfacedCount` are
  **unchanged**. A re-capture with a **byte-identical** score (and no new topics) → `merged:false`. A duplicate
  with **no** `score` → stored score unchanged. A re-capture of an **acted** trend with a new score → score
  updated, **status stays `acted`**. **At the CLI edge (folded — plan-critic #4):** a `capture` of a scored item,
  then a `capture` of the same title+url with a **changed** score → the second `capture --json` reports
  `merged:1`, and `list --json` shows the **updated** composite (a subprocess test, not only the manual §7 step).
- **SC3 (markSurfaced + per-day idempotency)** — `markSurfaced(store, [idA, idC], "2026-06-25")` increments
  `surfacedCount` (absent⇒0→1) and sets `lastSurfacedAt:"2026-06-25"` on A and C only (B untouched), returns
  `marked:2`; a second `markSurfaced` with the **same `today`** → `marked:0`, counts unchanged; a third with a
  **later** `today` → increments again, `lastSurfacedAt` advances; an id not in the store is silently skipped.
- **SC4 (migration v3→v4, both directions)** — a `schemaVersion:3` store with records lacking the lifecycle
  fields loads as **v4**, records intact, **no field invented**; round-trip `loadStore→saveStore` writes
  `schemaVersion:4`; a v4 store with lifecycle fields loads idempotent; **the three new fields survive
  load+resave** (byte-identical). Mirrors the R3a `(RE-R3a / score v2→v3)` block, retitled `(RE-R3b / lifecycle
  v3→v4)`, every `schemaVersion` literal flipped `3`→`4`.
- **SC5 (brief excludes acted/skipped)** — given a store with `new`, `acted`, and `skipped` records all matching
  pillars + fresh: `rankForBrief` places **only** the `new` ones in `topMatches`/`singleMatches`/`olderMatched`;
  `totals.trends` still equals the **full** store count; a store whose only matches are `acted`/`skipped` →
  empty buckets + the `briefSummary` "no fresh signals" line; the order among the surviving `new` records is the
  R3a composite total order (unchanged).
- **SC6 (brief render: id + surfaced marker + descriptor)** — `renderBrief` emits the **full pinned line shapes**
  (§3): a top entry ends with `· \`<id>\`` (after `Pillarer: …`); a bullet ends with `· \`<id>\`` (after `🔗
  <url>`); a record with `surfacedCount >= 2` shows `· sett <N>x` (after the score token), one with
  `surfacedCount` 0/1/absent shows **no** surfaced token — both asserted as **full lines**. The `ranking:`
  descriptor equals the pinned string ending `; excludes acted/skipped` verbatim. `surfacedIds(ranking)` returns
  exactly the ids of `topMatches ∪ singleMatches ∪ olderMatched.slice(0,5)`. Two `renderBrief` calls on the same
  input are byte-identical.
- **SC7 (CLI act/skip/reset)** — `act --id <id> --store <tmp>` then `list --store <tmp> --json` shows the record
  with `status:"acted"`; `skip` → `"skipped"`; `reset` → `"new"`; an **unknown** id → stderr error + **exit 2**,
  store unchanged; a missing `--id` → usage + exit 2.
- **SC8 (CLI brief marks surfaced + --no-mark + exclusion end-to-end)** — `brief --pillars … --store <tmp>` on a
  store with fresh matches → the written `.md` **omits** any acted/skipped record; a following `list --store <tmp>
  --json` shows the surfaced trends with `surfacedCount:1` + today's `lastSurfacedAt`, and the `--json` output
  carries `marked:<n>`; a **second** `brief` the same day → `marked:0`, counts unchanged (idempotent);
  `brief --no-mark --store <tmp>` on a fresh store → `marked:0`, **no `surfacedCount` written** (store's trends
  unchanged save for nothing).
- **SC9 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the bumped
  `TRENDS_TESTS_FLOOR`; new **Section 16k** green (`TrendStatus` + `surfacedCount` in `types.ts`, `markSurfaced`
  in `store.ts`, `effectiveStatus` in `brief.ts`, `command === "act"` in `cli.ts`, non-vacuity self-test);
  `ASSERT_BASELINE_FLOOR` = **105** (99 + 6); Section 17 de-niche green; counts 27/19/29; the hook suite still
  green untouched.

## 7. Verification

**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16k self-test +
greps pass; `ASSERT_BASELINE_FLOOR` = 105; Section 17 de-niche green; ref/agent/command counts unchanged.
**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3b touches no
hook; adds no hook test).
**Behavioural (manual):**
1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>",
   "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]'
   | node --import tsx src/cli.ts capture --store /tmp/r3b.json` — adds A.
2. Re-`capture` A with a **lower** timing (`"timing":3`) → `list --store /tmp/r3b.json --json` shows A's
   composite **dropped** (re-score last-wins); the capture tally reports `merged:1`.
3. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3b.json --out /tmp/r3b-brief --json` →
   confirm `marked:1`; `list --json` shows A with `surfacedCount:1` + today's `lastSurfacedAt`; the entry line
   shows `· \`<id>\``.
4. Re-run the **same** `brief` → `marked:0` (idempotent); `surfacedCount` still 1.
5. `node --import tsx src/cli.ts act --id <A's id> --store /tmp/r3b.json` → re-run `brief` → A is **absent** from
   the written `.md`; the summary reports no fresh signals (if A was the only match).
6. `node --import tsx src/cli.ts reset --id <A's id> --store /tmp/r3b.json` → A reappears in the brief.
7. `brief --no-mark` on a fresh store → `marked:0`, `surfacedCount` not written.

## 8. Open questions for the go-gate

Three architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-25): **A1** on-record seen-log,
the `brief` CLI records surfacing (`rankForBrief` pure, `--no-mark` dry-run); **A2** re-score last-wins (score the
one mutable field; status not reset); **A3** acted/skipped excluded from the brief. Residual decisions, all baked
to the recommended default — confirm or redirect with "Go":
- **D1 — status values `new`/`acted`/`skipped`, absent⇒new (omit on add)?** YES (rec). A 3-state lifecycle; a
  freshly-captured trend is implicitly `new` (field omitted); `reset` sets `"new"` explicitly. Re-open only if a
  `published`/`drafted` distinction is wanted (the plugin tracks posts elsewhere — kept out of the trend store).
- **D2 — `AddResult.merged` broadened (topics ∪ score-refresh), no new flag?** YES (rec). Keeps the 2-flag shape;
  the capture tally's "N merged" honestly means "N existing records updated". Re-open only if `rescored` must be
  counted **separately** from topic-merges in the CLI tally.
- **D3 — include `reset` (un-skip → new)?** YES (rec). Symmetric + cheap; the operator changes their mind. Drop
  only to keep the verb set to two.
- **D4 — show the trend `id` in brief entries?** YES (rec). The status feature is **inoperable** otherwise — the
  operator needs the id to `act`/`skip`. Shown in backticks for copy-paste. Alternative: omit, and require
  `list --json` to find ids (clunky).
- **D5 — minimal `· sett Nx` marker when `surfacedCount >= 2`?** YES (rec). Keeps the seen-log **honest/visible**
  in R3b (otherwise it is an invisible schema-only accumulation — the anti-pattern R3a warned of) without
  straying into (b)'s saturation scoring or (d)'s diff. The `>= 2` floor means a first/second sighting is silent.
  Drop only if any visible surfaced signal should wait for (b).
- **D6 — `act`/`skip` identify by `--id` only?** YES (rec). Store-native; the id is shown in the brief +
  `list --json`. A title/url alias is a deferred nice-to-have.
- **D7 — which entries count as "surfaced"?** The entries `renderBrief` **actually shows**: `topMatches ∪
  singleMatches ∪ olderMatched.slice(0,5)` (rec). Matches what the operator saw; the older-bucket cap mirrors the
  render's `.slice(0,5)`.
- **D8 — `BRIEF_SCHEMA_VERSION` 1→2?** NO (rec). No frontmatter field added/removed (the hook reads only
  `date`+`summary`). Re-open only if the artifact should self-announce the exclude-handled change.
- **D9 — commit split?** Single code commit (rec) — R3b's lifecycle (re-score/status/seen-log) is tightly
  coupled; the R3a data-then-visible split would land an invisible cut. Docs commit first, then one code commit.

## 9. Light-Voyage review — folded

Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED**
(every SC1–SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held; counts 27/19/29 verified live;
`score.ts`/`item.ts`-untouched verified — `itemToInput` already builds the envelope on every capture incl.
re-capture; A1/A2/A3 consistent across every step; the R3a-block reconcile is a necessary prerequisite, not creep;
2 MINOR plan line-cite nits). **brief-reviewer: PROCEED_WITH_RISKS** (all nine load-bearing claims verified TRUE —
incl. the v3→v4 reconcile complete for **every** breaking literal, enumerated; 1 MEDIUM + 3 LOW). **plan-critic:
PROCEED_WITH_RISKS (78/B)** (the two-phase RED, the atomic bump+reconcile, the `merged` broadening's
non-regression, the `surfacedIds` formula, and the gate arithmetic all verified correct; 1 MAJOR + 5 MINOR).
All findings folded; see `plan-re-r3b.md §Plan-critic — folded` for per-finding resolution. Headlines:

- **[MAJOR/MEDIUM, folded — both reviewers] the `brief` CLI's `store` binding does not exist.** `cli.ts:286`
  inlines `rankForBrief(loadStore(storePath), …)` — there is no `const store`, so the `markSurfaced(store, …)` /
  `saveStore(storePath, store)` edit referenced an undefined identifier. → §3 S-cli + plan Step 5 now **hoist**
  `const store = loadStore(storePath)` and pass it to `rankForBrief`; R5 wording corrected.
- **[MINOR, folded — plan-critic #2] not-found id → exit 2 contradicted the documented exit-code contract.** →
  the `cli.ts:33` doc-comment is **broadened** to *"2 on usage error or a not-found id (act/skip/reset)"* (a wrong
  `--id` is an argument-class error, distinct from `capture`'s data items); no third exit code introduced (§3 S-cli).
- **[MINOR, folded — plan-critic #3] `· sett Nx` off-by-one.** Render precedes the surfacing mutation, so the
  token reflects the **prior-day** count. → the **prior-day semantic** is now stated explicitly (§3 S-brief + the
  README): `· sett Nx` = "shown on N prior distinct days".
- **[MINOR, folded — plan-critic #4] `capture` tally comment stale + the re-score CLI tally untested.** → §3 S-cli
  updates the `cli.ts:251-252` comment (`merged` = topics ∪ score-refresh); SC2 adds a subprocess assertion that a
  re-captured changed-score item reports `merged:1` with the updated composite.
- **[MINOR, folded — plan-critic #5] Step 2 used `TrendStatus` before Step 3 defined it.** → the plan is
  reordered: Step 2 adds the `TrendStatus` type + the three fields to `types.ts` **first** (then the `store.ts`
  functions); Step 3 isolates the atomic `SCHEMA_VERSION` bump + the R3a-block reconcile.
- **[LOW, folded — brief-reviewer #4] forward-debt: the new R3b migration block hard-coded `4`** (perpetuating the
  reconcile-cycle this slice pays for R3a). → the new block's **target + idempotent** assertions commit against
  `SCHEMA_VERSION` (the hard-`4` is the Step-1 RED device only; the v3 **input** fixtures stay literal `3`),
  breaking the cycle so R3c won't pay it.
- **[LOW, folded] cosmetic literal/title drift** — `store.test.ts:571`/`:598` titles + `:570` comment flipped to
  "the current version"; `cli.test.ts:247`'s inert `schemaVersion:2` fixture added to the scope-fence enumeration;
  the two plan line-cites corrected to `~:1235` (after 16j's block) / `:1237` (Section 18 header).
