linkedin-studio/docs/research-engine/brief-re-r3b.md
Kjell Tore Guttormsen c40b937856 docs(linkedin-studio): RE-R3b brief + plan — trend lifecycle (re-score · status · seen-log), light-Voyage hardened
Slice (a) of the full-R3 build-out: the lifecycle layer over the trend store.
- re-score on re-capture (last-wins; R3a's explicit deferral)
- status new/acted/skipped (act/skip/reset CLI; brief excludes handled)
- seen-log surfacedCount/lastSurfacedAt (per-day idempotent, brief-recorded)

Architecture confirmed via AskUserQuestion: on-record seen-log + brief records
surfacing (rankForBrief stays pure, --no-mark dry-run) · last-score-wins ·
exclude acted/skipped. score.ts + item.ts untouched (re-score reuses the R3a
capture path); touched: types/store/brief/cli + schema v3->v4.

Light-Voyage hardened (3 Opus reviewers vs live code): scope-guardian ALIGNED;
brief-reviewer PROCEED_WITH_RISKS; plan-critic PROCEED_WITH_RISKS (78/B). All
folded — incl. the MAJOR (the brief CLI store binding hoist) + the v3->v4
migration-block reconcile (premise-verified before drafting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 00:44:47 +02:00

416 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 16, **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 A1A4** (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 SC1SC9 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).