linkedin-studio/docs/research-engine/plan-re-r3a.md
Kjell Tore Guttormsen 4d3b9f4711 docs(linkedin-studio): RE-R3a brief + plan — persist relevance score + rank morning brief on it (light-Voyage hardened)
R3 slice 1 (research-deepening): persist a 4-field TrendScore {mode, dimensions,
composite, priority} on the store record (schema v2->v3, additive lossless), computed
by the already-built score.ts (composite+band, one owner), threaded item->store, and
rank rankForBrief on composite first + surface band+mode in renderBrief.

Go-gate confirmed (operator "Go"): D1 4-field envelope · D2 composite primary within
bucket · D3 first-sight only · D4 one slice · D6 mode shown per body entry.

Light-Voyage: scope-guardian ALIGNED (0) / brief-reviewer PROCEED_WITH_RISKS (6 MINOR)
/ plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR) — all folded. Headline fold: the RED
proof is now explicitly two-phase (logic-RED for store/brief/cli; stub-first then
assertion-RED for score/item, since a missing named import throws at module-load under
Node16 ESM, not on assertion).

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

321 lines
29 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-R3a: persist the relevance score + rank the morning brief on it
> **Brief:** `docs/research-engine/brief-re-r3a.md`. **Slice:** RE-R3a (research-engine rung-2 — R3 slice 1,
> research-deepening: the relevance half of hull 5 + the hull-3 schema remainder).
> **TDD-order (two-phase RED — light-Voyage BLOCKER fold):** Step 1 records RED in two phases —
> **(A)** true logic-RED for `store`/`brief`/`cli` against the pre-edit code (inline fixtures, no new import);
> **(B)** for `score`/`item`, land non-throwing stubs for the new `score.ts` exports first (Node16 ESM throws a
> missing named import at module-load, not on assertion), then record the value-assertion RED against the stubs.
> Then GREEN: S-score envelope → S-types + S-store (first-sight persist + v2→v3 migrate) → S-item (validate +
> bridge) → S-brief (composite-sort + render band+mode) → S-cli (doc-only) → wire `trend-spotter.md` + README →
> gate floors + Section 16j → behavioural → land.
> **Counts recounted live at land, never pinned/guessed.**
> **Go-gate decisions (confirmed "Go" 2026-06-24):** D1 4-field `TrendScore` · D2 composite primary within
> bucket · D3 first-sight · D4 one slice (data-then-visible commit order) · D6 mode shown in per-entry render.
> **Light-Voyage hardened:** scope-guardian ALIGNED (0) / brief-reviewer PROCEED_WITH_RISKS (6 MINOR) /
> plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR) — all folded (see §Plan-critic — folded).
## Goal
Stop discarding the relevance judgment the `trend-spotter` agent already computes. Persist a 4-field
`TrendScore { mode, dimensions, composite, priority }` on the store record (schema v2→v3, additive lossless
migrate — the R2a pattern), computed deterministically from the agent's five judgment scores by the already-built
`score.ts` (`composite`+`band`, one owner). Then make `rankForBrief` order each bucket on composite first, and
`renderBrief` surface the band + mode. No re-score-on-recapture, no saturation/status, no scheduler, no new
source file — those stay later R3 slices.
## Files touched (exhaustive — for scope-guardian)
| File | Change | SC |
|---|---|---|
| `scripts/trends/src/score.ts` | **EDIT**`TrendScore` interface + `requiredDimensions(mode)` (ordered) + `scoreEnvelope(mode, dimensions)` (composes the existing `composite`+`band`, no new arithmetic; throws on bad dim by contract) | SC1 |
| `scripts/trends/src/types.ts` | **EDIT**`import type { TrendScore }`; `TrendRecord.score?: TrendScore`; `SCHEMA_VERSION` 2→3; doc-comment | SC4 |
| `scripts/trends/src/store.ts` | **EDIT**`TrendInput.score?: TrendScore`; `addTrend` persists `score` first-sight on add (conditional spread), duplicate does NOT update; migrate comment v1→v2→v3 (logic unchanged) | SC3, SC4 |
| `scripts/trends/src/item.ts` | **EDIT**`TrendItem.score?: {mode,dimensions}`; `normalizeItem` validates (non-array `score`/`dimensions`; mode; the mode's five dims in [1,10]) → structured error, carries validated dims; `itemToInput` carries `scoreEnvelope(...)` (throws by contract on direct bad dims) | SC2 |
| `scripts/trends/src/brief.ts` | **EDIT**`rankForBrief` comparator: composite primary (`?? -1`), buckets unchanged; `renderBrief`/`renderBulletEntry` surface `· <priority> (<mode>)` (full pinned shapes); `briefSummary` band only; exact `ranking:` descriptor | SC5, SC6 |
| `scripts/trends/src/cli.ts` | **EDIT (doc-only behavior)** — capture persists score automatically via `itemToInput` (no logic change); header doc-comment note | SC7 |
| `scripts/trends/tests/score.test.ts` | **EDIT**`requiredDimensions` (both modes, ordered + order pinned) + `scoreEnvelope` (composite/priority = existing funcs; bad dim throws) | SC1 |
| `scripts/trends/tests/item.test.ts` | **EDIT** — score validation (valid carried/validated dims; bad mode/missing/out-of-range/non-array/array-dims → structured error, no throw) + `itemToInput` envelope + `itemToInput` direct bad-dim throws | SC2 |
| `scripts/trends/tests/store.test.ts` | **EDIT** — first-sight persist (new persists; duplicate keeps first score, topics union; score-free add) + v2→v3 migration (lossless/idempotent + score-survives-round-trip, mirrors `:403-476` with `2``3`) | SC3, SC4 |
| `scripts/trends/tests/brief.test.ts` | **EDIT** — composite-primary within bucket; unscored last (`-1`); total order; full render lines (`· <priority> (<mode>)` scored / unchanged unscored); summary band; unscored single-match-top summary; quote-safe summary; exact `ranking:` string; determinism | SC5, SC6 |
| `scripts/trends/tests/cli.test.ts` | **EDIT** — capture batch with score → record carries computed composite/priority (read back via `list --json`); bad score → `errors[]`, valid added, exit 0 | SC7 |
| `agents/trend-spotter.md` | **EDIT** — Step 4.5 capture batch carries per-item `"score":{"mode":"kortform","dimensions":{…}}`; prose ("carry the Step-2 scores"); domain-general; contains literal `"dimensions"` (currently absent → grep non-vacuous) | SC8 |
| `scripts/trends/README.md` | **EDIT** — item `score` field (judgment in) + persisted `TrendScore` (out) + brief ranks on composite | — |
| `scripts/test-runner.sh` | **EDIT**`TRENDS_TESTS_FLOOR` 104→recount + breakdown comment (`:701`); NEW unconditional **Section 16j** (after 16i `~:1171` / before 18 `:1173`); `ASSERT_BASELINE_FLOOR` 94→**99**; header-enumeration chain (16i clause `:46-49`, Section-18 clause `:49`) | SC8 |
| `docs/research-engine/{brief,plan}-re-r3a.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — |
| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 99, schema v3, gate total). *Land bookkeeping, LOCAL-ONLY.* | — |
**Not touched (scope fence):** `references/trend-scoring-modes.md` (SSOT weights/bands unchanged) · the
SessionStart hook + its tests (R3a touches no hook; no frontmatter-schema change; no new hook test) ·
`queryByTopic`/`history`/`newestCaptureDate` (store query unchanged) · the `score` CLI digest path + the `add`
manual path (`cli.ts`) · `config/*` · no new `.ts`/`.mjs` file · `agents/*` count (19) · `commands/*` (29) ·
`references/*` (27) · `.gitignore` (trends lines present).
## Step 1 — (RED, two phases) failing tests across score/item/store/brief/cli
RED discipline (R2a/R2b) + the light-Voyage BLOCKER fold: a missing **named** import throws at module-load under
Node16 ESM (`package.json:8` `node --import tsx --test`; `tsconfig.json` `module: Node16`), so `score`/`item`
(which reference new `score.ts` exports) cannot be assertion-RED before those exports exist. Split the RED proof:
**Phase A — true logic-RED against the pre-edit code** (`store`/`brief`/`cli` build fixtures inline; `TrendStore`/
`TrendRecord` are `import type`, erased by tsx; they import no new runtime symbol):
- `store.test.ts`: `addTrend(emptyStore, inputWithScore)` → record has `score`; a second `addTrend` (same
title+url, **different** score) → stored score **unchanged**, `added:false`, topics unioned; score-free add →
score-free record. **Migration:** a `schemaVersion:2` store with score-less records → `loadStore` gives
`schemaVersion:3`, records intact, `"score" in record === false` (not invented); round-trip writes
`schemaVersion:3`; a v3 store with `score` is idempotent; **a v3 store's `score` survives load+resave**
(read back, re-save, re-read — `score` byte-identical). *(Mirror the RE-R2a block `store.test.ts:403-476`,
retitled `(RE-R3a / score v2→v3)`, every `schemaVersion` assertion literal flipped `2``3`.)*
- `brief.test.ts`: two **same-overlap, same-freshness** records, composites 9 vs 6 → the 9 sorts first in its
bucket; a **scored** vs **unscored** same-bucket pair → scored first (the `-1` sentinel); a same-title/diff-url
**both-unscored** pair → fixed by `url asc` (total order intact); `renderBrief` for a scored top entry contains
the **full line** `… (<age>d) · <priority> (<mode>) · Pillarer: …`, for an unscored one the **unchanged** line
(no token) — assert full lines, not substrings; a scored bullet contains `… (<age>d) · <priority> (<mode>) ·
🔗 …`; `briefSummary` names the band on a scored top, omits it on an unscored top, and is one line with no `"`
**even when the top title contains a guillemet/quote**; a store whose only fresh match is a **single-pillar
unscored** record → summary with no `· <priority>` token; the `ranking:` line equals the pinned string
verbatim; two `renderBrief` calls byte-identical.
- `cli.test.ts`: a `capture` batch (subprocess, `--store` temp) with a valid per-item `score` → a following
`list --store <tmp> --json` shows the record's `score` with the computed composite/priority; a batch with one
bad score (`timing:99`) → JSON `errors[]` non-empty, the valid items added, **exit 0**.
**Phase B — stub-first, then value-assertion RED** (`score`/`item` reference new exports):
- Land **non-throwing stubs** in `score.ts` so the imports resolve: `requiredDimensions → []`; `scoreEnvelope →
{ mode, dimensions, composite: 0, priority: "Skip" }`. (These are the wrong-value stubs Step 2 replaces.)
- `score.test.ts`: `requiredDimensions("kortform")`/`("long-form")` deep-equal the two **ordered** five-key
lists (and a guard that the order matches `Object.keys(KORTFORM_WEIGHTS)` so a SSOT reorder fails);
`scoreEnvelope(mode, dims)` returns `{mode, dimensions, composite, priority}` with composite/priority **equal
to `composite(dims,mode)` / `band(...).priority`** (assert against the existing functions — not hard-coded —
so they share one owner); a bad dim makes `scoreEnvelope` throw (it calls `composite`). Fails on assertion
against the stubs (`[] ≠ expected`, `composite 0 ≠ real`).
- `item.test.ts`: a valid `score` → `normalizeItem` `ok:true` carrying the **validated** dims; bad `mode` / a
missing dimension / a dim `0` or `11` / a non-object `score` / an **array** `dimensions` → `ok:false` with an
`invalid score` error (**does not throw**); absent `score` → key omitted. `itemToInput(validItemWithScore,
"2026-06-24")` → `score` equals `scoreEnvelope(mode,dims)`; without score → no `score`; **`itemToInput` with an
out-of-range dim throws** (the defense-in-depth contract). Fails on assertion against the stubs.
**RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit →
the `store`/`brief`/`cli` new cases fail on **assertion** (logic-RED), not module-not-found. Phase B — after the
non-throwing stubs land, the `score`/`item` cases fail on **value assertion** against the stubs. The plan does
**not** claim a single "all five fail before any code" run.
## Step 2 — (GREEN) `score.ts` envelope
In `scripts/trends/src/score.ts`:
- `export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority:
Priority }`.
- `export function requiredDimensions(mode: ScoreMode): string[] { return Object.keys(WEIGHTS[mode]); }` —
**ordered** (insertion order of the SSOT weight literal); `score.test` pins the order.
- `export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore { const c =
composite(dimensions, mode); return { mode, dimensions, composite: c, priority: band(c).priority }; }` —
composes the existing pure functions; **no new arithmetic**; throws via `composite` on a bad dim (its
contract). Replace the Phase-B stubs. Make `score.test` green.
## Step 3 — (GREEN) `types.ts` + `store.ts` (schema v3 + first-sight persist)
- `types.ts`: `import type { TrendScore } from "./score.js";`; add `score?: TrendScore;` to `TrendRecord`
(doc-comment marks it as the realized `:21-23` field); `SCHEMA_VERSION = 3`.
- `store.ts`: add `score?: TrendScore;` to `TrendInput` (`import type { TrendScore } from "./score.js"`);
in `addTrend`'s new-record branch add `...(input.score !== undefined ? { score: input.score } : {})` (after the
`summary` spread `:136`); the **duplicate** branch is unchanged (topics union only — score is first-sight,
D3). Extend the `loadStore` migrate comment to *"v1→v2→v3 all additive-optional … the migration is the version
stamp alone"* — **no logic change** (`Math.max(onDisk, SCHEMA_VERSION)` `:87` already handles v3; `saveStore`
`JSON.stringify` `:95` preserves `score`). Make `store.test` green (first-sight + v2→v3 + survives-round-trip).
## Step 4 — (GREEN) `item.ts` (validate + bridge)
- `TrendItem` gains `score?: { mode: ScoreMode; dimensions: DimensionScores };` (`import type { ScoreMode,
DimensionScores } from "./score.js"` + `import { requiredDimensions } from "./score.js"`).
- `normalizeItem`: after the `publishedAt` validation (`:99-106`), if `r.score !== undefined && r.score !==
null`, validate: `score` is a **non-array** object; `mode` ∈ `{kortform, long-form}`; `dimensions` is a
**non-array** object; **for each key in `requiredDimensions(mode)`**, the value is a number in [1,10]. On any
failure `errors.push("invalid score: <reason>")`; on success build `score = { mode, dimensions }` from the
**validated** values (not raw `r.score.dimensions`). Carried into the returned `TrendItem` via conditional
spread (key omitted when absent/invalid). **Never throws** — structured errors only (the `publishedAt`
discipline).
- `itemToInput`: add `...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode,
item.score.dimensions) } : {})` (`import { scoreEnvelope } from "./score.js"`). On the **capture path** the
dims are pre-validated by `normalizeItem`, so `scoreEnvelope`→`composite` cannot throw there; called
**directly** with bad dims it throws by contract (defense-in-depth — SC2 asserts it). Make `item.test` green.
## Step 5 — (GREEN) `brief.ts` (composite sort + render band+mode)
- `rankForBrief` comparator (`:94-98`): prepend `(b.trend.score?.composite ?? -1) - (a.trend.score?.composite
?? -1) ||` before the existing `b.overlap - a.overlap || …`. **Sentinel `-1`** (composite ≥ 1.0 always; `-1`
sorts unscored last and subtracts cleanly). Buckets (`:100-104`) and totals unchanged.
- `renderTopEntry` (`:132-141`): when `e.trend.score` is present, insert `· ${e.trend.score.priority}
(${e.trend.score.mode})` into the meta line **between `(${e.ageDays}d)` and `· Pillarer`** (unscored:
unchanged). `renderBulletEntry` (`:143-145`): when scored, insert `· ${score.priority} (${score.mode})`
**before `· 🔗`** (unscored: unchanged). Both shapes asserted as **full lines** in `brief.test`.
- `briefSummary` (`:122-130`): in the fresh>0 branch, when `top.trend.score` is present include `·
${top.trend.score.priority}` in the top mention (**band only — no mode** to keep the one-line headline clean):
`… Topp: «<title>» (<pillar> · <priority> · <age>d).`; when the top is unscored omit the token:
`… Topp: «<title>» (<pillar> · <age>d).` Keep it one line, no `"`/`\n`.
- `renderBrief`'s `ranking:` line (`:160`) → the **exact pinned** string
`composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays
${ranking.freshDays}` (asserted byte-for-byte). `BRIEF_SCHEMA_VERSION` stays 1. Make `brief.test` green.
## Step 6 — (GREEN) `cli.ts` (doc-only) + `cli.test`
`capture` (`:243-269`) already folds through `itemToInput` (`:254`), which now carries `score` → capture
persists it with **no logic change**. Update only the header doc-comment (`:15-21`): note `capture` persists an
optional relevance score computed from the item's judgment scores. Make `cli.test`'s capture-persists-score +
bad-score-in-errors cases green. *(No `today()` exact-value assertions in `cli.test` — the wall clock is read at
the edge; composite/priority are deterministic and asserted on the read-back record.)*
## Step 7 — wire `trend-spotter.md` (Open Q#1 default = WIRE) + README
In `agents/trend-spotter.md` Step 4.5 (`:291-298`), extend each capture-batch item with **`"score": {"mode":
"kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}}`** — the same
five judgment scores computed in Step 2 (`:134`). Add prose: don't discard the Step-2 scores; fold them into the
capture batch so the store persists the relevance assessment and the morning brief ranks on it. Mode defaults
`kortform`; `long-form` when invoked from `/linkedin:newsletter` (the long-form dims are
`pillar/depth/angle/authority/currency`, `trend-scoring-modes.md:59-65`). The replacement prose **must contain
the literal `"dimensions"`** (Section 16j `grep -qF`; verified absent today → non-vacuous). Keep the "skip
silently if no deps" escape hatch + domain-general phrasing (Section 17). Update `scripts/trends/README.md`: the
item `score` field (judgment in), the persisted `TrendScore` (composite/priority out), and that the brief now
ranks on composite.
## Step 8 — gate: floors + new unconditional Section 16j
In `scripts/test-runner.sh`:
- Set `TRENDS_TESTS_FLOOR` (`:701`, currently 104) to the **`tests N` line** reported by `(cd scripts/trends &&
npm test)` after Steps 16 — recounted live, NOT additive-guessed. Stays **inside** the `if [ -x …/tsx ]`
deps guard. **Append** `+ RE-R3a: score +N` to the inline breakdown comment (`:701`) so it can't drift.
- Add **Section 16j** ("Trends Score Wiring", RE-R3a), mirroring Section 16i's shape (`:1122-1171`). **Placement
(verified):** file order is 17→16g→16h→16i→18 (`:947/:1014/:1078/:1122/:1173`), so **16i is the last section
before Section 18** — insert 16j **after 16i's closing `fi`/`echo ""` (~`:1171`), before the Section 18 block
(`:1173`)** (anti-erosion Section 18 must stay last so it counts every prior check). Five **unconditional**,
deps-absent-safe checks (pure `grep -qF`/self-test, no `tsx`), the self-test emitting **one** pass/fail like
16i:
(1) a non-vacuity self-test (a probe carrying `score?.composite` accepted, one without rejected);
(2) `grep -qF 'export interface TrendScore' scripts/trends/src/score.ts`;
(3) `grep -qF 'score?: TrendScore' scripts/trends/src/types.ts`;
(4) `grep -qF '"dimensions"' agents/trend-spotter.md` (the capture batch carries the judgment);
(5) `grep -qF 'score?.composite' scripts/trends/src/brief.ts` (the brief ranks on it — payoff wired, not
merely doc'd).
- Bump `ASSERT_BASELINE_FLOOR` (`:1193`, currently 94) → **exactly 99** (94 + the 5 new unconditional 16j
emitters; the self-test emits one pass/fail like 16i, so 99 is deterministic — "live recount" is the safety
net, not a guess). Update the **header-enumeration prose chain** by inserting the 16j clause **between** the
16i clause (`:46-49`) and the Section-18 clause (`:49`), preserving sentence flow (it's prose, not an append).
- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3a adds no hook test). It
must still pass untouched (`node --test hooks/scripts/__tests__/`) as a regression sanity at land.
## Step 9 — behavioural verification
`(cd scripts/trends && npm install)` if needed, then run brief §7's four behavioural steps (capture A>B with
scores, `list --json` to confirm persisted composite/priority, `brief --json` to confirm A precedes B + the band
in the summary + `· <priority> (kortform)` in the entry line, a bad-score item lands in `errors[]` with exit 0).
Run full `bash scripts/test-runner.sh` → `FAIL=0` (`ASSERT_BASELINE_FLOOR` 99); run `node --test
hooks/scripts/__tests__/` → still green (untouched regression).
## Step 10 — land
Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 99, schema v3,
gate total). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3a.md` (no
suffix, tracked); **(2)** code commit — the six `src/*.ts` + five test files + `agents/trend-spotter.md` +
`scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]`. (Per D4, the code commit MAY be split
into a data commit [score/types/store/item/cli + their tests] and a visible commit [brief + its tests + agent
wire] if the R2a/R2b two-commit rhythm is preferred.) 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 | store/brief/cli new cases fail on assertion (logic-RED), not module-not-found |
| — | RED Phase B | `npm test` after non-throwing stubs | score/item cases fail on value assertion against stubs (`[] ≠ expected`, `composite 0 ≠ real`) |
| SC1 | score envelope | `npm test` (score.test) | `requiredDimensions` both modes ordered (order pinned); `scoreEnvelope` composite/priority = existing funcs; bad dim throws |
| SC2 | item validate + bridge + contract | `npm test` (item.test) | valid score carried (validated dims); bad mode/missing/out-of-range/non-array/array-dims → structured error (no throw); `itemToInput` envelope; direct bad-dim throws |
| SC3 | first-sight persist | `npm test` (store.test) | new persists score; duplicate keeps first score (topics union); score-free add works |
| SC4 | migration v2→v3 | `npm test` (store.test) | v2 loads as v3, records intact, no score invented; round-trip writes v3; v3 idempotent; score survives load+resave |
| SC5 | brief ranks on composite | `npm test` (brief.test) + manual | composite primary within bucket; unscored last (`-1`); total order; deterministic |
| SC6 | render band+mode | `npm test` (brief.test) | full lines `· <priority> (<mode>)` scored / unchanged unscored; summary band (no mode); unscored single-match summary; quote-safe; exact `ranking:` string |
| SC7 | CLI persists score | `npm test` (cli.test) + manual | capture batch w/ score → record carries composite/priority; bad score → `errors[]`, valid added, exit 0 |
| SC8 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | `FAIL=0`; trends ≥ floor; Section 16j green; `ASSERT_BASELINE_FLOOR`=99; Section 17; counts 27/19/29 |
## Risks
- **R1 — composite/band drift from the SSOT** (someone re-implements the math in `scoreEnvelope`). *Mitigated:*
`scoreEnvelope` *composes* `composite()`+`band()` (one owner); SC1 asserts equality against those functions;
`score.test.ts:12-30` (weights) + the band-string assertions already pin them to the SSOT.
- **R2 — a bad score crashes the capture loop.** *Mitigated:* `normalizeItem` fully validates the score (mode +
the mode's five dims in [1,10], non-array objects) → structured error into `errors[]`; on the capture path
`itemToInput`→`composite` is unreachable for bad dims; SC2 + SC7 assert no-throw + `errors[]` routing.
`itemToInput` called **directly** with bad dims throws by contract (SC2) — defense-in-depth, not a leak.
- **R3 — comparator NaN from the unscored sentinel** (`-Infinity - -Infinity`). *Mitigated:* sentinel is `-1`
(composite ≥ 1.0 = min 1×Σweights); subtracts cleanly; SC5 asserts the both-unscored total order holds.
- **R4 — losing the brief's determinism** (composite ties not fully broken). *Mitigated:* the new term is a
leading tie-break; the existing `overlap → effectiveDate → title → url` chain still gives a total order (the
`(title,url)` pair is the unique dedupe id, `store.ts:66-68`); SC5 asserts byte-identical output + the
same-title/diff-url case.
- **R5 — `extractYaml` mis-reads the `summary`** if the band token introduces a `"`/newline. *Mitigated:* the
band strings (`Immediate`/`High`/…) are bare words; the summary stays one line, no `"` — asserted in SC6 even
with a guillemet/quote in the top title; the surfacing hook is untouched.
- **R6 — migration not actually lossless** (a v2 record mutated on load, or a new field stripped on resave).
*Mitigated:* the migration is the version stamp alone (logic unchanged); `saveStore` `JSON.stringify` `:95`
strips nothing; SC4 mirrors the proven R2a `store.test:403-476` and adds a score-survives-round-trip case.
- **R7 — editing `trend-spotter.md` trips the de-niche guard.** *Mitigated:* Section 17 runs in the gate; the
added `score`/`dimensions` are the rubric's generic dimension names + the user's pillars, vendor/sector-free.
- **R8 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16j is pure
`grep`/self-test on tracked source (no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps guard.
- **R9 — import cycle.** *Mitigated:* `score.ts` imports nothing internal today (`:1-17`, verified leaf). R3a
adds three new **one-way** inbound edges to it — `score.ts ← types.ts` (`TrendScore` type), `score.ts ←
store.ts` (`TrendScore` type on `TrendInput`), `score.ts ← item.ts` (`ScoreMode`/`DimensionScores` types +
`requiredDimensions`/`scoreEnvelope` values). The DAG stays acyclic: `score.ts (leaf) ← {types, store, item,
brief, cli}`, since `score.ts` imports none of them back.
- **R10 — mode-mixing makes the ranking apples-to-oranges** (kortform vs long-form composites ranked together).
*Accepted for R3a, mitigated visibly:* almost all records are `kortform` (the default); the body entry line
**shows the mode** (`<priority> (<mode>)`, D6/SC6) so the operator can see when adjacent entries used different
instruments; a mode-segmented brief is a documented R3-later non-goal (brief §4).
## Plan-critic — folded
Three Opus reviewers ran, each verifying claims against live code: **scope-guardian ALIGNED** (0 findings; counts
27/19/29 + "no new source file" verified live); **brief-reviewer PROCEED_WITH_RISKS** (all four load-bearing
claims verified TRUE; 6 MINOR); **plan-critic REVISE** (1 BLOCKER, 4 MAJOR, 4 MINOR; score 72/C). Resolution,
each verified against live code:
- **[BLOCKER — plan-critic] Step 1 RED-proof self-contradictory for score/item under ESM** (a missing named
import throws at module-load, not on assertion; the stub-first fix inverted the stated ordering). ✅ Step 1 is
now **explicitly two-phase**: Phase A true logic-RED for `store`/`brief`/`cli` against pre-edit code; Phase B
stub-first then value-assertion RED for `score`/`item`. The header blockquote + brief §5 + §TDD-order state it;
the "all five fail before any code" claim is removed.
- **[MAJOR — plan-critic] no-throw guarantee overstated** (`itemToInput` is public; direct bad-dim calls throw).
✅ Step 4 + R2 + brief §5 reword it **path-specific** (no throw on the capture path because `normalizeItem`
gates it; direct calls throw by contract); SC2 adds a direct-throw assertion + a carries-validated-dims
assertion.
- **[MAJOR — plan-critic] mode-mixing waved away + "mode shown per entry" contradicted the render spec.** ✅ D6:
the render now shows `· <priority> (<mode>)` per body entry (Step 5); brief §4 adds a mode-blind-ranking
non-goal with the rationale (mode visible, mostly kortform); SC6 asserts the full line incl. mode; R10 added.
- **[MAJOR — plan-critic] `requiredDimensions` order contract ambiguous** (SC1 hard-coded arrays vs membership
use). ✅ pinned **ordered** (Step 2 + SC1 deep-equal the SSOT-order array; `score.test` pins the order;
`normalizeItem` consumes as a set).
- **[MAJOR — plan-critic] `ASSERT_BASELINE_FLOOR` "~99" not pinned.** ✅ pinned **99** (94 + 5 unconditional 16j
emitters; the self-test emits one pass/fail like 16i) — Step 8 + SC8 + brief §3.
- **[MINOR — brief-reviewer] SC4 ref `:403-471` stale + v2 assertions** → ✅ `:403-476` + "flip every
`schemaVersion` literal 2→3" note (SC4, Step 1).
- **[MINOR — brief-reviewer] R1 SSOT-pin cite was the doc-comment** (`score.ts:9-13`) → ✅ now `score.test.ts:12-30`
(R1, brief §2/§5).
- **[MINOR — brief-reviewer] bullet `· <priority>` placement substring-only** → ✅ full pinned line shape
(priority+mode before `🔗`), asserted as a full line (Step 5, SC6).
- **[MINOR — brief-reviewer] three diverging `ranking:` descriptor strings** → ✅ one verbatim target string,
asserted byte-for-byte (Step 5, SC6).
- **[MINOR — brief-reviewer] unscored single-match-top summary path untested** → ✅ added as a Phase-A
brief.test case + SC6.
- **[MINOR — brief-reviewer] `normalizeItem` non-array object case understated in the brief** → ✅ "non-array"
added to both `score` and `dimensions` object checks (Step 4, brief §3, SC2 array-dims case).
- **[MINOR — plan-critic] header-chain line-ref `:33-49` loose** → ✅ tightened to the 16i clause `:46-49` /
Section-18 clause `:49` (Step 8).
- **[MINOR — plan-critic] R9 DAG omitted the new `score.ts ←` edges** → ✅ R9 now lists all three one-way edges.
- **[MINOR — plan-critic] SC6 quote-safety regression with the new token** → ✅ SC6 asserts the summary stays
one-line/no-`"` with a scored top title containing a guillemet/quote.
- **[MINOR — plan-critic] SC4 forward-compat / score-survives-round-trip untested** → ✅ added to SC4 + Step 1
store.test.
- **[plan-critic headless-readiness] N/A** — R3a executes **in-session, operator-driven** (driftsmodell), not as
a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a/R2b had none either).
**scope-guardian — ALIGNED:** every SC1SC8 traces to a step; zero creep; all §4 non-goals held (no
re-score-on-recapture, no saturation/status/first-mover field, no scheduler, no seen-log, no brief-diff, no
A1A4, no mode-filter, no `score` in the `add` path, no new source file/agent/command); counts 27/19/29 verified
live; exactly 6 `src/*.ts` + 5 `tests/*.test.ts`, all edited, none added.