From c40b9378565bed218d79c90e76c13b20a1e6d176 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 00:44:47 +0200 Subject: [PATCH] =?UTF-8?q?docs(linkedin-studio):=20RE-R3b=20brief=20+=20p?= =?UTF-8?q?lan=20=E2=80=94=20trend=20lifecycle=20(re-score=20=C2=B7=20stat?= =?UTF-8?q?us=20=C2=B7=20seen-log),=20light-Voyage=20hardened?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD --- docs/research-engine/brief-re-r3b.md | 416 +++++++++++++++++++++++++++ docs/research-engine/plan-re-r3b.md | 384 +++++++++++++++++++++++++ 2 files changed, 800 insertions(+) create mode 100644 docs/research-engine/brief-re-r3b.md create mode 100644 docs/research-engine/plan-re-r3b.md diff --git a/docs/research-engine/brief-re-r3b.md b/docs/research-engine/brief-re-r3b.md new file mode 100644 index 0000000..694ab03 --- /dev/null +++ b/docs/research-engine/brief-re-r3b.md @@ -0,0 +1,416 @@ +# 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 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 **` · \`\``** at the end (after `Pillarer: …`), + and `surfacedToken(e)` after the `scoreToken`: + `- Kilde: · Publisert: (d) · Pillarer: · \`\`` + - Bullet line (`renderBulletEntry`, `:159`): append **` · \`\``** at the end (after `🔗 `), with + `surfacedToken` after `scoreToken`: + `- **** — «<matched>» · <date> (<age>d)<scoreToken><surfacedToken> · 🔗 <url> · \`<id>\`` + - The id is rendered in backticks so it is copy-paste-ready for `act --id <id>` / `skip --id <id>`. +- **`export function surfacedIds(ranking: BriefRanking): string[]`** — the ids of the entries `renderBrief` + **actually shows**: `topMatches ∪ singleMatches ∪ olderMatched.slice(0, 5)` (mirrors the `:199` `.slice(0, 5)` + older cap), mapped to `e.trend.id`. The CLI feeds this to `markSurfaced` so the seen-log records exactly what + the operator saw. Pure. +- **`ranking:` frontmatter descriptor (`:175`)** → the **exact** string + `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N>; excludes + acted/skipped` (pinned verbatim; `brief.test` asserts byte-for-byte). The trailing `; excludes acted/skipped` + is the only descriptor change. +- `briefSummary` (`:129-139`) is **unchanged** (the headline still names the top fresh match's band + age; status + exclusion happens upstream in the ranking, so the summary already reflects only `new` trends). `BRIEF_SCHEMA_ + VERSION` stays **1** (no frontmatter *field* added/removed — `date`/`summary`/`store`/`ranking`/`schemaVersion` + unchanged; only the `ranking:` *string* and body content change; the surfacing hook still reads `date`+`summary`). + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) +- **`act` / `skip` / `reset` subcommands** (set lifecycle status by id): + - `act --id <id> [--store <path>]` → `setStatus(store, id, "acted")`; `skip …` → `"skipped"`; `reset …` → + `"new"`. Each: load → setStatus → if `found` save + print `Marked <id> <status>` (exit 0); if **not found** + print `error: no trend with id: <id>` to stderr + **exit 2**. A missing/`true` `--id` → `usage('<cmd> needs + --id <id>')` (exit 2). **Exit-code contract broadened (folded — plan-critic #2):** a not-found id is exit 2, + which the existing contract documents as "usage error". Update the header doc-comment (`cli.ts:33`) to read + *"0 on success, 2 on usage error or a not-found id (act/skip/reset)"* — a wrong `--id` value is an + argument-class error, distinct from `capture`'s data-stream items (which stay in `errors[]`, never the exit + code). A new exit code is **not** introduced (the CLI keeps its two codes). +- **`brief` records surfacing (A1):** **hoist the load** (folded — plan-critic #1 / brief-reviewer #1): replace + the inline `rankForBrief(loadStore(storePath), …)` (`cli.ts:286`) with **`const store = loadStore(storePath); + const ranking = rankForBrief(store, pillars, day, { freshDays });`** — `cli.ts:286` does **not** currently bind + a `store` variable (verified), so the surfacing edit needs this hoist or it references an undefined identifier. + Then after `writeFileSync(path, md, …)` (`:290`), **unless `--no-mark`**: `markSurfaced(store, surfacedIds + (ranking), day)` then `saveStore(storePath, store)` — the **hoisted `store`** holds the full inventory, so + acted/skipped records (filtered from the ranking but still in the store) are preserved on resave; the `.md` is + rendered from the pure `ranking` **before** the mutation. `const mark = flags["no-mark"] !== "true";` (a bare + `--no-mark` → `"true"` → mark off). The `--json` output gains a **`marked`** count (trends whose seen-log this + run incremented; `0` when `--no-mark`). `rankForBrief`/`renderBrief` are untouched — the mutation is purely at + the edge. +- **`capture` tally comment (`cli.ts:251-252`)** (folded — plan-critic #4): the broadened `AddResult.merged` + (topics ∪ score-refresh) makes the existing comment *"a fold is … `merged` (existing gained topics)"* stale → + update it to *"`merged` (existing gained topics and/or a refreshed score)"*. No tally-logic change (the loop + already counts `res.merged`). +- **Usage + header doc:** add the three new verbs + `[--no-mark]` to the `usage()` block (`:82-91`) and the + header synopsis (`:5-13`); a one-line header note that `act`/`skip`/`reset` set a trend's lifecycle status, the + brief excludes handled trends and records surfacing, and re-capture refreshes the score. + +### Wiring (D-default — WIRE, mirrors R3a) +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): Step 4.5 already emits the per-item `score` (R3a); + re-score is **automatic** (capture re-folds an existing trend with a fresh score → `addTrend` now refreshes it), + so **no batch-shape change**. Add one prose line: re-capturing a known trend now **refreshes** its relevance + score (timing decays), and the operator marks trends `acted`/`skipped` via the CLI so the brief stops + re-surfacing handled work. Domain-general (no vendor/sector tokens). *(If a Section-16k grep targets the agent, + it must be verified non-vacuous first; the recommended 16k greps target src files only — see gate below.)* +- `scripts/trends/README.md` (EDIT): document the status lifecycle (`new`/`acted`/`skipped` + `act`/`skip`/`reset`), + the seen-log (`surfacedCount`/`lastSurfacedAt`, per-day idempotent, brief-recorded), re-score-on-recapture + (last-wins), and the brief's exclude-handled behaviour + `--no-mark`. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:705`, currently 146) to the `tests N` line + reported after Steps 1–6, **append** `+ RE-R3b: lifecycle +N` to the inline breakdown comment. Add + **Section 16k** ("Trends Lifecycle Wiring", RE-R3b) **after Section 16j's closing block, before Section 18** + (16j is the last 16x before the anti-erosion Section 18; preserve that order). Mirror 16j's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). Recommended + **6 emitters** (all on tracked src — no `tsx`): (1) self-test; (2) `export type TrendStatus` in `types.ts`; + (3) `surfacedCount` in `types.ts` (seen-log field); (4) `export function markSurfaced` in `store.ts` (seen-log + writer); (5) `effectiveStatus` in `brief.ts` (the brief excludes handled); (6) `command === "act"` in `cli.ts` + (the lifecycle verb). **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 99 → exactly 105** (`:1259`; + "live recount" is the safety net; the expected value is the pinned 99 + 6 = 105). Update the header-enumeration + prose chain by inserting the 16k clause between the 16j clause and the Section-18 clause. + +## 4. Non-goals — what is OUT (deferred) + +- **Saturation scoring / first-mover-as-a-field** (the quantitative *use* of `surfacedCount`) — **slice (b)**. + R3b **accumulates** the seen-log and shows a minimal `· sett Nx` hint, but it does **not** compute a saturation + score, decay the composite by surfacings, or add a first-mover field. (b) reads R3b's accumulated data. +- **Autonomous nightly trigger** (cron/launchd, headless entry — hull 1+6) — **slice (c)**. R3b adds no scheduler; + it builds the lifecycle (c) will safely automate. +- **Brief history surfacing / diff** ("what's new since yesterday" — hull 7) — **slice (d)**. The seen-log records + *that* a trend was surfaced; the cross-brief **diff** is (d). R3b's `· sett Nx` is a per-record count, not a + day-over-day diff. +- **Research-deepening A1–A4** (plan → isolated workers → gap loop → curate) — **slice (e)**, behind the post-(d) + re-evaluation gate. +- **Mode-segmented ranking / `--mode` filter** — still OUT (R3a non-goal, unchanged). +- **Re-score semantics other than last-wins** (monotone / timing-only refresh) — OUT (A2 chose last-wins). +- **A `status`/`surfaced*` input on the capture/`add` path** — OUT. Lifecycle is set **post-capture** by + `act`/`skip`/`reset`; capture never ingests a status. `TrendInput` is unchanged. +- **`act`/`skip` by title/url** (deriving the id) — OUT for R3b; `--id` only (the id is shown in the brief + + `list --json`). A title/url alias is a later ergonomic nice-to-have. +- **Auto-acting on publish** (wiring `act` into `/linkedin:post` / the post-tracking flow) — OUT. R3b ships the + CLI verbs; auto-marking from the content commands is a separate plugin-surface slice. +- **`BRIEF_SCHEMA_VERSION` bump** — OUT (no frontmatter field changes); Open Q. +- **New source file / new agent / new command** — none. R3b is edits to **four** existing `src/*.ts` (`types`, + `store`, `brief`, `cli`) + their tests + one agent (prose) + README + gate. `score.ts` + `item.ts` are + **untouched** (re-score reuses the R3a capture path). Counts stay 27/19/29. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — true logic-RED for + the re-score + migration parts of `store.test` (existing `addTrend`/`loadStore`, inline fixtures), all of + `brief.test` (behaviour change to existing `rankForBrief`/`renderBrief`), and `cli.test` (subprocess: `act`/`skip` + print a usage/unknown-command error today → assertion-RED). Phase B — `setStatus`/`markSurfaced`/`effectiveStatus` + reference new `store.ts` exports → land non-throwing stubs first (Node16 ESM throws a missing named import at + module-load), then record value-assertion RED against the stubs. The plan does **not** claim a single + "everything fails before any code" run. +- **`rankForBrief` stays pure (A1):** no fs, no clock, no env, no AI, **no store mutation**. The status filter is + a pure read of `effectiveStatus`. The seen-log **write** lives only in the `brief` CLI edge (after the pure + ranking), guarded by `--no-mark`. `markSurfaced`/`setStatus`/`effectiveStatus`/`surfacedIds` are all pure. +- **One composite owner (unchanged):** re-score reuses the **already-built** capture path + (`itemToInput`→`scoreEnvelope`→`composite`+`band`); R3b adds **no new arithmetic** and does not touch `score.ts`. +- **Provenance discipline (A2):** `source`, `capturedAt`, and the first `publishedAt` stay **first-sight**; only + `score` is mutable on re-capture; `status`/`surfacedCount`/`lastSurfacedAt` are mutated only by their own + owners (`setStatus`/`markSurfaced`), never by `addTrend`. +- **Per-day-idempotent surfacing:** running `brief` twice on the same `today` increments `surfacedCount` **at most + once** (`markSurfaced` skips records whose `lastSurfacedAt === today`). Re-generating today's brief is a no-op on + the seen-log. *(This is the determinism guarantee for the autonomous loop: an idempotent daily mark.)* +- **No false-merge on re-capture:** a re-capture with a **byte-identical** score → `merged:false` (the + `JSON.stringify` compare); only a genuine topic-union or score-change flips `merged`. +- **Determinism (brief):** same `(store, pillars, today, freshDays)` → byte-identical `renderBrief` output (the + status filter + `surfacedToken` + id are deterministic reads of the store; the composite sort total order from + R3a holds). The CLI's surfacing mutation is **outside** the pure render. +- **Lossless additive migration (both directions):** a v3 store loads as v4 with records **untouched** (no + `status`/`surfaced*` invented); round-trip writes `schemaVersion: 4`; a v4 store is idempotent; the three new + optional fields **survive a load+resave**. Mirrors the R3a v2→v3 proof (`store.test.ts`, `(RE-R3a / score + v2→v3)` block) with the literals flipped `3`→`4`. +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and **never shells out to tsx**. + R3b touches neither the hook nor the frontmatter schema (`BRIEF_SCHEMA_VERSION` stays 1), so surfacing is + unchanged. The existing hook suite must still pass untouched (regression sanity; R3b adds no hook test). +- **Domain-general:** Section 17 de-niche stays green; the `trend-spotter.md` prose carries only generic + lifecycle wording (`acted`/`skipped`/"refresh the score"), no vendor/sector tokens. +- **No SSOT change:** `references/trend-scoring-modes.md` untouched (R3b changes no scoring math). +- **No store-query change:** `queryByTopic`/`history`/`newestCaptureDate` untouched. *(The CLI `status` + subcommand — the staleness reader — is unrelated to the new `TrendStatus` lifecycle type; the name collision is + pre-existing and not reconciled here.)* +- **Pathguard:** all edits are to **existing** files (no new `.mjs` under `hooks/scripts/`; no new `.ts` — R3b + adds no source file). `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (status field + effectiveStatus + setStatus)** — `effectiveStatus({…no status})` is `"new"`; + `effectiveStatus({…status:"acted"})` is `"acted"`. `setStatus(store, id, "skipped")` on a present id sets the + record's `status` and returns `{ found:true }`; on an absent id returns `{ found:false }` (no throw, store + unchanged); a `reset` sets `status:"new"` explicitly. +- **SC2 (re-score last-wins, no false-merge, status/provenance untouched)** — `addTrend(store, dupInput)` where + `dupInput` has the same title+url and a **different** `score` → the stored `score` is **replaced**, `merged:true`, + `added:false`, topics still unioned, and `source`/`capturedAt`/`publishedAt`/`status`/`surfacedCount` are + **unchanged**. A re-capture with a **byte-identical** score (and no new topics) → `merged:false`. A duplicate + with **no** `score` → stored score unchanged. A re-capture of an **acted** trend with a new score → score + updated, **status stays `acted`**. **At the CLI edge (folded — plan-critic #4):** a `capture` of a scored item, + then a `capture` of the same title+url with a **changed** score → the second `capture --json` reports + `merged:1`, and `list --json` shows the **updated** composite (a subprocess test, not only the manual §7 step). +- **SC3 (markSurfaced + per-day idempotency)** — `markSurfaced(store, [idA, idC], "2026-06-25")` increments + `surfacedCount` (absent⇒0→1) and sets `lastSurfacedAt:"2026-06-25"` on A and C only (B untouched), returns + `marked:2`; a second `markSurfaced` with the **same `today`** → `marked:0`, counts unchanged; a third with a + **later** `today` → increments again, `lastSurfacedAt` advances; an id not in the store is silently skipped. +- **SC4 (migration v3→v4, both directions)** — a `schemaVersion:3` store with records lacking the lifecycle + fields loads as **v4**, records intact, **no field invented**; round-trip `loadStore→saveStore` writes + `schemaVersion:4`; a v4 store with lifecycle fields loads idempotent; **the three new fields survive + load+resave** (byte-identical). Mirrors the R3a `(RE-R3a / score v2→v3)` block, retitled `(RE-R3b / lifecycle + v3→v4)`, every `schemaVersion` literal flipped `3`→`4`. +- **SC5 (brief excludes acted/skipped)** — given a store with `new`, `acted`, and `skipped` records all matching + pillars + fresh: `rankForBrief` places **only** the `new` ones in `topMatches`/`singleMatches`/`olderMatched`; + `totals.trends` still equals the **full** store count; a store whose only matches are `acted`/`skipped` → + empty buckets + the `briefSummary` "no fresh signals" line; the order among the surviving `new` records is the + R3a composite total order (unchanged). +- **SC6 (brief render: id + surfaced marker + descriptor)** — `renderBrief` emits the **full pinned line shapes** + (§3): a top entry ends with `· \`<id>\`` (after `Pillarer: …`); a bullet ends with `· \`<id>\`` (after `🔗 + <url>`); a record with `surfacedCount >= 2` shows `· sett <N>x` (after the score token), one with + `surfacedCount` 0/1/absent shows **no** surfaced token — both asserted as **full lines**. The `ranking:` + descriptor equals the pinned string ending `; excludes acted/skipped` verbatim. `surfacedIds(ranking)` returns + exactly the ids of `topMatches ∪ singleMatches ∪ olderMatched.slice(0,5)`. Two `renderBrief` calls on the same + input are byte-identical. +- **SC7 (CLI act/skip/reset)** — `act --id <id> --store <tmp>` then `list --store <tmp> --json` shows the record + with `status:"acted"`; `skip` → `"skipped"`; `reset` → `"new"`; an **unknown** id → stderr error + **exit 2**, + store unchanged; a missing `--id` → usage + exit 2. +- **SC8 (CLI brief marks surfaced + --no-mark + exclusion end-to-end)** — `brief --pillars … --store <tmp>` on a + store with fresh matches → the written `.md` **omits** any acted/skipped record; a following `list --store <tmp> + --json` shows the surfaced trends with `surfacedCount:1` + today's `lastSurfacedAt`, and the `--json` output + carries `marked:<n>`; a **second** `brief` the same day → `marked:0`, counts unchanged (idempotent); + `brief --no-mark --store <tmp>` on a fresh store → `marked:0`, **no `surfacedCount` written** (store's trends + unchanged save for nothing). +- **SC9 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the bumped + `TRENDS_TESTS_FLOOR`; new **Section 16k** green (`TrendStatus` + `surfacedCount` in `types.ts`, `markSurfaced` + in `store.ts`, `effectiveStatus` in `brief.ts`, `command === "act"` in `cli.ts`, non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **105** (99 + 6); Section 17 de-niche green; counts 27/19/29; the hook suite still + green untouched. + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16k self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 105; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3b touches no +hook; adds no hook test). +**Behavioural (manual):** +1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>", + "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]' + | node --import tsx src/cli.ts capture --store /tmp/r3b.json` — adds A. +2. Re-`capture` A with a **lower** timing (`"timing":3`) → `list --store /tmp/r3b.json --json` shows A's + composite **dropped** (re-score last-wins); the capture tally reports `merged:1`. +3. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3b.json --out /tmp/r3b-brief --json` → + confirm `marked:1`; `list --json` shows A with `surfacedCount:1` + today's `lastSurfacedAt`; the entry line + shows `· \`<id>\``. +4. Re-run the **same** `brief` → `marked:0` (idempotent); `surfacedCount` still 1. +5. `node --import tsx src/cli.ts act --id <A's id> --store /tmp/r3b.json` → re-run `brief` → A is **absent** from + the written `.md`; the summary reports no fresh signals (if A was the only match). +6. `node --import tsx src/cli.ts reset --id <A's id> --store /tmp/r3b.json` → A reappears in the brief. +7. `brief --no-mark` on a fresh store → `marked:0`, `surfacedCount` not written. + +## 8. Open questions for the go-gate + +Three architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-25): **A1** on-record seen-log, +the `brief` CLI records surfacing (`rankForBrief` pure, `--no-mark` dry-run); **A2** re-score last-wins (score the +one mutable field; status not reset); **A3** acted/skipped excluded from the brief. Residual decisions, all baked +to the recommended default — confirm or redirect with "Go": +- **D1 — status values `new`/`acted`/`skipped`, absent⇒new (omit on add)?** YES (rec). A 3-state lifecycle; a + freshly-captured trend is implicitly `new` (field omitted); `reset` sets `"new"` explicitly. Re-open only if a + `published`/`drafted` distinction is wanted (the plugin tracks posts elsewhere — kept out of the trend store). +- **D2 — `AddResult.merged` broadened (topics ∪ score-refresh), no new flag?** YES (rec). Keeps the 2-flag shape; + the capture tally's "N merged" honestly means "N existing records updated". Re-open only if `rescored` must be + counted **separately** from topic-merges in the CLI tally. +- **D3 — include `reset` (un-skip → new)?** YES (rec). Symmetric + cheap; the operator changes their mind. Drop + only to keep the verb set to two. +- **D4 — show the trend `id` in brief entries?** YES (rec). The status feature is **inoperable** otherwise — the + operator needs the id to `act`/`skip`. Shown in backticks for copy-paste. Alternative: omit, and require + `list --json` to find ids (clunky). +- **D5 — minimal `· sett Nx` marker when `surfacedCount >= 2`?** YES (rec). Keeps the seen-log **honest/visible** + in R3b (otherwise it is an invisible schema-only accumulation — the anti-pattern R3a warned of) without + straying into (b)'s saturation scoring or (d)'s diff. The `>= 2` floor means a first/second sighting is silent. + Drop only if any visible surfaced signal should wait for (b). +- **D6 — `act`/`skip` identify by `--id` only?** YES (rec). Store-native; the id is shown in the brief + + `list --json`. A title/url alias is a deferred nice-to-have. +- **D7 — which entries count as "surfaced"?** The entries `renderBrief` **actually shows**: `topMatches ∪ + singleMatches ∪ olderMatched.slice(0,5)` (rec). Matches what the operator saw; the older-bucket cap mirrors the + render's `.slice(0,5)`. +- **D8 — `BRIEF_SCHEMA_VERSION` 1→2?** NO (rec). No frontmatter field added/removed (the hook reads only + `date`+`summary`). Re-open only if the artifact should self-announce the exclude-handled change. +- **D9 — commit split?** Single code commit (rec) — R3b's lifecycle (re-score/status/seen-log) is tightly + coupled; the R3a data-then-visible split would land an invisible cut. Docs commit first, then one code commit. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(every SC1–SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held; counts 27/19/29 verified live; +`score.ts`/`item.ts`-untouched verified — `itemToInput` already builds the envelope on every capture incl. +re-capture; A1/A2/A3 consistent across every step; the R3a-block reconcile is a necessary prerequisite, not creep; +2 MINOR plan line-cite nits). **brief-reviewer: PROCEED_WITH_RISKS** (all nine load-bearing claims verified TRUE — +incl. the v3→v4 reconcile complete for **every** breaking literal, enumerated; 1 MEDIUM + 3 LOW). **plan-critic: +PROCEED_WITH_RISKS (78/B)** (the two-phase RED, the atomic bump+reconcile, the `merged` broadening's +non-regression, the `surfacedIds` formula, and the gate arithmetic all verified correct; 1 MAJOR + 5 MINOR). +All findings folded; see `plan-re-r3b.md §Plan-critic — folded` for per-finding resolution. Headlines: + +- **[MAJOR/MEDIUM, folded — both reviewers] the `brief` CLI's `store` binding does not exist.** `cli.ts:286` + inlines `rankForBrief(loadStore(storePath), …)` — there is no `const store`, so the `markSurfaced(store, …)` / + `saveStore(storePath, store)` edit referenced an undefined identifier. → §3 S-cli + plan Step 5 now **hoist** + `const store = loadStore(storePath)` and pass it to `rankForBrief`; R5 wording corrected. +- **[MINOR, folded — plan-critic #2] not-found id → exit 2 contradicted the documented exit-code contract.** → + the `cli.ts:33` doc-comment is **broadened** to *"2 on usage error or a not-found id (act/skip/reset)"* (a wrong + `--id` is an argument-class error, distinct from `capture`'s data items); no third exit code introduced (§3 S-cli). +- **[MINOR, folded — plan-critic #3] `· sett Nx` off-by-one.** Render precedes the surfacing mutation, so the + token reflects the **prior-day** count. → the **prior-day semantic** is now stated explicitly (§3 S-brief + the + README): `· sett Nx` = "shown on N prior distinct days". +- **[MINOR, folded — plan-critic #4] `capture` tally comment stale + the re-score CLI tally untested.** → §3 S-cli + updates the `cli.ts:251-252` comment (`merged` = topics ∪ score-refresh); SC2 adds a subprocess assertion that a + re-captured changed-score item reports `merged:1` with the updated composite. +- **[MINOR, folded — plan-critic #5] Step 2 used `TrendStatus` before Step 3 defined it.** → the plan is + reordered: Step 2 adds the `TrendStatus` type + the three fields to `types.ts` **first** (then the `store.ts` + functions); Step 3 isolates the atomic `SCHEMA_VERSION` bump + the R3a-block reconcile. +- **[LOW, folded — brief-reviewer #4] forward-debt: the new R3b migration block hard-coded `4`** (perpetuating the + reconcile-cycle this slice pays for R3a). → the new block's **target + idempotent** assertions commit against + `SCHEMA_VERSION` (the hard-`4` is the Step-1 RED device only; the v3 **input** fixtures stay literal `3`), + breaking the cycle so R3c won't pay it. +- **[LOW, folded] cosmetic literal/title drift** — `store.test.ts:571`/`:598` titles + `:570` comment flipped to + "the current version"; `cli.test.ts:247`'s inert `schemaVersion:2` fixture added to the scope-fence enumeration; + the two plan line-cites corrected to `~:1235` (after 16j's block) / `:1237` (Section 18 header). diff --git a/docs/research-engine/plan-re-r3b.md b/docs/research-engine/plan-re-r3b.md new file mode 100644 index 0000000..219c11b --- /dev/null +++ b/docs/research-engine/plan-re-r3b.md @@ -0,0 +1,384 @@ +# 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 `· \`<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** | SC1–SC4 | +| `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 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 `· \`<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 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).