docs(linkedin-studio): RE-R1 brief + plan — lift research-engine item-schema (B1) + triage-scorer (B2) to tested code (light-Voyage hardened)

Research engine lifted to Tier-1 (operator 2026-06-24): the daily workflow
rests on a steady stream of topic suggestions, and research is the only
subsystem whose core logic (poll/score/digest) is ungated prose in
agents/trend-spotter.md rather than tested code.

RE-R1 (rung-2, slice 1): B1 canonical item envelope + normalizer
(scripts/trends/src/item.ts) and B2 deterministic triage-scorer
(score.ts — composite/band/triage, owns only the arithmetic; the five
1-10 dimension scores stay model judgment), plus a stdin/JSON CLI seam,
tests, a trend-spotter prose pointer, and gate-floor bumps. No
store-schema change (SCHEMA_VERSION stays 1). The visible morning-brief
stream (B3 + surfacing) is R2.

Light-Voyage hardened: scope-guardian ALIGNED; plan-critic blocker + 6
majors folded — incl. TrendItem does NOT map directly onto TrendInput
(capturedAt vs publishedAt → store bridge deferred to R2), CLI reads
stdin (no --json overload), gate Section 16g before the anti-erosion
Section 18, ASSERT floor recount-not-pinned, band thresholds + action
strings drift-pinned, wiring grep literal src/cli.ts score.

No code touched yet — awaiting operator go-gate on the plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 01:11:06 +02:00
commit 0e95ca8cce
2 changed files with 199 additions and 0 deletions

View file

@ -0,0 +1,89 @@
# Brief — RE-R1: lift the research engine's item-schema + triage-scorer from agent prose to tested code
> **Slice:** RE-R1 (research-engine rung-2, slice 1) — the first build slice of the daily research motor.
> **Status:** drafted, awaiting go-gate. **Predecessors:** "slice 2a" = the scoring SSOT `references/trend-scoring-modes.md`; "slice 2b" = the trend store `scripts/trends/` (24/24). This slice adds the **scoring/normalization code those two anticipate** (`trend-scoring-modes.md:96-100` names "any future research-engine pass that scores candidates before writing them to the trend store" as a consumer).
> **Substrate:** `docs/research-engine-concepts.local.md` §1, §3 (B1/B2), §5 ("anbefalt minste neste slice"). **TDD-order:** RED tests + RED gate self-test land before code.
## 1. Operator decision context (2026-06-23)
The operator lifted the **research engine to Tier-1**. Rationale, verbatim: *"hele min arbeidsflyt hviler på at jeg får en jevn strøm av gode forslag til tema å skrive om."* A/moat (lived-specifics · positioning · shareability · analytics→loop) remains the long-term nordstjerne but is built **interactively in Maskinrommet from lived use**, not as a TDD slice now.
This slice is exactly the one `research-engine-concepts.local.md` §5 named as the minimal next move *if research is lifted*: **B1 (uniform item-schema, tested) + B2 (triage-scorer as code with threshold config)**. It moves "the only untestable layer" from agent prose into gated code, and is the foundation every later rung (brief artifact, surfacing, freshness, deepening) hangs on.
## 2. The gap — grounded in code
- **Storage exists; scoring does not.** `scripts/trends/src/store.ts` (183 LOC) stores/dedupes/queries `TrendRecord {id,title,url,source,capturedAt,topics[],summary?}` (`types.ts:26-41`). Its header states scoring "lives in the agent/command layer; this module only stores, dedupes, and serves." There is **no scoring/routing/saturation/digest code anywhere** in the repo.
- **The arithmetic is prose the LLM runs in its head.** `references/trend-scoring-modes.md` (SSOT, 101 lines) defines two modes — **kortform** (Pillar .30 / Audience .25 / Timing .20 / Angle .15 / Authority .10) and **long-form** (Pillar .30 / Depth .25 / Angle .20 / Authority .15 / Currency .10) — a weighted composite on a shared 010 scale, and a composite→band map (8.010 Immediate · 6.07.9 High · 4.05.9 Medium · 2.03.9 Low · 01.9 Skip), each band carrying a kortform + long-form action. `agents/trend-spotter.md:124-137` tells the agent to "score each dimension 110, take the weighted composite, rank highest-first, apply the bands" — **deterministic arithmetic with no test and no code.**
- **Ingress is ad hoc.** Nothing normalizes a source's raw output into one item envelope before it reaches `addTrend`; downstream would branch on source-type. B1 is the "one schema downstream never branches on" the concepts doc calls "the most load-bearing idea — build it first."
## 3. Scope — what is IN (RE-R1)
### B1 — canonical item schema + normalizer (`scripts/trends/src/item.ts`, NEW)
A `TrendItem` **ingress envelope** `{ source, title, url, publishedAt?, topics[], summary? }` — what a source/adapter emits — plus a pure `normalizeItem(raw): { ok: true, item } | { ok: false, errors }` and `normalizeItems(raw[]): { items, errors }` (batch partition). Deterministic: required-field validation (`source/title/url` present and non-empty → else a structured `{ok:false}` error naming the field, never a silent partial), whitespace normalization (reuse `normalizeField` from `store.ts`), topic normalize + dedupe, `publishedAt` ISO-8601-validate-**if-present** (the source's publish date — carried for forward-compat, **not persisted in R1**, and distinct from the store's `capturedAt`). **No `id` field:** the envelope carries none and the store already derives it via `addTrend``trendId`. Vocabulary kept as `topics` (≡ the concepts doc's `tags`; see §8). **The item→store bridge** (injecting `capturedAt`, persisting `publishedAt`) is **R2 orchestration — out of scope here**; R1 delivers the validated envelope + normalizer only.
### B2 — deterministic triage-scorer (`scripts/trends/src/score.ts`, NEW)
`ScoreMode = 'kortform' | 'long-form'`; per-mode weight constants **encoded from the SSOT** (with the SSOT's "ordering is the signal, not a measured coefficient" caveat as a comment). `DimensionScores` (mode-specific keys). `composite(scores, mode): number` — weighted sum, validates each dimension ∈ [1,10]. `band(composite): { priority, kortformAction, longformAction }` — the five-band map. `triage(scored, { mode, threshold }): { kept, dropped }` — kept = composite ≥ threshold, ranked composite-desc, each annotated with composite + band; dropped below. `threshold` is a single config parameter (default — see §8). The module owns **only** the arithmetic / bands / threshold; producing the five dimension scores stays model judgment (the deterministic/judgment split from the concepts doc §5 / SSOT analysis).
### CLI surface (`scripts/trends/src/cli.ts`, EDIT existing)
Two new subcommands over the new modules — both **read their JSON payload from stdin** (a raw item/batch for `normalize`; items-with-dimension-scores for `score`) and **emit JSON to stdout**, so they do **not** overload the existing `--json` *output* toggle (`cli.ts:79`). `score` takes `[--mode kortform|long-form] [--threshold N]` flags. Exit codes: **2** for a malformed invocation (missing required flag, unparseable stdin JSON) — matching `cli.ts`'s usage-error precedent (`cli.ts:54-63`); **0** for a well-formed call, even when the payload carries content-invalid items (reported as `{ok:false,...}` entries in the output, never via the exit code).
### Wiring + gate
- `agents/trend-spotter.md` (EDIT): replace the "do the composite + bands yourself" prose at L124-137 with a pointer to the scorer CLI as the owner of the composite/band/threshold step (the lift becomes real and grep-able; the agent still supplies the five judgment scores). Kept domain-general.
- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (recount live at land; it **stays inside the `if [ -x …/tsx ]` deps guard** — conditional, never hoisted out). Add a new **grep-only** Section **16g**, placed after Section 17 (de-niche) and **before** Section 18 (anti-erosion, which must run last so it sees every prior check), with deps-absent-safe checks (no `tsx`): (1) `grep` that `score.ts` encodes both mode weight-sets; (2) `grep -qF "src/cli.ts score"` that `trend-spotter.md` references the scorer CLI; (3) a non-vacuity self-test for those greps, per the house pattern (every sibling section 16c17 has one). These are **unconditional** → bump `ASSERT_BASELINE_FLOOR` 84 → **live recount** (expected ~+3, not a pinned number). The arithmetic proof (`composite(all-tens)=10.0` ⇒ weights sum to 1.0) lives as a **unit test** in `score.test.ts` (conditional, counts toward the trends floor), since it needs the `tsx` runtime.
## 4. Non-goals — what is OUT (deferred)
- **The dated brief artifact / morning-brief file (B3)** — R2. *This is the rung that makes the stream visible to the operator; R1 is the foundation beneath it, not the stream itself.*
- **Session-start surfacing** of the brief (hull 4) — R2.
- **Store-schema migration / new persisted fields** (publishedAt, relevance, saturation, status — hull 3/5) — R2. R1 keeps `SCHEMA_VERSION = 1` untouched (no migration risk).
- **Freshness window / dedup-vs-seen-log / autonomous trigger (B4)** — R3.
- **Research-deepening (plan → isolated workers → synthesis, A1A4)** — R3.
- **Adapter sub-agents / MCP fetch fan-out** — R2/R3.
- **Producing the five dimension scores in code** — stays model judgment, by design.
## 5. Boundaries / invariants (must hold)
- **TDD iron law:** the failing `item`/`score` tests **and** the failing gate self-test land BEFORE the implementation. RED proofs recorded (logic-RED, not import-RED).
- **No store-schema change:** `SCHEMA_VERSION` stays 1; `TrendRecord` untouched; no data migration.
- **Domain-general:** de-niche guard (Section 17) stays green; no vendor/sector tokens enter the edited `trend-spotter.md` prose.
- **SSOT discipline:** `trend-scoring-modes.md` remains the human source of truth; `score.ts` mirrors it; a test pins **the exact per-mode weights, the four band thresholds (8.0/6.0/4.0/2.0), and the ten band action strings** against the SSOT values, and asserts each mode sums to 1.0 — so silent drift in *any* of them (not just the weights) fails loudly. (A markdown-parsing cross-check of the SSOT table itself is deferred — out of scope.)
- **House conventions:** ESM + `node:test` + `tsx`; data-seam stays inline (no new shared util — the documented idiom); no new `.mjs` under `hooks/scripts/`; `.gitignore` already covers `scripts/trends/{node_modules,build}`.
- **No new `references/*.md`** (would trip the named-additions guard) and **no new agent/command** (counts stay 19/29/27). Brief+plan live under `docs/` (uncounted), TRACKED like `docs/second-brain/*` (general feature design).
- **Counts recounted live at land**, never pinned/guessed.
## 6. Success criteria (testable)
- **SC1**`normalizeItem` returns a canonical item from a well-formed raw item; a missing/empty required field returns a structured `{ok:false}` error naming the field (not a silent partial); whitespace + topic dedupe applied; `publishedAt` validated-if-present, undefined-if-absent. `normalizeItems` partitions a batch into `{items, errors}`.
- **SC2**`composite()` equals the SSOT formula exactly for both modes: all-tens → **10.0** (proves Σweights=1), and the **asymmetric golden vector `{10, 8, 6, 4, 2}` (in dimension order) → 7.0** for both modes (`10·.30 + 8·.25 + 6·.20 + 4·.15 + 2·.10`) — asymmetric so a weight↔dimension transposition is caught; a dimension outside [1,10] throws.
- **SC3**`band()` returns the correct priority + **the exact mode-appropriate action string** (pinned against the SSOT) at every boundary (8.0, 6.0, 4.0, 2.0 edges).
- **SC4**`triage()` keeps composite ≥ threshold, drops below, ranks kept composite-desc, annotates each kept item with composite + band.
- **SC5** — CLI `normalize`/`score` read JSON from stdin, emit valid JSON; exit **2** on a malformed invocation (missing flag / unparseable stdin), **0** on a well-formed call (content-invalid items reported in the payload, never via exit code).
- **SC6**`(cd scripts/trends && npm test)` green at the bumped floor; gate's new unconditional section green; `ASSERT_BASELINE_FLOOR` bumped to the live recount; overall gate `FAIL=0`.
- **SC7**`trend-spotter.md` references the scorer for the deterministic step (gate grep green); de-niche guard green; structure counts unchanged (19/29/27).
## 7. Verification
**Deterministic (gate):** `bash scripts/test-runner.sh``FAIL=0`; trends suite ≥ new floor; new self-test + wiring-grep pass; Section 17 de-niche green; ref/agent/command counts unchanged.
**Behavioural (manual):** run `npm run start -- normalize --json '<sample batch>'` and `... score --json '<scored sample>' --mode kortform --threshold 4.0`; eyeball that kept/dropped/bands match a hand-computed expectation on 34 items.
## 8. Open questions for the go-gate
1. **Default threshold.** Propose **4.0** — the SSOT's Medium-band floor and the agent's existing "score 4.0+" angle-mapping cutoff (`trend-spotter.md:179,274`). Confirm or set otherwise.
2. **Vocabulary `topics` vs `tags`.** The concepts doc envelope says `tags`; the codebase standardized on `topics`. Propose **keep `topics`** (consistency, no rename) and note `tags ≡ topics`. Confirm.
3. **CLI tests now?** Trends has no `cli.test.ts` today; siblings (brain, contract-gate) do. Propose **add light cli tests** for the two new subcommands (happy path + exit codes). Confirm vs defer.
4. **Wire `trend-spotter.md` now?** Propose **yes** — a prose pointer so the lift is real and gate-grep-able; full orchestration (fan-out, brief assembly) stays R2.
## 9. Light-Voyage review — folded
Three Opus reviewers ran on the drafts. **scope-guardian: ALIGNED** (every SC1SC7 traces to a step; zero scope creep; NON-goals fully respected). **brief-reviewer: PROCEED_WITH_RISKS.** **plan-critic: REVISE (1 blocker, 6 majors).** All findings folded:
- **[BLOCKER, folded]** `TrendItem``TrendInput` does not map directly — live `TrendInput` requires `capturedAt` and has no `publishedAt`; the item has the opposite. → §3 B1 now scopes R1 to the validated envelope only; the item→store bridge (`capturedAt` injection, `publishedAt` persistence) is **R2**. A scope-tightening, not an addition.
- **[MAJOR, folded]** CLI `--json` is already an *output* toggle → §3 now reads payload from **stdin**, `--json` untouched.
- **[MAJOR, folded]** Gate-section placement + anti-erosion-last → §3 pins **Section 16g, before Section 18**; `TRENDS_TESTS_FLOOR` stays inside the deps guard.
- **[MAJOR, folded]** `ASSERT_BASELINE_FLOOR` was pinned to 86 against the recount-live rule, and the house self-test pattern makes it +3 → §3 now says **live recount (~+3), not pinned**.
- **[MAJOR, folded]** Only weights were drift-guarded → §5 SSOT discipline now pins **weights + band thresholds + the ten action strings**.
- **[MAJOR, folded]** Wiring grep literal unpinned → §3 pins `grep -qF "src/cli.ts score"`.
- **[testability, folded]** SC2 unnamed vector → **pinned `{10,8,6,4,2}`→7.0**; SC5 exit-code ambiguity → **explicit 2-vs-0 contract**; `id` hedge → **envelope carries no id**; `normalizeItems` batch shape → **SC1 extended**.
- **[minor, folded in plan]** RED-stub split per assertion type; explicit `node --import tsx` invocation (not `npm run start`); commit grouping fixed; STATE.md noted as land-bookkeeping; `config/trends-sources.template.md` added to the scope fence.
See `plan-re-r1.md` §Plan-critic — folded for the per-step resolution.

View file

@ -0,0 +1,110 @@
# Plan — RE-R1: item-schema (B1) + triage-scorer (B2) as tested code
> **Brief:** `docs/research-engine/brief-re-r1.md`. **Slice:** RE-R1 (research-engine rung-2, slice 1).
> **TDD-order:** RED (item + score tests as logic-RED) → GREEN (item.ts, score.ts) → GREEN (CLI + cli tests) → wire trend-spotter prose → gate floors → behavioural → land. **Counts recounted live at land, never pinned/guessed.**
> **Light-Voyage hardened:** scope-guardian ALIGNED; brief-reviewer + plan-critic findings folded (see §Plan-critic — folded).
## Goal
Move the research engine's deterministic core — the canonical item envelope (B1) and the composite/band/threshold arithmetic (B2) — out of `agents/trend-spotter.md` prose into pure, tested TypeScript under `scripts/trends/`, behind a CLI seam the agent and a future orchestrator call. No store-schema change; the five judgment scores stay with the model; wiring `normalizeItem` into the store is R2.
## Files touched (exhaustive — for scope-guardian)
| File | Change | SC |
|---|---|---|
| `scripts/trends/src/item.ts` | **NEW**`TrendItem` ingress envelope + `normalizeItem` / `normalizeItems` (pure, validating; no `id`) | SC1 |
| `scripts/trends/src/score.ts` | **NEW**`ScoreMode`, per-mode weight consts (mirror SSOT), `composite`, `band`, `triage` | SC2, SC3, SC4 |
| `scripts/trends/src/cli.ts` | **EDIT** — add `normalize` + `score` subcommands (stdin JSON in, JSON out, exit 2 on bad invocation) | SC5 |
| `scripts/trends/tests/item.test.ts` | **NEW** — normalize: required-field errors, whitespace/topic dedupe, publishedAt validate, batch partition | SC1 |
| `scripts/trends/tests/score.test.ts` | **NEW** — golden composite (both modes: all-tens=10.0 + `{10,8,6,4,2}`=7.0 + weights-sum-1.0 + pinned weights), range guard, band boundaries + pinned action strings, triage gate/rank | SC2, SC3, SC4 |
| `scripts/trends/tests/cli.test.ts` | **NEW** — subprocess: `normalize`/`score` happy path (stdin→JSON) + exit-2 bad invocation | SC5 |
| `agents/trend-spotter.md` | **EDIT** — replace L124-137 "compute composite/bands yourself" with a pointer naming `src/cli.ts score` as the deterministic-step owner; domain-general | SC7 |
| `scripts/test-runner.sh` | **EDIT**`TRENDS_TESTS_FLOOR` 24→recount (stays inside deps guard); NEW unconditional Section **16g** (before Section 18); `ASSERT_BASELINE_FLOOR` 84→recount; header enumeration | SC6, SC7 |
| `docs/research-engine/{brief,plan}-re-r1.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — |
| `STATE.md` | **EDIT at land** — Telling-block reconcile (floors, gate total). *Land bookkeeping, not slice scope; LOCAL-ONLY.* | — |
**Not touched (scope fence):** `scripts/trends/src/{store.ts,types.ts}` (no schema change, `SCHEMA_VERSION` stays 1) · `references/trend-scoring-modes.md` (SSOT unchanged) · `references/*` (no new ref doc) · `config/trends-sources.template.md` (source list — not wired in R1) · `agents/*` count (19) · `commands/*` (29) · `hooks/**` · `.gitignore` (trends lines already present).
## Step 1 — (RED) failing tests for B1 + B2
Write `tests/item.test.ts` and `tests/score.test.ts` against not-yet-existing modules. Make them **logic-RED**, not import-RED, with a stub strategy split by assertion type:
- **arithmetic / "returns X" tests** → stub returns a *wrong constant* (so the equality assertion fails on value, not on a throw);
- **"should throw" validation tests** → stub returns a valid-looking value (so the `assert.throws` fails because nothing threw).
`item.test.ts`: well-formed raw → canonical item (fields verbatim; topics normalized+deduped); missing/empty `source|title|url``{ok:false}` naming the field; whitespace collapse via the same normalization as `store.normalizeField`; `publishedAt` present-and-ISO → kept, absent → undefined, present-and-invalid → `{ok:false}`; `normalizeItems` partitions a batch into `{items, errors}`; the canonical item carries **no `id`**.
`score.test.ts`: `composite` for both modes on (a) all-tens → exactly **10.0**, (b) the asymmetric vector `{10,8,6,4,2}` in dimension order → **7.0**; a dimension = 0 or 11 → throws; a **pinned-weights** assertion (each mode's five constants equal the SSOT values, Σ=1.0); `band` at 8.0 / 6.0 / 4.0 / 2.0 / below → correct priority + **the exact SSOT action string** (kortform + long-form); `triage` with threshold 4.0 → kept (≥4.0, sorted composite-desc, annotated composite+band) and dropped (<4.0).
**RED proof (record in commit):** `(cd scripts/trends && npm test)` → new cases fail with assertion errors (not module-not-found).
## Step 2 — (GREEN) implement `item.ts`
Implement the `TrendItem` ingress envelope `{source,title,url,publishedAt?,topics[],summary?}` + `normalizeItem`/`normalizeItems` to pass Step 1's item cases. Reuse `normalizeField` (import from `./store.js`) for whitespace; topic normalize + dedupe. `publishedAt` validated with a strict ISO-date check; **carried, not persisted** (comment: the *source's* publish date, forward-compat for B4 freshness — distinct from the store's `capturedAt`). **Do not derive or carry `id`** — the store owns it via `addTrend``trendId`; the envelope has no id field. **Do not wire to the store** — the item→`TrendInput` bridge (`capturedAt` injection) is R2.
## Step 3 — (GREEN) implement `score.ts`
Encode the two weight-sets as `const` records mirroring `trend-scoring-modes.md` (header comment: the SSOT is the human source, "ordering is the signal, not a measured coefficient", + the SSOT path). Implement `composite` (validate each dimension ∈[1,10], weighted sum), `band` (the five-range map → `{priority, kortformAction, longformAction}` using the **exact SSOT action strings**), and `triage` (`kept`/`dropped` + composite-desc sort + per-item composite/band annotation). Make Step 1's score cases green (incl. the pinned-weights + pinned-action-string assertions).
## Step 4 — (GREEN) CLI subcommands + `cli.test.ts`
Add `normalize` and `score` to `cli.ts`'s `main` dispatch. Both **read the JSON payload from stdin** (not a flag — the existing `--json` is an *output* toggle and must keep that meaning) and **print JSON to stdout**. `normalize` → canonical items / `{ok:false}` error entries. `score``triage` with `--mode`/`--threshold`, prints `{kept, dropped}`. Exit **2** on a malformed invocation (unparseable stdin, missing required flag) via the existing `usage()` path; **0** otherwise. Write `tests/cli.test.ts` (subprocess: spawn `node --import tsx src/cli.ts <sub>` with a piped stdin payload) covering a happy path + an exit-2 bad-invocation for each new subcommand.
## Step 5 — wire `trend-spotter.md` (prose pointer)
Replace the L124-137 "score 5 dims, take the weighted composite, apply the bands yourself" instruction with: the agent supplies the five 110 judgment scores, then pipes them to **`scripts/trends/src/cli.ts score`** (the deterministic owner of composite + bands + threshold). The replacement prose **must contain the literal `src/cli.ts score`** (the exact string Step 6's `grep -qF` matches). Keep it domain-general — no vendor/sector tokens (Section 17). The agent still owns mode selection and the qualitative scoring.
## Step 6 — gate: floors + new unconditional section
In `scripts/test-runner.sh`:
- Bump `TRENDS_TESTS_FLOOR` 24 → **live recount** after Steps 14 (24 + new item/score/cli cases). It **stays inside** the `if [ -x scripts/trends/node_modules/.bin/tsx ]` deps guard (conditional — do not hoist it out; that would break fresh-clone safety).
- Add **Section 16g** (label it 16g; place it **after Section 17 / before Section 18**, since Section 18 anti-erosion must run last). Three **unconditional**, deps-absent-safe checks (pure `grep`, no `tsx`): (1) `score.ts` encodes both `kortform` and `long-form` weight-sets; (2) `grep -qF "src/cli.ts score" agents/trend-spotter.md`; (3) a non-vacuity self-test for those greps (house pattern, per Sections 16c17).
- Bump `ASSERT_BASELINE_FLOOR` 84 → **live recount** (= 84 + the count of new unconditional `pass`/`fail` emitters in 16g; expected ~87 with the self-test, **recounted at land, not pinned**). Update the section-header enumeration comment.
## Step 7 — behavioural verification
`(cd scripts/trends && npm install)` if needed, then run (verified invocation form, not `npm run start`):
`echo '<3-item sample>' | node --import tsx src/cli.ts normalize` and
`echo '<scored sample>' | node --import tsx src/cli.ts score --mode kortform --threshold 4.0`;
confirm by hand that one ≥4.0 item is kept (correct band/action) and one <4.0 is dropped. Run full `bash scripts/test-runner.sh``FAIL=0`.
## Step 8 — land
Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor, gate total). Commit order (house style: `feat … [skip-docs]` for code; plain for docs): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r1.md` (no suffix, tracked); **(2)** code commit `scripts/trends/*` + `agents/trend-spotter.md` + `scripts/test-runner.sh` with `[skip-docs]`. Check the push window (`date '+%u %H:%M'`); `origin` is the PUBLIC `open/` remote → **confirm with operator before push**. No version bump (additive; `v0.5.2` dev).
## Verification (testable)
| SC | Check | Command | Expected |
|---|---|---|---|
| — | RED proof | `(cd scripts/trends && npm test)` after Step 1 | new cases fail on assertion (logic-RED), not module-not-found |
| SC1 | normalize | `npm test` (item.test.ts) | required-field `{ok:false}` + dedupe + publishedAt + batch-partition cases green; no `id` on item |
| SC2 | composite | `npm test` (score.test.ts) | all-tens=10.0 both modes; `{10,8,6,4,2}`=7.0 both modes; pinned weights; range guard throws |
| SC3 | bands | `npm test` (score.test.ts) | 8.0/6.0/4.0/2.0 boundaries → correct priority + exact SSOT action string |
| SC4 | triage | `npm test` (score.test.ts) | kept ≥ threshold ranked desc + annotated; dropped below |
| SC5 | CLI | `npm test` (cli.test.ts) + manual stdin run | JSON out; exit 2 malformed invocation / 0 well-formed |
| SC6 | gate | `bash scripts/test-runner.sh` | `FAIL=0`; trends ≥ new floor; ASSERT floor = live recount |
| SC7 | wiring + de-niche | Section 16g greps + Section 17 | `src/cli.ts score` present in trend-spotter.md; no vendor/sector tokens; counts 19/29/27 |
## Risks
- **R1 — SSOT/code drift.** Weights, band thresholds, AND the ten action strings now live in both `trend-scoring-modes.md` and `score.ts`. *Mitigated:* `score.test.ts` pins all three (weights + Σ=1.0 + thresholds + exact action strings) against the SSOT values, with an SSOT-path comment naming the markdown as the human source. (A markdown-table-parsing cross-check is deferred — out of scope.)
- **R2 — `publishedAt` carried but not persisted; `capturedAt` not on the item.** Could read as a dangling field. *Mitigated:* explicit comment (source publish-date, forward-compat for B4) + the brief's non-goal; the store bridge (capturedAt injection) is explicitly R2; the scorer does not depend on either.
- **R3 — editing `trend-spotter.md` could trip the de-niche guard.** *Mitigated:* Section 17 runs in the gate; replacement prose is pillar-driven and vendor/sector-free.
- **R4 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* the three new checks are pure `grep`/self-test on tracked source files (no `tsx`), so they are unconditional and safe; the arithmetic proof stays inside the deps-gated suite; `TRENDS_TESTS_FLOOR` stays inside the deps guard.
- **R5 — default threshold (4.0) may not match operator intent.** *Mitigated:* single config param; brief open question #1 settles it at the go-gate.
- **R6 — CLI `--json` semantic collision.** *Mitigated:* new subcommands take payload via **stdin**, leaving `--json` as the existing output toggle; cli.test.ts encodes the stdin contract.
## Plan-critic — folded
plan-critic returned **REVISE** (1 blocker, 6 majors, 4 minors); brief-reviewer **PROCEED_WITH_RISKS**; scope-guardian **ALIGNED**. Resolution, each verified against live code:
- **[BLOCKER] `TrendInput` shape mismatch** (`store.ts:26-33` requires `capturedAt`, no `publishedAt`). ✅ Step 2 no longer claims a direct map; the item→store bridge is deferred to R2; envelope carries no `id`.
- **[MAJOR] gate-section placement / Section-18-last** ✅ Step 6 pins **16g, before Section 18**.
- **[MAJOR] `ASSERT_BASELINE_FLOOR` hard-pinned 86** ✅ Step 6 now **live recount** (~+3 with the house self-test), not pinned.
- **[MAJOR] `TRENDS_TESTS_FLOOR` could be hoisted out of the deps guard** ✅ Step 6 states it stays conditional.
- **[MAJOR] CLI `--json` input/output overload** (`cli.ts:79` output toggle) ✅ Step 4 reads payload from **stdin**.
- **[MAJOR] band action-string drift unguarded** ✅ Step 1/3 + R1 pin the thresholds + action strings.
- **[MAJOR] Step 5/6 grep literal unpinned** ✅ pinned to `src/cli.ts score` in both steps.
- **[MINOR] RED-stub strategy** ✅ Step 1 splits stub by assertion type. **[MINOR] `npm run start` unverified** ✅ Step 7 uses `node --import tsx src/cli.ts`. **[MINOR] commit grouping** ✅ Step 8 fixes order. **[MINOR] empty folded sections** ✅ filled.
- **[scope-guardian MINOR] STATE.md + `config/trends-sources.template.md`** ✅ STATE.md added as a land-bookkeeping row; template added to the scope fence.
**scope-guardian — ALIGNED:** every SC1SC7 traces to a step; zero scope creep; every NON-goal respected.