# Plan — RE-R3b: trend lifecycle — re-score on re-capture · status (acted/skipped) · seen-log > **Brief:** `docs/research-engine/brief-re-r3b.md`. **Slice:** RE-R3b (research-engine rung-2 — R3 slice 2, the > **lifecycle** slice: re-score-on-recapture + status `new`/`acted`/`skipped` + the `surfacedCount`/`lastSurfacedAt` > seen-log). > **TDD-order (two-phase RED — light-Voyage BLOCKER fold, inherited from R3a):** Step 1 records RED in two phases — > **(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` are unknown commands today → assertion-RED); **(B)** for `setStatus`/`markSurfaced`/ > `effectiveStatus` (new `store.ts` exports), land non-throwing stubs first (Node16 ESM throws a missing named > import at module-load, not on assertion), then record value-assertion RED against the stubs. Then GREEN: > S-store stubs→real (effectiveStatus + setStatus + markSurfaced + re-score) → S-types (3 fields + v3→v4) **+ the > R3a-migration-block reconcile** → S-brief (status filter + id/marker render + surfacedIds + descriptor) → S-cli > (act/skip/reset + brief-marks-surfaced + --no-mark) → wire `trend-spotter.md` (prose) + README → gate floors + > Section 16k → behavioural → land. > **Counts recounted live at land, never pinned/guessed.** > **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-25):** A1 on-record seen-log, brief records > surfacing (rankForBrief pure, `--no-mark`) · A2 re-score last-wins (score the one mutable field; status not > reset) · A3 acted/skipped EXCLUDED from the brief. Go-gate D1–D9 baked to recommended defaults (brief §8). > **Light-Voyage hardened:** scope-guardian ALIGNED (0 creep / 0 gaps; 2 MINOR line-cites) · brief-reviewer > PROCEED_WITH_RISKS (all 9 load-bearing claims TRUE; 1 MEDIUM + 3 LOW) · plan-critic PROCEED_WITH_RISKS (78/B; > 1 MAJOR + 5 MINOR) — all folded (see §Plan-critic — folded). The MAJOR (the `brief` CLI `store` binding) is > fixed by the Step-5 hoist. ## Goal Give a trend a **life after first capture**. (1) **Re-score on re-capture** — `addTrend`'s duplicate branch now refreshes `score` (last-wins; timing decays), reusing the already-built capture path (no new arithmetic, `score.ts` untouched). (2) **Status lifecycle** — `new`/`acted`/`skipped` on the record, set by new `act`/`skip`/`reset` CLI verbs; the brief **excludes** handled trends. (3) **Seen-log** — `surfacedCount`/`lastSurfacedAt` accumulated (per-day-idempotent) by the `brief` CLI after the pure ranking, the temporal foundation slices (c)+(b) build on. Schema v3→v4 (additive lossless migrate — the R3a pattern, **plus** reconciling the R3a migration block's hard-coded `3` literals that the bump would otherwise regress). No saturation scoring, no scheduler, no brief-diff, no new source file — those stay later R3 slices. ## Files touched (exhaustive — for scope-guardian) | File | Change | SC | |---|---|---| | `scripts/trends/src/types.ts` | **EDIT** — `export type TrendStatus`; `TrendRecord` gains `status?`/`surfacedCount?`/`lastSurfacedAt?` (all optional); `SCHEMA_VERSION` 3→4; doc-comment | SC1, SC4 | | `scripts/trends/src/store.ts` | **EDIT** — `effectiveStatus(t)` (absent⇒new); `setStatus(store,id,status)`; `markSurfaced(store,ids,today)` (per-day idempotent); `addTrend` duplicate branch re-scores (last-wins, `merged` broadened, no false-merge); migrate comment v1→…→v4 (logic unchanged) | SC1, SC2, SC3, SC4 | | `scripts/trends/src/brief.ts` | **EDIT** — `rankForBrief` excludes `effectiveStatus !== "new"`; `renderTopEntry`/`renderBulletEntry` append `· \`\`` + `surfacedToken` (`· sett Nx` when count≥2); `surfacedIds(ranking)`; `ranking:` descriptor gains `; excludes acted/skipped` | SC5, SC6 | | `scripts/trends/src/cli.ts` | **EDIT** — `act`/`skip`/`reset --id` (setStatus, not-found→exit 2); `brief` **hoists `const store = loadStore(storePath)`** + records surfacing via `markSurfaced`+`saveStore` unless `--no-mark`, `--json` gains `marked`; exit-code doc-comment (`:33`) + capture tally comment (`:251-252`) broadened; usage + header doc | SC7, SC8 | | `scripts/trends/tests/store.test.ts` | **EDIT** — effectiveStatus; setStatus (found/absent/reset); markSurfaced (increment/idempotent/skip); re-score (last-wins/no-false-merge/status+provenance untouched/no-score-noop); **NEW `(RE-R3b / lifecycle v3→v4)` migration block** + **reconcile the existing `(RE-R3a / score v2→v3)` block's `3` literals** | SC1–SC4 | | `scripts/trends/tests/brief.test.ts` | **EDIT** — exclude acted/skipped (all buckets; totals.trends full; only-handled→empty); render full lines (`· \`\``; `· sett Nx` at count≥2 / none below); `surfacedIds`; exact `ranking:` string; determinism | SC5, SC6 | | `scripts/trends/tests/cli.test.ts` | **EDIT** — act/skip/reset (read back via `list --json`; unknown id→exit 2; missing --id→exit 2); brief marks surfaced (`surfacedCount:1`+`lastSurfacedAt`, `marked` in json); second-same-day idempotent (`marked:0`); `--no-mark` (no write); brief md omits acted/skipped | SC7, SC8 | | `scripts/trends/src/item.ts` | **UNTOUCHED** — re-score reuses the existing `itemToInput`→`scoreEnvelope` bridge (R3a). Listed to assert it is *not* in scope. | — | | `scripts/trends/src/score.ts` | **UNTOUCHED** — no scoring-math change. Listed to assert it is *not* in scope. | — | | `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: re-capture refreshes the score (timing decays); operator marks `acted`/`skipped` via the CLI so the brief stops re-surfacing handled work. No batch-shape change (score already emitted, R3a). Domain-general. | — | | `scripts/trends/README.md` | **EDIT** — status lifecycle + verbs, seen-log (per-day idempotent, brief-recorded), re-score last-wins, brief exclude-handled + `--no-mark` | — | | `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 146→recount + breakdown comment (`:705`); NEW unconditional **Section 16k** (after 16j's block `~:1235` / before Section 18 header `:1237`); `ASSERT_BASELINE_FLOOR` 99→**105**; header-enumeration chain | SC9 | | `docs/research-engine/{brief,plan}-re-r3b.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | | `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 105, schema v4, gate total). *Land bookkeeping, LOCAL-ONLY.* | — | **Not touched (scope fence):** `references/trend-scoring-modes.md` (no scoring-math change) · `score.ts` + `item.ts` (re-score reuses the R3a capture path) · the SessionStart hook + its tests (no hook change; no frontmatter-schema change; `BRIEF_SCHEMA_VERSION` stays 1; no new hook test) · `queryByTopic`/`history`/`newestCaptureDate` (store query unchanged) · the CLI `status` staleness reader (unrelated to the new `TrendStatus` type — the name collision is pre-existing, not reconciled here) · `config/*` · no new `.ts`/`.mjs` file · `agents/*` count (19) · `commands/*` (29) · `references/*` (27) · `.gitignore` (trends lines present) · the two inert `schemaVersion: 2` fixture literals — `brief.test.ts:53` (`rankForBrief` ignores `schemaVersion`) AND `cli.test.ts:247` (the seed store is re-stamped to current on capture/brief load; no test reads its on-disk version — `cli.test.ts:102` uses the `SCHEMA_VERSION` constant) — both **left as-is** (folded — brief-reviewer #3: enumerated so "miss none" is literally true). ## Step 1 — (RED, two phases) failing tests across store/brief/cli Same RED discipline as R3a (two-phase, light-Voyage BLOCKER fold): a missing **named** import throws at module-load under Node16 ESM, so the `store.test` cases that reference the new `setStatus`/`markSurfaced`/ `effectiveStatus` exports cannot be assertion-RED before stubs exist. Split: **Phase A — true logic-RED against the pre-edit code** (uses existing `addTrend`/`loadStore`/`saveStore`/ `rankForBrief`/`renderBrief`; `TrendStore`/`TrendRecord` are `import type`, erased by tsx; subprocess for cli): - `store.test.ts` — **re-score** (uses existing `addTrend`): `addTrend(store, dupSameTitleUrlDifferentScore)` → stored `score` **replaced**, `merged:true`, `added:false`, topics unioned, `source`/`capturedAt`/`publishedAt` unchanged; a **byte-identical** re-score (no new topics) → `merged:false`; a duplicate with **no** `score` → stored score unchanged; re-capture of a record with `status:"acted"` (constructed inline) → score updated, `status` still `"acted"`, `surfacedCount` untouched. *(These fail against pre-edit `addTrend`, which never touches `score` on a duplicate.)* - `store.test.ts` — **NEW migration block `(RE-R3b / lifecycle v3→v4)`** (uses existing `loadStore`/`saveStore`, **hard-coded `4`** so it is RED while `SCHEMA_VERSION` is still 3 — the genuine-RED device; the target+idempotent assertions are switched to `SCHEMA_VERSION` at GREEN, Step 3, per brief-reviewer #4): a `schemaVersion:3` store with score-bearing, lifecycle-field-less records → `loadStore` gives `schemaVersion:4`, records intact, `"status" in record === false` (+ `surfacedCount`/`lastSurfacedAt` absent — none invented); round-trip writes `schemaVersion:4`; a `schemaVersion:4` store with lifecycle fields loads **idempotent** (stays 4, fields deep-equal); the three new fields **survive load→save→load**. - `brief.test.ts` — **exclude** (uses existing `rankForBrief`): a store with `new` + `acted` + `skipped` matches all fresh+on-pillar → only the `new` records appear in any bucket; `totals.trends` equals the **full** count (incl. handled); a store whose only matches are `acted`/`skipped` → all buckets empty + the "no fresh signals" `briefSummary`. **render** (uses existing `renderBrief`): a top entry's meta line ends `· \`\`` (full line); a bullet ends `· \`\`` (full line); a record with `surfacedCount:3` shows `· sett 3x` (full line), one with `surfacedCount:1`/absent shows **no** surfaced token (full line); `surfacedIds(ranking)` (new export — see Phase B note) … **[moved to Phase B]**; the `ranking:` line equals the pinned string ending `; excludes acted/skipped`; two `renderBrief` calls byte-identical. - `cli.test.ts` — **act/skip/reset** (subprocess, `--store` temp): `act --id ` then `list --json` → record `status:"acted"`; `skip` → `"skipped"`; `reset` → `"new"`; an **unknown** id → **exit 2** + store unchanged; a missing `--id` → exit 2. **brief-marks-surfaced**: `brief --store ` on fresh matches → `list --json` shows the surfaced trends `surfacedCount:1` + today's `lastSurfacedAt`, the `brief --json` carries `marked:`; a **second** same-day `brief` → `marked:0`, counts unchanged; `brief --no-mark` → `marked:0`, no `surfacedCount` written; the brief `.md` **omits** an `acted` record. **re-score tally** (folded — plan-critic #4): `capture` a scored item, then `capture` the same title+url with a **changed** score → the second `capture --json` reports `merged:1` and `list --json` shows the **updated** composite. *(Fail today: `act`/`skip`/`reset` are unknown commands → `usage` exit 2 but no status set; `brief` does not write `surfacedCount` / emit `marked`; a re-capture with a changed score is a plain `duplicate` (score discarded), not `merged`.)* **Phase B — stub-first, then value-assertion RED** (`store.test`/`brief.test` reference new exports): - Land **non-throwing stubs** so the imports resolve: in `store.ts` — `effectiveStatus → "new"` (constant), `setStatus → { store, found:false }`, `markSurfaced → { store, marked:0 }`; in `brief.ts` — `surfacedIds → []`. (Wrong-value stubs Step 2/4 replace.) - `store.test.ts`: `effectiveStatus({…status:"acted"})` is `"acted"` (stub returns `"new"` → RED); `setStatus(store, presentId, "skipped")` → `{ found:true }` and the record's status set (stub `found:false` → RED); `markSurfaced(store,[idA],today)` increments + sets `lastSurfacedAt`, `marked:1`, and is per-day idempotent on a re-call (stub `marked:0`, no mutation → RED). - `brief.test.ts`: `surfacedIds(ranking)` deep-equals the ids of `topMatches ∪ singleMatches ∪ olderMatched.slice(0,5)` (stub `[]` → RED). **RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit → the re-score/migration/brief/cli new cases fail on **assertion** (logic-RED), not module-not-found. Phase B — after the non-throwing stubs land, the `effectiveStatus`/`setStatus`/`markSurfaced`/`surfacedIds` cases fail on **value assertion** against the stubs. The plan does **not** claim a single "everything fails before any code" run. ## Step 2 — (GREEN) `types.ts` lifecycle type+fields, then `store.ts` functions + re-score **(Reordered — folded plan-critic #5: the type must exist before `store.ts` uses it.)** First, in `scripts/trends/src/types.ts` — add **`export type TrendStatus = "new" | "acted" | "skipped";`** and the three optional `TrendRecord` fields (`status?: TrendStatus`, `surfacedCount?: number`, `lastSurfacedAt?: string`) with doc-comments marking them the realized lifecycle fields the `:22` note anticipated. **Do NOT bump `SCHEMA_VERSION` here** — the bump lands in Step 3, atomic with the R3a-block reconcile (so the suite is never bumped-but-unreconciled). Then in `scripts/trends/src/store.ts` (replace the Phase-B stubs): - `import type { TrendStatus } from "./types.js";` (type-only — no cycle; the DAG stays `score ← types ← store`, acyclic). - `export function effectiveStatus(t: TrendRecord): TrendStatus { return t.status ?? "new"; }`. - `export function setStatus(store, id, status): { 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 };`. Sets `"new"` explicitly on a `reset`. - `export function markSurfaced(store, 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; t.surfacedCount = (t.surfacedCount ?? 0) + 1; t.lastSurfacedAt = today; marked++; } return { store, marked };`. Per-day idempotent. - **Re-score in `addTrend`'s duplicate branch** (`:127-131`): after `existing.topics = topics;` compute `let changed = topicsChanged;` (rename the `unionTopics` result) and add: `if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) { existing.score = input.score; changed = true; }`; `return { store, added: false, merged: changed };`. Broaden the `AddResult.merged` doc-comment to *"the existing record was mutated — topics unioned and/or score refreshed."* The new-record branch is unchanged. Make the Phase-A re-score + Phase-B lifecycle `store.test` cases green. ## Step 3 — (GREEN) `SCHEMA_VERSION` 3→4 bump — ATOMIC with the R3a-block reconcile The bump + every test it touches land in **one** step (verified-correct by plan-critic #2): the suite is never bumped-but-unreconciled. *(At RED, Step 1, only the new R3b block was failing — hard-coded `4` while SCHEMA_VERSION was still 3; the existing R3a block was green at `3===3`. This step flips SCHEMA_VERSION to 4, which would regress the R3a block's `3` literals UNLESS reconciled here — hence atomic.)* - `types.ts`: **`SCHEMA_VERSION = 4`**. Extend the `store.ts` `loadStore` migrate comment to *"v1→v2→v3→v4 all additive-optional … the migration is the version stamp alone"* (no logic change — `Math.max(onDisk, SCHEMA_VERSION)` already stamps v4; `saveStore` preserves the new fields). - **Reconcile the existing `(RE-R3a / score v2→v3)` block** (`store.test.ts:558-650`) — the bump to 4 regresses its hard-coded `3` literals (a v3 store now migrates to **4**, so "v3 idempotent" is no longer true). The brief-reviewer's full enumeration confirmed **exactly three** breaking assertions (`:588`/`:602`/`:628`); apply the minimal, intent-preserving fix (align to the R2a block's `SCHEMA_VERSION` discipline, `:493`): - `:588` `assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3")` → `SCHEMA_VERSION` + message "must migrate to the current version"; **flip the test title at `:571`** ("…loads stamped as **v3**" → "…to the current version") and the **stale `:570` comment** ("…≠ 3"). - `:602` `assert.equal(onDisk.schemaVersion, 3)` → `SCHEMA_VERSION`; **flip the test title at `:598`** ("…writes **schemaVersion:3** to disk" → "…writes the current schemaVersion", mirroring the reconciled R2a twin `:502`). - `:606` test **"a v3 store … loads idempotent"** → **retitle** "a v3 store migrates to the current version, score preserved" and change `:628` `assert.equal(s.schemaVersion, 3)` → `SCHEMA_VERSION` (the `assert.deepEqual(score, …)` stays — score survives the migration). *(The v4-**idempotent** guarantee now lives in the new R3b block.)* - `:638` test "a v3 store's score survives load→save→load" → **verified** it asserts only the `score` field (`:663`, no version literal) — **no change**. - **The new `(RE-R3b / lifecycle v3→v4)` block** (landed RED in Step 1 with hard-coded `4`): switch its **target + idempotent** version assertions from hard-`4` to **`SCHEMA_VERSION`** (folded — brief-reviewer #4: now == 4 and **future-proof**, so R3c's v4→v5 bump won't have to reconcile this block — breaking the cycle R3b pays for R3a). The v3 **input** fixtures stay literal `3` (they are old-version inputs). *(The hard-`4` was only the genuine-RED device for the Step-1 run; record that RED proof in the commit log.)* - Make the new `(RE-R3b / lifecycle v3→v4)` block + the reconciled R3a block both green; the full suite green after the bump (SC4). ## Step 4 — (GREEN) `brief.ts` (exclude handled + id/marker render + surfacedIds) - `import { effectiveStatus } from "./store.js";` (brief.ts already imports `defaultStorePath` from there — one-way). - `rankForBrief` entry loop (`:82-92`): add `if (effectiveStatus(trend) !== "new") continue;` immediately before the `if (overlap === 0) continue;` (`:89`). `totals.trends` stays `store.trends.length` (`:116`). Buckets + the composite total order (R3a) unchanged. - `surfacedToken(e)` helper (after `scoreToken`, `:141-145`): `const c = e.trend.surfacedCount; return c && c >= 2 ? ` · sett ${c}x` : "";`. **Prior-day semantic (folded — plan-critic #3):** the CLI records today's surfacing **after** `renderBrief`, so `surfacedCount` here is the **prior-day** count — `· sett Nx` = "shown on N prior distinct days" (today's appearance is recorded but not yet in this render). Document it in the README; `brief.test` asserts the token by setting `surfacedCount` directly, the cross-day behaviour by §8. - `renderTopEntry` (`:147-156`) meta line (`:150`): append `${surfacedToken(e)}` after `${scoreToken(e)}` and ` · \`${e.trend.id}\`` at the very end (after `Pillarer: …`). - `renderBulletEntry` (`:158-160`): append `${surfacedToken(e)}` after `${scoreToken(e)}` and ` · \`${e.trend.id}\`` at the very end (after `🔗 ${e.trend.url}`). - `export function surfacedIds(ranking: BriefRanking): string[]` — `return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id);` (mirrors the render's `:199` `.slice(0, 5)`). Replace the Phase-B stub. - `renderBrief`'s `ranking:` line (`:175`) → append `; excludes acted/skipped` to the pinned string. `briefSummary` + `BRIEF_SCHEMA_VERSION` unchanged. Make `brief.test` green. ## Step 5 — (GREEN) `cli.ts` (act/skip/reset + brief marks surfaced + --no-mark) - Import `setStatus`, `markSurfaced` from `./store.js`; `surfacedIds` from `./brief.js`. - A shared `setStatusCmd(status: TrendStatus)` inline helper (or three branches): read `flags.id`; if missing/`"true"` → `usage(' needs --id ')`; `const store = loadStore(storePath); const res = setStatus(store, flags.id, status); if (!res.found) { console.error(`error: no trend with id: ${flags.id}`); process.exit(2); } saveStore(storePath, store); console.log(`Marked ${flags.id} ${status}`);`. Wire `command === "act"` → `"acted"`, `"skip"` → `"skipped"`, `"reset"` → `"new"`. - **Broaden the exit-code doc-comment** (folded — plan-critic #2): `cli.ts:33` *"2 on usage error (incl. unparseable stdin / bad flag)"* → *"2 on usage error or a not-found id (act/skip/reset)"*. A wrong `--id` value is an argument-class error (exit 2), distinct from `capture`'s data items (`cli.ts:31` — `errors[]`, never the exit code). No third code introduced. - `brief` branch (`:274-298`): **hoist the load** (folded — plan-critic #1 / brief-reviewer #1): `cli.ts:286` is `const ranking = rankForBrief(loadStore(storePath), …)` — there is **no `store` variable** (verified). Replace with `const store = loadStore(storePath); const ranking = rankForBrief(store, pillars, day, { freshDays });`. Then after `writeFileSync(path, md, "utf8")` (`:290`), add: `const mark = flags["no-mark"] !== "true"; const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0; if (mark) saveStore(storePath, store);` — the **hoisted `store`** holds the full inventory, so acted/skipped records (filtered from the ranking, still in the store) are preserved on resave; the `.md` is rendered from the pure `ranking` **before** the mutation. Add `marked` to the `--json` object (`:293`) and the human summary line. - **Update the `capture` tally comment** (folded — plan-critic #4): `cli.ts:251-252` *"`merged` (existing gained topics)"* → *"`merged` (existing gained topics and/or a refreshed score)"* (no tally-logic change). - `usage()` (`:82-91`) + header synopsis (`:5-13`): add `act`/`skip`/`reset --id ` and `[--no-mark]`; a one-line header note (lifecycle verbs set status; the brief excludes handled trends + records surfacing; re-capture refreshes the score). Make `cli.test` green (incl. the re-captured-changed-score → `merged:1` tally assertion, plan-critic #4). ## Step 6 — wire `trend-spotter.md` (prose) + README In `agents/trend-spotter.md`: add **one prose line** (no batch-shape change — the per-item `score` is already emitted, R3a): re-capturing a known trend now **refreshes** its relevance score (timing decays), and the operator marks trends `acted`/`skipped` via the CLI (`act`/`skip --id`) so the morning brief stops re-surfacing handled work. Domain-general (Section 17). Update `scripts/trends/README.md`: the status lifecycle (`new`/`acted`/`skipped` + `act`/`skip`/`reset`), the seen-log (`surfacedCount`/`lastSurfacedAt`, per-day idempotent, recorded by `brief`), re-score-on-recapture (last-wins), and the brief's exclude-handled + `--no-mark` behaviour. ## Step 7 — gate: floors + new unconditional Section 16k In `scripts/test-runner.sh`: - Set `TRENDS_TESTS_FLOOR` (`:705`, currently 146) to the **`tests N` line** reported by `(cd scripts/trends && npm test)` after Steps 1–6 — recounted live, NOT additive-guessed. Stays **inside** the deps guard. **Append** `+ RE-R3b: lifecycle +N` to the inline breakdown comment. - Add **Section 16k** ("Trends Lifecycle Wiring", RE-R3b), mirroring Section 16j (header `:1177`, block runs through `~:1235`). **Placement (verified live — scope-guardian / brief-reviewer):** 16j is the last section before Section 18 (anti-erosion, header `:1237`) — insert 16k **after 16j's block (`~:1235`), before Section 18 (`:1237`)** (anti-erosion must stay last so it counts every prior check). Six **unconditional**, deps-absent-safe checks (pure `grep -qF`/self-test, no `tsx`), the self-test emitting **one** pass/fail like 16j: (1) a non-vacuity self-test (a probe carrying `effectiveStatus` accepted, one without rejected); (2) `grep -qF 'export type TrendStatus' scripts/trends/src/types.ts`; (3) `grep -qF 'surfacedCount' scripts/trends/src/types.ts` (the seen-log field); (4) `grep -qF 'export function markSurfaced' scripts/trends/src/store.ts` (the seen-log writer); (5) `grep -qF 'effectiveStatus' scripts/trends/src/brief.ts` (the brief excludes handled); (6) `grep -qF 'command === "act"' scripts/trends/src/cli.ts` (the lifecycle verb). - Bump `ASSERT_BASELINE_FLOOR` (`:1259`, currently 99) → **exactly 105** (99 + the 6 new unconditional 16k emitters; the self-test emits one pass/fail like 16j, so 105 is deterministic — "live recount" is the safety net, not a guess). Update the **header-enumeration prose chain** (`:49-53`) by inserting the 16k clause **between** the 16j clause and the Section-18 clause, preserving sentence flow. - **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3b adds no hook test). It must still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land. ## Step 8 — behavioural verification `(cd scripts/trends && npm install)` if needed, then run brief §7's seven behavioural steps (capture A; re-capture A with a lower timing → `list --json` shows the composite dropped + `merged:1`; `brief --json` → `marked:1`, `list` shows `surfacedCount:1`+`lastSurfacedAt`, entry line shows `· \`\``; re-run brief → `marked:0`; `act --id ` → A absent from the brief md; `reset --id ` → A reappears; `--no-mark` → no surfacedCount written). Run full `bash scripts/test-runner.sh` → `FAIL=0` (`ASSERT_BASELINE_FLOOR` 105); run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched regression). ## Step 9 — land Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 105, schema v4, gate total). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3b.md` (no suffix, tracked); **(2)** code commit — the three `src/*.ts` (`types`/`store`/`brief`/`cli` — four) + three test files + `agents/trend-spotter.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]` (D9: single code commit — the lifecycle is tightly coupled). Push freely (window lifted; gitleaks at commit; `origin` = PUBLIC `open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev). ## Verification (testable) | SC | Check | Command | Expected | |---|---|---|---| | — | RED Phase A | `(cd scripts/trends && npm test)` before src edits | re-score/migration/brief/cli new cases fail on assertion (logic-RED), not module-not-found | | — | RED Phase B | `npm test` after non-throwing stubs | effectiveStatus/setStatus/markSurfaced/surfacedIds fail on value assertion against stubs | | SC1 | status + effectiveStatus + setStatus | `npm test` (store.test) | effectiveStatus absent⇒new; setStatus found/absent; reset⇒new | | SC2 | re-score last-wins | `npm test` (store.test) | dup w/ diff score → replaced, merged:true, provenance+status+surfaced untouched; identical→merged:false; no-score→unchanged; acted stays acted | | SC3 | markSurfaced + idempotency | `npm test` (store.test) | increments + sets lastSurfacedAt; same-day re-call marked:0; later day increments; unknown id skipped | | SC4 | migration v3→v4 | `npm test` (store.test) | v3 loads as v4, intact, no field invented; round-trip writes v4; v4 idempotent; new fields survive; **R3a block reconciled (no regression)** | | SC5 | brief excludes handled | `npm test` (brief.test) | only `new` in buckets; totals.trends full; only-handled→empty + no-fresh summary; surviving order = R3a total order | | SC6 | render id + marker + descriptor | `npm test` (brief.test) | full lines `· \`\``; `· sett Nx` at count≥2 / none below; surfacedIds set; exact `ranking:` ending `; excludes acted/skipped`; deterministic | | SC7 | CLI act/skip/reset | `npm test` (cli.test) + manual | act→acted, skip→skipped, reset→new (via list --json); unknown id→exit 2; missing --id→exit 2 | | SC8 | CLI brief marks + --no-mark + exclude | `npm test` (cli.test) + manual | surfacedCount:1 + lastSurfacedAt + marked in json; second same-day marked:0; --no-mark no write; md omits acted/skipped | | SC9 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16k green; ASSERT_BASELINE_FLOOR=105; Section 17; counts 27/19/29; hook suite green | ## Risks - **R1 — the v3→v4 bump silently regresses the R3a migration block** (its hard-coded `3` literals; "v3 idempotent" is false after the bump). *Mitigated:* Step 3 **explicitly reconciles** the `(RE-R3a / score v2→v3)` block (flip `3`→`SCHEMA_VERSION`, retitle the idempotent test to a forward-migration test); SC4 asserts the full suite green after the bump. This is the load-bearing migration subtlety — caught by premise verification before drafting, not after. - **R2 — re-score corrupts provenance / status / the seen-log** (over-broad mutation in `addTrend`). *Mitigated:* the duplicate branch touches **only** `topics` + `score`; `source`/`capturedAt`/`publishedAt`/`status`/ `surfacedCount`/`lastSurfacedAt` are untouched; SC2 asserts each is preserved (incl. an acted-trend re-capture keeping `status:"acted"`). - **R3 — false-merge inflation** (a re-capture with an identical score reported as `merged`). *Mitigated:* the `JSON.stringify(existing.score) !== JSON.stringify(input.score)` guard (the envelope is built in a fixed key order by `scoreEnvelope`, so the compare is stable); SC2 asserts an identical re-score → `merged:false`. - **R4 — non-idempotent surfacing** (re-running today's brief double-counts; the autonomous loop (c) would inflate `surfacedCount`). *Mitigated:* `markSurfaced` skips records whose `lastSurfacedAt === today`; SC3 + SC8 assert a same-day re-call → `marked:0`, counts unchanged. - **R5 — the brief CLI's new store write loses data** (acted/skipped records dropped on resave, or the brief written from a mutated ranking). *Mitigated:* `markSurfaced` mutates only the `surfacedCount`/`lastSurfacedAt` of the **surfaced** ids on the **already-loaded full store**; `saveStore` writes the whole store (handled records preserved); the `.md` is rendered from the pure `ranking` **before** the mutation; SC8 asserts the md omits acted/skipped AND the store still contains them with surfacing recorded. - **R6 — `rankForBrief` loses purity** (the status filter or surfacing leaking fs/mutation into the pure render). *Mitigated:* the filter is a pure read of `effectiveStatus`; the seen-log **write** is only in the `brief` CLI edge, guarded by `--no-mark`; SC5/SC6 assert deterministic, byte-identical render; `markSurfaced`/`setStatus`/ `surfacedIds`/`effectiveStatus` are all pure (no fs). - **R7 — losing the brief's determinism** (the id/marker tokens or the status filter perturbing the total order). *Mitigated:* the id + `surfacedToken` are deterministic reads; the filter only removes records, preserving the R3a composite total order on the survivors; SC5/SC6 assert byte-identical output. - **R8 — editing `trend-spotter.md` trips the de-niche guard.** *Mitigated:* Section 17 runs in the gate; the added prose is generic lifecycle wording (`acted`/`skipped`/"refresh the score"), vendor/sector-free. - **R9 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16k is pure `grep`/self-test on tracked source (no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps guard. - **R10 — import cycle.** *Mitigated:* the new edges are one-way: `store.ts ← types.ts` (`TrendStatus` type into `store`, via `types`), `brief.ts → store.ts` (`effectiveStatus` value — brief already imports store), `cli.ts → {store,brief}` (already). `score.ts`/`item.ts` are untouched. The DAG stays acyclic: `score (leaf) ← types ← store ← brief ← cli`. - **R11 — the `status` name collision** (the CLI `status` staleness subcommand vs the new `TrendStatus` lifecycle). *Accepted:* the subcommand reads store staleness (newest capture), the type is the per-record lifecycle — no shared code; the collision is pre-existing and cosmetic; not reconciled here (documented non-goal). ## Plan-critic — folded Three Opus reviewers ran, each verifying claims against live code: **scope-guardian ALIGNED** (0 creep, 0 gaps; counts 27/19/29 + `score.ts`/`item.ts`-untouched + A1/A2/A3-consistency verified live; 2 MINOR plan line-cites); **brief-reviewer PROCEED_WITH_RISKS** (all 9 load-bearing claims TRUE; 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). Resolution, each verified against live code: - **[MAJOR — plan-critic #1 / MEDIUM — brief-reviewer #1] the `brief` CLI's `store` binding does not exist.** `cli.ts:286` inlines `rankForBrief(loadStore(storePath), …)` — no `const store`, so the `markSurfaced(store, …)` / `saveStore(storePath, store)` edit referenced an undefined identifier (would not compile). ✅ Step 5 now **hoists** `const store = loadStore(storePath); const ranking = rankForBrief(store, …);`; the brief §3 S-cli + A1 wording + R5 corrected to the hoisted binding. - **[MINOR — plan-critic #2] not-found id → exit 2 contradicted the documented exit-code contract** (`cli.ts:33` "2 on usage error"; `cli.ts:31` data-conditions never via exit code). ✅ Step 5 **broadens the `:33` doc-comment** to "2 on usage error or a not-found id (act/skip/reset)" (a wrong `--id` is an argument-class error); no third code introduced. - **[MINOR — plan-critic #3] `· sett Nx` off-by-one** (render precedes the surfacing mutation → the token reflects the prior-day count). ✅ the **prior-day semantic** is now explicit (Step 4 + brief §3 + README): `· sett Nx` = "shown on N prior distinct days"; the `brief.test` sets `surfacedCount` directly, §8 exercises the cross-day path. - **[MINOR — plan-critic #4] `capture` tally comment stale + the re-score CLI tally untested.** ✅ Step 5 updates the `cli.ts:251-252` comment (`merged` = topics ∪ score-refresh); Step 1 + SC2 add a subprocess assertion that a re-captured changed-score item reports `merged:1` with the updated composite. - **[MINOR — plan-critic #5] Step 2 used `TrendStatus` before Step 3 defined it.** ✅ **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 reconcile. - **[MINOR — plan-critic #6 / brief-reviewer LOW] stale R3a test titles + comment** (`store.test.ts:571`/`:598` titles, `:570` comment still say "v3" after the bump). ✅ Step 3 flips both titles to "the current version" + refreshes the comment, alongside the `:588`/`:602`/`:628` assertion flips. - **[LOW — brief-reviewer #4] forward-debt: the new R3b migration block hard-coded `4`** (perpetuating the reconcile-cycle). ✅ Step 3 commits the new block's **target + idempotent** assertions against `SCHEMA_VERSION` (future-proof; the hard-`4` is the Step-1 RED device only); the v3 **input** fixtures stay literal `3`. - **[LOW — brief-reviewer #3] `cli.test.ts:247` inert fixture not enumerated.** ✅ added to the scope-fence enumeration alongside `brief.test.ts:53` ("miss none" now literally true). - **[MINOR — scope-guardian] two plan line-cites** for the gate placement (`~:1191`/`:1262`). ✅ corrected to `~:1235` (after 16j's block) / `:1237` (Section 18 header) in Step 7 + the Files-touched table. **scope-guardian — ALIGNED:** every SC1–SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held (no saturation scoring, no scheduler, no brief-diff, no A1–A4, no status/surfaced input on capture, no act/skip-by-title, no auto-act-on-publish, no new source/agent/command, `BRIEF_SCHEMA_VERSION` unchanged); counts 27/19/29 verified live; `score.ts`/`item.ts` untouched verified (`itemToInput` already builds the envelope on every capture incl. re-capture); the R3a-block reconcile is a necessary prerequisite the bump forces, not creep. **[plan-critic headless-readiness] N/A** — R3b executes **in-session, operator-driven** (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a/R2b/R3a had none either).