linkedin-studio/docs/research-engine/plan-re-r3d.md
Kjell Tore Guttormsen 5aa7187243 docs(linkedin-studio): RE-R3d brief + plan — temporal overlay (first-mover + saturation), light-Voyage hardened
R3 slice (b): the rest of hull #3. Two derived brief-time signals — first-mover
(recent + unsurfaced -> ranked up) and saturation (surfaced >= N prior days ->
ranked down) — computed from already-persisted fields. SB1 derived (no schema
bump, SCHEMA_VERSION stays 4); SB2 R3a composite stays the primary sort key, the
overlay is a within-tier cmp refinement. Zero new source/test files; counts
29/19/27; ASSERT floor 111 -> 117.

Three Opus reviewers (scope-guardian / brief-reviewer / plan-critic) folded:
warming badge gated at >=2 (preserves the R3b contract), disagreement ordering
fixture (true RED), saturationAt clamp, ageDays>=0 guard, fresh->neutral rename,
SSOT one-line note, nightly-thresholds known limitation, cite fixes.

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

335 lines
30 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-R3d: temporal overlay — first-mover + saturation (R3 slice b)
> **Brief:** `docs/research-engine/brief-re-r3d.md`. **Slice:** RE-R3d (research-engine rung-2 — R3 slice **(b)**:
> the live temporal overlay). Closes the rest of hull #3 — **first-mover** + **saturation** (relevance ✅ R3a;
> status ✅ R3b; `angle` already a scored dimension). **Zero new files** — pure EDITs.
> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)**
> `temporalSignal` is a NEW named export of the EXISTING `brief.ts`; Node16 ESM throws a missing named import at
> module-load (every `brief.test.ts` test would error, not assert), so land a **non-throwing stub** first
> (`temporalSignal → {tier:"fresh",…}` + the type exports + `BriefEntry.temporal` populated by the stub in
> `rankForBrief`), then record value-assertion RED against it (the constant "fresh" stub fails the
> first-mover/saturated/ordering/token assertions); **(B)** the CLI flag tests are value-RED against the existing
> `brief` handler (the new flags are silently ignored by `parseFlags` today → tuned-threshold behaviour unchanged).
> Then GREEN: real `temporalSignal` + `RankOptions` knobs + `cmp` key → render tokens + summary marker + descriptor
> → `cli.ts` flags → wire `trend-spotter.md` (prose) + SSOT note + README → gate floors + Section 16m →
> behavioural → land.
> **Counts recounted live at land, never pinned/guessed.**
> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** SB1 derived-at-brief (no schema bump) · SB2
> refine recency within the composite tier (composite stays PRIMARY). Go-gate D1D10 baked to recommended defaults
> (brief §8).
> **Light-Voyage:** scope-guardian / brief-reviewer / plan-critic to run on these drafts; findings folded in
> §Plan-critic — folded before the code commit.
## Goal
Make the morning brief read the **temporal axis** R3b logs but the ranking ignores. Two derived signals, computed
**at brief time** from already-persisted fields (no new store field, no schema bump): **first-mover** (recent AND
never surfaced on a prior day — "you'd be early") ranks a trend up; **saturation** (surfaced on `>= saturationAt`
prior days — "you keep seeing this and not acting") ranks it down. The R3a relevance composite stays the PRIMARY
sort key (SB2); the overlay is a new `cmp` key **after** pillar-overlap, **before** `effectiveDate`, re-ordering
only WITHIN a (composite, overlap) tier. It also promotes the R3b `· sett Nx` display hint into a graded badge set
and surfaces a first-mover marker on the one-line summary. **No schema change** (`SCHEMA_VERSION` 4 /
`BRIEF_SCHEMA_VERSION` 1 untouched); **no new agent/command/reference/module/file**; `score.ts`/`store.ts`/
`types.ts`/`item.ts`/`schedule.ts`/`run-daily.sh` untouched.
## Files touched (exhaustive — for scope-guardian)
| File | Change | SC |
|---|---|---|
| `scripts/trends/src/brief.ts` | **EDIT** — add `TemporalTier`/`TemporalSignal` exports + pure `temporalSignal(ageDays, surfacedCount, {firstMoverDays, saturationAt})`; `RankOptions` gains `firstMoverDays?`(def 2)/`saturationAt?`(def 3); `BriefEntry` gains `temporal`; `rankForBrief` threads the opts + populates `temporal`; `cmp` gains `b.temporal.rank - a.temporal.rank` after overlap, before effectiveDate; `surfacedToken``temporalToken` (badges); `briefSummary` first-mover marker; `ranking:` descriptor names the new key | SC1SC5, SC7, SC9 |
| `scripts/trends/src/cli.ts` | **EDIT**`brief … [--first-mover-days N] [--saturation-at N]` (mirror `--fresh-days` at `:312-317`, into `rankForBrief` opts at `:323`); usage line (`:110`) + header synopsis (`:14`) note the overlay; **`schedule` untouched** (nightly uses defaults) | SC6 |
| `scripts/trends/tests/brief.test.ts` | **EDIT**`temporalSignal` unit incl. clamp + future-date + neutral cases (SC1/SC2/SC9); **disagreement** ordering fixture (SC3); render badges (SC4) + **4 touched assertions**: `:407` `sett 3x``mettet (3x)`, `:408` unchanged (≥2 gate), `:325-331`+`:410-416` descriptor segment, `:96-102` re-based to a shared tier; summary marker (SC5); determinism (SC7); no-score-mutation (SC8) | SC1SC5, SC7SC9 |
| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess: `brief --first-mover-days N --saturation-at N` changes tiers vs defaults; absent → defaults 2/3; `--first-mover-days -1`/`x`, `--saturation-at 0`/`x` → exit 2 | SC6 |
| `scripts/trends/src/types.ts` · `store.ts` · `score.ts` · `item.ts` · `schedule.ts` · `run-daily.sh` | **UNTOUCHED** — no data-shape/scoring/store/scheduler change. Listed to assert they are *not* in scope (`SCHEMA_VERSION` 4 held in `types.ts`; the R3c scheduler suite stays green untouched). | — |
| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief now applies a live temporal overlay (first-mover↑/saturated↓) at rank time, derived from dates + the seen-log; capture path unchanged. Domain-general. | — |
| `references/trend-scoring-modes.md` | **EDIT (one-line note, "Consumers")** — the brief applies a within-composite-tier temporal overlay (RE-R3d); **no** weight/band/formula change. | — |
| `scripts/trends/README.md` | **EDIT** — new `## Temporal overlay (RE-R3d)` between the R3c scheduler section (`:135-154`) and `## Tests` (`:155`): first-mover/saturation, derived-not-stored, the flags + defaults, the `cmp` integration, the badges | — |
| `scripts/test-runner.sh` | **EDIT**`TRENDS_TESTS_FLOOR` (`:713`, 192)→recount + breakdown comment; NEW unconditional **Section 16m** between 16l's `echo ""` (`:1374`) and Section 18 (`:1376`); `ASSERT_BASELINE_FLOOR` (`:1403`) 111→**117**; header-enum chain (`:57-62`) + Section-18 floor-history narration (`:1376-1402`, ends "= 111") | SC10 |
| `docs/research-engine/{brief,plan}-re-r3d.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — |
| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 117, gate total; schema unchanged v4; **correct the stale :709/:1329 line-cites to live :713/:1403**). *Land bookkeeping, LOCAL-ONLY.* | — |
**Not touched (scope fence):** `types.ts`/`store.ts`/`score.ts`/`item.ts` (no data-shape/scoring change;
`SCHEMA_VERSION` 4) · `schedule.ts`/`run-daily.sh` + their tests (R3c untouched — the nightly run uses default
thresholds; no regression surface) · the SessionStart hook + its tests (R3d changes no frontmatter *field*; the
summary's new marker is regex-safe; no hook change/test) · `config/*` · `commands/*` (29) · `agents/*` count (19
`trend-spotter.md` is a prose EDIT) · `references/*` count (27 — `trend-scoring-modes.md` is an EDIT) ·
`algorithm-signals-reference.md` (cited for grounding, not edited) · `.gitignore` (no new artifact) ·
`BRIEF_SCHEMA_VERSION` (1) · `SCHEMA_VERSION` (4).
## Step 1 — (RED, two phases) failing tests across brief/cli
**Phase A — stub-first, then value-assertion RED** (`brief.test.ts` imports the new `brief.ts` exports):
- Land **non-throwing stubs** so the static imports resolve (Node16 ESM links named imports before any test runs):
in `brief.ts` — add `export type TemporalTier`, `export interface TemporalSignal`, and `export function
temporalSignal(): TemporalSignal { return { tier: "neutral", firstMover: false, surfacings: 0, rank: 2 }; }` (a
constant stub ignoring its args); add `temporal: TemporalSignal` to `BriefEntry` and populate it in `rankForBrief`
via the stub. The `cmp` key + `temporalToken` + the summary marker are **NOT** added yet (Steps 23).
- `brief.test.ts` (value-RED against the stub):
- **unit (SC1/SC2/SC9)** — `temporalSignal(1,0,{firstMoverDays:2,saturationAt:3})` expects
`{tier:"first-mover",rank:3}` (stub returns "neutral" → RED); `(5,3,…)``saturated`; `(5,2,…)``warming`;
`(5,0,…)``neutral`; `(3,0,…)``neutral` (past window); `(-1,0,…)``neutral` (future-date `>=0` guard); the
clamp case `(5,5,{…,saturationAt:0})``saturated` via `at=1`.
- **ordering (SC3) — DISAGREEMENT fixture** (folded — plan-critic M1: `surfacedCount` correlates with age, so a
naive first-mover-vs-saturated fixture is *already* ordered by the existing `effectiveDate`-desc key → GREEN in
Phase A, vacuous in GREEN). Three same-overlap entries: **A** `neutral` (surfaced 0, **older** date ≈5d,
composite 7.0); **B** `warming` (surfaced 2, **newer** date ≈1d, composite 7.0); **Z** `saturated` (surfaced 4,
composite **8.5**). Expect `topMatches.map(e=>e.trend.title) === [Z, A, B]`. Phase A (stub: constant rank, no
temporal key) yields `[Z, B, A]` (`effectiveDate`-desc decides A vs B) → RED; GREEN (Step 2 inserts
`temporal.rank`) flips A above the newer B, proving the key.
- **render (SC4)** — `renderBrief(...)` expects `· 🥇 først ute` on the first-mover entry, `· 🔁 mettet (3x)` on a
`surfacedCount 3` entry, `· sett 2x` on `surfacedCount 2`, **no badge** on `surfacedCount 1`, none on a
`neutral` entry (stub render still emits the old `surfacedToken` → RED). **Four existing assertions touched**
(folded — brief-reviewer MEDIUM-2/3): (1) `:407` `· sett 3x``· 🔁 mettet (3x)` (its surfacedCount-3 trend is
now saturated); (2) `:408` `!md.includes("sett 1x")` **stays unchanged** (the ≥2 badge gate keeps
surfacedCount-1 markerless); (3) `:325-331` + (4) the regex `:410-416` (the `ranking:` descriptor) gain `then
temporal (first-mover↑/saturated↓), `; and the `effectiveDate`-isolation test `:96-102` is **re-based** so both
entries share a `neutral` tier (else the new key, not `effectiveDate`, silently decides it — coverage erosion).
- **summary (SC5)** — `briefSummary(...)` with a first-mover top expects `· 🥇 først ute` in the headline (stub →
no marker → RED); assert the summary has no `"` and no `\n`.
- **no-score-mutation (SC8)** — capture a store's `score.composite` values, run `rankForBrief` (pure — no
mutation) and assert unchanged; assert `BRIEF_SCHEMA_VERSION === 1`, `SCHEMA_VERSION === 4`.
**Phase B — subprocess value-RED against the existing CLI handler** (no new import; the flags are parsed-but-unused
today):
- `cli.test.ts``brief --pillars ai --first-mover-days 1 --saturation-at 2 --store <seeded> --out <tmp>`:
today `parseFlags` stores the flags but the `brief` handler ignores them → the rendered tiers match the defaults
→ RED. **The tier badges live in the written `.md` body, not in `--json`** (which returns only
`{path,date,totals,summary,marked}`, `cli.ts:336`), so the assertion **`readFileSync(path)`** and checks the
badge (a `surfacedCount 2` trend renders `· 🔁 mettet (2x)` at `--saturation-at 2`, but `· sett 2x` at the
default) — folded — brief-reviewer LOW-5; the first-mover marker is also observable via the `--json` `summary`.
`--first-mover-days x` / `--saturation-at 0` today are ignored (exit 0) → RED against the expected `usage` exit 2.
**RED proof (record in commit, two phases):** Phase A — after the non-throwing `temporalSignal` stub lands,
`(cd scripts/trends && npm test)` fails the unit/ordering/render/summary cases on **value** assertions against the
constant-"fresh" stub (not module-not-found). Phase B — the `cli.test` flag cases fail on value/exit assertions
against the flag-ignoring handler. The plan does **not** claim a single "everything fails before any code" run.
## Step 2 — (GREEN) `brief.ts` — the real signal + ranking integration
Replace the Phase-A stub with the real, pure implementation:
- `export type TemporalTier = "first-mover" | "fresh" | "warming" | "saturated";`
- `export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number; }`
- `export function temporalSignal(ageDays, surfacedCount, opts): TemporalSignal`:
```ts
const surfacings = surfacedCount ?? 0;
const at = Math.max(1, opts.saturationAt); // defensive clamp (plan-critic m3)
const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; // >=0 guard (m5)
const tier: TemporalTier = firstMover ? "first-mover"
: surfacings >= at ? "saturated"
: surfacings >= 1 ? "warming" : "neutral";
const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0;
return { tier, firstMover, surfacings, rank };
```
Pure (no clock/fs/env). Branches disjoint + total (`firstMover` requires `surfacings === 0`; `saturated`/
`warming` both require `surfacings >= 1`).
- `RankOptions` (`:51-54`) gains `firstMoverDays?: number` + `saturationAt?: number`. `rankForBrief` (`:78`) reads
`const firstMoverDays = opts.firstMoverDays ?? 2; const saturationAt = opts.saturationAt ?? 3;` and populates
`BriefEntry.temporal = temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt })` at the
push site (`:93`).
- `cmp` (`:102-107`): insert `b.temporal.rank - a.temporal.rank ||` **after** `b.overlap - a.overlap ||` and
**before** `b.effectiveDate.localeCompare(a.effectiveDate)`. Composite stays the first key (SB2). The chain stays
a total order. The bucketing (`isFresh`/`freshDays`, `:109-113`) is **unchanged**.
Make the Phase-A unit + ordering cases green.
## Step 3 — (GREEN) `brief.ts` — render badges + summary marker + descriptor
- Replace `surfacedToken` (`:154-157`) with `temporalToken(e: BriefEntry): string`:
```ts
const t = e.temporal;
if (t.tier === "first-mover") return " · 🥇 først ute";
if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`;
if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`; // preserves the R3b ≥2 badge contract
return ""; // neutral, or warming with surfacings 1
```
Replace the `surfacedToken(e)` call in `renderTopEntry` (`:162`) and `renderBulletEntry` (`:171`) with
`temporalToken(e)`. (`scoreToken` untouched.)
- `briefSummary` (`:131-141`): compute `const fm = top.temporal.firstMover ? " · 🥇 først ute" : "";` and emit
`(${pillar}${band}${fm} · ${top.ageDays}d)`. The marker carries no `"`/`\n` (hook-regex invariant, `:128-130`).
- `renderBrief`'s `ranking:` descriptor (`:187`): insert `then temporal (first-mover↑/saturated↓), ` between
`pillar-overlap desc, ` and `then publishedAt desc`.
Make the Phase-A render + summary cases green.
## Step 4 — (GREEN) `cli.ts` — the two `brief` threshold flags
After the `--fresh-days` block (`:313-317`), add (mirroring its idiom):
```ts
let firstMoverDays = 2;
if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") {
const n = Number.parseInt(flags["first-mover-days"], 10);
if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer");
firstMoverDays = n;
}
let saturationAt = 3;
if (flags["saturation-at"] && flags["saturation-at"] !== "true") {
const n = Number.parseInt(flags["saturation-at"], 10);
if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer");
saturationAt = n;
}
```
Pass into the rank call (`:323`): `rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt })`.
Update the `brief` usage line (`:110`) + header synopsis (`:14`) to list the two flags + a one-line note that the
brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. **No new exit code** (0/2). The
`schedule` branch (`:343-417`) is **untouched** (nightly run uses defaults). Make the Phase-B `cli.test` cases green.
## Step 5 — wire `trend-spotter.md` (prose) + SSOT note + README
- `agents/trend-spotter.md` — one prose line (no batch-shape change): the morning brief now applies a **live
temporal overlay** at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), **derived**
from the publish/capture dates + the seen-log — no new capture step; the polling/capture path is unchanged.
Domain-general (Section 17).
- `references/trend-scoring-modes.md` — under "Consumers", add ONE line: the morning brief applies a brief-time
temporal overlay (first-mover/saturation, RE-R3d) as a within-composite-tier ranking refinement; this does **not**
change the capture-time weights above. (No weight/band/formula edit.)
- `scripts/trends/README.md` — add `## Temporal overlay (RE-R3d)` between `:154` and `## Tests` (`:155`): the
first-mover/saturation definitions (self-surfacing, not market-coverage), the **derived-not-stored** boundary
(no schema bump), the `--first-mover-days`(2)/`--saturation-at`(3) flags, the `cmp` integration (composite stays
primary), the badge set (`🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` / none).
## Step 6 — gate: floors + new unconditional Section 16m
In `scripts/test-runner.sh`:
- Set `TRENDS_TESTS_FLOOR` (`:713`, currently **192**) to the **`tests N`** line reported by `(cd scripts/trends &&
npm test)` after Steps 15 — recounted live, NOT additive-guessed. Stays inside the deps guard. **Append**
`+ RE-R3d: brief +N, cli +N (temporal overlay)` to the inline breakdown comment.
- Add **Section 16m** ("Trends Temporal Overlay", RE-R3d), mirroring Section 16l (unconditional, deps-absent-safe,
pure `grep -qF`/self-test, no `tsx`, **all literals ASCII** — the badge emoji are NEVER grepped; the shell must
stay ASCII-clean for bash 3.2 `set -u`). **Placement (verified live):** between Section 16l's trailing `echo ""`
(`:1374`) and the Section 18 header (`:1376`) — anti-erosion stays last. Six **unconditional** checks, the
self-test emitting **one** pass/fail like 16l:
(1) a non-vacuity self-test (a probe carrying `temporalSignal` accepted, one without rejected);
(2) `grep -qF 'export function temporalSignal' scripts/trends/src/brief.ts`;
(3) `grep -qF 'b.temporal.rank' scripts/trends/src/brief.ts` (the cmp integration);
(4) `grep -qF '"first-mover"' scripts/trends/src/brief.ts` (the tier literal);
(5) `grep -qF 'first-mover-days' scripts/trends/src/cli.ts` (the flag);
(6) `grep -qF 'saturation-at' scripts/trends/src/cli.ts` (the flag).
- Bump `ASSERT_BASELINE_FLOOR` (**`:1403`**, currently **111**) → **exactly 117** (111 + the 6 new unconditional
16m emitters; the self-test emits one pass/fail like 16l, so 117 is deterministic — "live recount" is the safety
net, not a guess). Insert the 16m clause into the **header-enumeration prose chain (`:57-62`)** before "…the
assertion-count anti-erosion floor (SC6) in Section 18", preserving sentence flow. **Append** the RE-R3d (→117)
narration to the **Section-18 floor-history comment** (`:1376-1402`, which ends "= 111").
- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3d adds no hook test). It must
still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land.
## Step 7 — behavioural verification
`(cd scripts/trends && npm install)` if needed, then run brief §7's five behavioural steps: seed a store with a
fresh+unsurfaced, a warming (`surfacedCount` 2), and a saturated (`surfacedCount` 4) trend at the **same**
composite; `brief --pillars … --out /tmp/r3d-mb --store /tmp/r3d.json` → the fresh+unsurfaced sorts first with
`· 🥇 først ute`, the saturated last with `· 🔁 mettet (4x)`, the `ranking:` descriptor names the temporal key;
re-run with `--first-mover-days 0 --saturation-at 2` → first-mover badge gone, `surfacedCount 2` escalates to
`mettet (2x)`; `--first-mover-days x` → exit 2; confirm the seeded `score.composite` values are unchanged after the
brief (only `surfacedCount`/`lastSurfacedAt` advance). Run full `bash scripts/test-runner.sh` → `FAIL=0`
(`ASSERT_BASELINE_FLOOR` 117, trends ≥ new floor, Section 16m green, Section 17 de-niche green, counts 29/19/27);
run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched regression); confirm `schedule.test`/
`run-daily.test` still green (R3c untouched).
## Step 8 — land
Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 117, gate total;
schema unchanged v4; **correct the stale :709/:1329 cites to live :713/:1403**). Commit order (house style):
**(1)** docs commit `docs/research-engine/{brief,plan}-re-r3d.md` (no suffix, tracked); **(2)** code commit —
`brief.ts` + `cli.ts` + `brief.test.ts` + `cli.test.ts` + `agents/trend-spotter.md` +
`references/trend-scoring-modes.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]`
(D10: single code commit — the overlay is one coherent feature). 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)` after the `temporalSignal` stub | unit/ordering/render/summary cases fail on **value** assertions vs the constant-"fresh" stub (not module-not-found) |
| — | RED Phase B | `npm test` (cli.test) before the flag impl | `--first-mover-days`/`--saturation-at` cases fail on value/exit vs the flag-ignoring handler |
| SC1 | first-mover detection | `npm test` (brief.test) | `(≤firstMoverDays, surfaced 0)` → first-mover; `(>window,0)`/`(≤window,≥1)` → not |
| SC2 | saturation grading | `npm test` (brief.test) | `surfacings ≥ saturationAt` → saturated; `1..at-1` → warming; `0` → fresh (inclusive `>=`) |
| SC3 | ranking — within-tier re-order, composite dominates (DISAGREE fixture) | `npm test` (brief.test) | `[Z(8.5,sat), A(7.0,neutral,older), B(7.0,warming,newer)]`; temporal key flips A above newer B; Phase A `[Z,B,A]`→RED; total order |
| SC4 | render badges + ≥2 boundary | `npm test` (brief.test) | `🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` (warming≥2) / none (warming 1, neutral); `:407` updated, `:408` stays green |
| SC5 | summary first-mover marker | `npm test` (brief.test) | first-mover top → `· 🥇 først ute` in headline; else absent; no `"`/`\n` |
| SC6 | CLI flags | `npm test` (cli.test) | flags change tiers vs defaults; defaults 2/3 when absent; bad values → exit 2 |
| SC7 | determinism | `npm test` (brief.test) | same `(store,pillars,today,freshDays,firstMoverDays,saturationAt)` → byte-identical `.md` |
| SC8 | no schema / no score mutation | `npm test` (brief.test) | `SCHEMA_VERSION` 4; `BRIEF_SCHEMA_VERSION` 1; `score.composite` unchanged after a brief |
| SC9 | purity | `npm test` (brief.test) | `temporalSignal` stable over a grid; first-mover ⊆ recent∧unsurfaced; saturated ⇔ `surfacings≥saturationAt` |
| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16m green; `ASSERT_BASELINE_FLOOR`=117; Section 17; counts 29/19/27; hook suite green |
## Risks
- **R1 — changing `cmp` re-orders existing brief output → silently breaks downstream expectations.** *Mitigated:*
composite stays the PRIMARY key (SB2) — the overlay only re-orders WITHIN a (composite, overlap) tier; the
bucketing is unchanged; SC3 pins the exact order; SC7 pins byte-determinism; the hook reads only date+summary
(unaffected).
- **R2 — the `surfacedToken`→`temporalToken` promotion breaks pinned R3b/descriptor assertions.**
*Mitigated/expected (folded — all three reviewers):* the warming badge is gated at `surfacings >= 2`, so
`brief.test.ts:408` (`!md.includes("sett 1x")`) stays green and the R3b ≥2 contract is preserved exactly; Step 1
enumerates the **four** touched assertions (`:407` `sett 3x`→`mettet (3x)`; `:325-331`+`:410-416` descriptor) and
re-bases the coverage-eroded `:96-102`. `surfacedCount 2` still renders `· sett 2x`.
- **R3 — emoji in the gate crashes bash 3.2 `set -u`.** *Mitigated:* Section 16m greps ONLY ASCII literals
(`temporalSignal`, `b.temporal.rank`, `"first-mover"`, `first-mover-days`, `saturation-at`); the emoji live only
in `brief.ts` source + rendered output, asserted by the TS tests, never by the shell gate.
- **R4 — the summary marker breaks the SessionStart extractYaml regex.** *Mitigated:* `🥇 først ute` carries no
`"` and no `\n`; SC5 asserts the invariant; the hook suite is a land-time regression check.
- **R5 — saturation framing overclaims (reads as market-coverage).** *Accepted/honest:* R3d's saturation is
**self-surfacing** (our seen-log), a proxy for a closing/ignored window — NOT external coverage (that is slice
e, AI polling). The README + badge wording say "seen N×", not "covered online"; the brief §4 non-goal states the
boundary. No salesmanship.
- **R6 — first-mover default (2) too tight / saturationAt default (3) arbitrary.** *Mitigated:* both are
CLI-tunable (`--first-mover-days`/`--saturation-at`), documented as deliberate defaults (like `freshDays` 7),
grounded in the SSOT timing band (`<24-72h`) + the existing `sett Nx` `>=2` hint; D1/D2 are operator go-gate
knobs.
- **R7 — float/`-1` composite sentinel interaction with the new integer key.** *Mitigated:* the temporal key is a
separate `||` term (a small integer diff); it never touches the `score?.composite ?? -1` term; the comparator
stays a sum-free short-circuit chain (no NaN risk).
- **R8 — editing `trend-spotter.md` / the SSOT trips the de-niche guard (Section 17).** *Mitigated:* the added
prose + the SSOT note carry only generic overlay wording; pillars/topics stay config; Section 17 runs in the
gate.
- **R9 — gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16m is pure `grep`/self-test on
tracked source (`brief.ts` + `cli.ts`; no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps
guard.
- **R10 — STATE's pinned floor line-cites (`:709`/`:1329`) are stale (live `:713`/`:1403`).** *Mitigated:* caught
at brief time (the line numbers drifted when R3c added Section 16l + the floor-history narration); the plan cites
live values; Step 8 corrects STATE.
- **R11 — a temporal ordering test that passes WITHOUT the feature (false RED / vacuous GREEN).** *Mitigated
(folded — plan-critic M1):* `surfacedCount` correlates with age, so a naive first-mover-vs-saturated fixture is
already ordered by `effectiveDate`-desc. The SC3 fixture is built to force the temporal key and `effectiveDate`
to **disagree** (older-`neutral` A vs newer-`warming` B at equal composite), so it is RED in Phase A and the new
key is provably what decides in GREEN; the coverage-eroded `:96-102` is re-based to a shared tier.
## Plan-critic — folded
Three Opus reviewers ran on the brief + this plan, each verifying against live code; they **converged on the same
two defects**. Verdicts: **scope-guardian MIXED** (0 hard creep; both confirmed decisions honored; every SC traces
to a step; the floor/line cites verified live); **brief-reviewer PROCEED_WITH_RISKS** (all seven RED-premise/
correctness claims HOLD; the gap was GREEN-completeness — 1 of 4 breaking assertions listed); **plan-critic
APPROVE_WITH_NOTES 78/B** (floor arithmetic, line-cites, grep sentinels, cmp total-order, two-phase-RED structure
all verified correct). Per-finding resolution (full headline list in `brief-re-r3d.md §9`):
- **[MAJOR — all three] warming badge fired at `>=1`, but live `surfacedToken` fires at `>=2` (`brief.ts:156`) +
`brief.test.ts:408` pins `!sett 1x`.** ✅ Step 3 gates the warming badge at `surfacings >= 2` (R3b ≥2 contract
preserved exactly; `:408` unchanged); the warming *tier* still demotes in `rank`. SC4 gains the surfacedCount-1
no-badge boundary. The "preserves the hint" wording is corrected.
- **[MAJOR — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not RED + vacuous** (`surfacedCount`
correlates with age → `effectiveDate`-desc already orders first-mover-vs-saturated). ✅ Step 1 SC3 fixture forces
temporal↔date **disagreement** (older-`neutral` A vs newer-`warming` B, equal composite; expect `[Z,A,B]`, Phase
A `[Z,B,A]`→RED); `:96-102` re-based to a shared tier (R11).
- **[MEDIUM — brief-reviewer MEDIUM-2] the `ranking:` descriptor change breaks `:325-331` + `:410-416`.** ✅ Step 1
+ the Files table enumerate all four touched assertions, not one.
- **[MINOR — plan-critic m3] `temporalSignal` undefensive vs `saturationAt < 1`.** ✅ Step 2 clamps `const at =
Math.max(1, opts.saturationAt)` inside the pure function (the CLI guard alone is insufficient — the function is a
public, gate-grepped export). SC2 clamp case added.
- **[MINOR — plan-critic m4] the "fresh" tier was a misnomer** (collides with `freshDays`; a 30-day unsurfaced
trend is not "fresh"). ✅ renamed **`neutral`** throughout (Steps 13, SCs); the gate sentinel greps `"first-mover"`
(unaffected). The in-bucket effect (unsurfaced ranks above seen-and-skipped within `olderMatched`) documented in
brief §3 as intended.
- **[MINOR — plan-critic m5] future `publishedAt` (ageDays < 0) became a first-mover "act now" headline.** ✅ Step 2
adds the `ageDays >= 0` guard (future → `neutral`). SC1 gains the `(-1,0)` case.
- **[MINOR — plan-critic m6 / brief-reviewer] nightly run locked to default thresholds** (the primary saturation
consumer). ✅ reframed in brief §4 as a known limitation, not a "later nicety."
- **[LOW — brief-reviewer LOW-5] SC6 tier assertions can't read tiers from `--json`** (it omits the body badges).
✅ Step 1 Phase B reads the written `.md` via `readFileSync(path)`; the first-mover marker is also in the `--json`
`summary`.
- **[LOW — all three] long-form `angle` cite `:34` → `:32`** (`:34` = `currency`; substance holds). ✅ brief §0/§2
corrected.
**Verified correct (no change needed):** the floor arithmetic (16m = 1 self-test + 5 greps = 6 → `ASSERT_BASELINE_
FLOOR` 111→117), all live cites (`:713`/`:1403`/`:1374`/`:1376`/`:57-62`), the six ASCII grep sentinels match the
literals Steps 24 write, the `cmp` insertion preserves a total order with no NaN risk, and the two-phase RED
structurally avoids a module-load ERROR. **This slice is authored + landed in-session (driftsmodell), not as a
headless autonomous run, so per-step revert/halt clauses are not needed** (R1/R2/R3a/R3b had none either).