linkedin-studio/docs/research-engine/plan-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

384 lines
36 KiB
Markdown
Raw Permalink 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.

# 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 D1D9 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 `· \`<id>\`` + `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** | SC1SC4 |
| `scripts/trends/tests/brief.test.ts` | **EDIT** — exclude acted/skipped (all buckets; totals.trends full; only-handled→empty); render full lines (`· \`<id>\``; `· 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 `· \`<id>\`` (full line);
a bullet ends `· \`<id>\`` (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 <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 <tmp>` on fresh matches → `list --json` shows
the surfaced trends `surfacedCount:1` + today's `lastSurfacedAt`, the `brief --json` carries `marked:<n>`; 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('<cmd> needs --id <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 <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 16 — 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 `· \`<id>\``; re-run brief → `marked:0`;
`act --id <A>` → A absent from the brief md; `reset --id <A>` → 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 `· \`<id>\``; `· 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 SC1SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held (no
saturation scoring, no scheduler, no brief-diff, no A1A4, 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).