R2 ("the visible topic-stream") split into two sequenced slices at the
2026-06-24 go-gate, foundation-first: R2a (this — the pure scripts/trends/
data layer) before R2b (dated morning-brief + session-start surfacing).
R2a builds the item→store bridge RE-R1 deferred: a pure itemToInput mapping
the validated TrendItem envelope to a store input (injecting capturedAt,
carrying publishedAt), a publishedAt schema bump (SCHEMA_VERSION 1→2) with a
lossless forward migrate-on-load, and a `capture` CLI closing the
poll→normalize→store loop. No scoring change, no hook touch.
Go-gate: WIRE (operator) — re-point trend-spotter Step 4.5 add→capture +
Section 16h grep/self-test. Q2 publishedAt-merge = first-sight, no back-fill.
Light-Voyage hardened: scope-guardian ALIGNED, brief-reviewer
PROCEED_WITH_RISKS, plan-critic REVISE — all folded (BLOCKER: v2-idempotence
is GREEN-only not RED; capture tally mapped exactly onto AddResult; "lossless"
scoped to well-formed stores; add --published-at deferred; README framing fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
18 KiB
Plan — RE-R2a: capture bridge (item→store) + publishedAt persistence
Brief:
docs/research-engine/brief-re-r2a.md. Slice: RE-R2a (research-engine rung-2, slice 2 — R2 data layer). TDD-order: RED (migration + bridge + capture tests as logic-RED) → GREEN (types.ts/store.tsmigration + persist) → GREEN (item.tsbridge) → GREEN (cli.ts capture+ tests) → wiretrend-spotter.md→ gate floors → behavioural → land. Counts recounted live at land, never pinned/guessed. Light-Voyage hardened: scope-guardian / brief-reviewer / plan-critic findings folded (see §Plan-critic — folded).
Goal
Build the item→store bridge RE-R1 deferred: a pure itemToInput mapping the validated TrendItem envelope to a
store input (injecting capturedAt, carrying publishedAt), a publishedAt schema bump (v1→v2) with a lossless
migrate-on-load, and a capture CLI that closes the poll→normalize→store loop. No scoring change; no hook touch; the
dated brief + surfacing are R2b.
Files touched (exhaustive — for scope-guardian)
| File | Change | SC |
|---|---|---|
scripts/trends/src/types.ts |
EDIT — SCHEMA_VERSION 1→2; publishedAt?: string on TrendRecord (after capturedAt, with the capturedAt-distinction comment) |
SC3 |
scripts/trends/src/store.ts |
EDIT — TrendInput +publishedAt?; addTrend persists it (conditional-spread, first-sight kept on merge); loadStore forward migrate-on-load (max(onDisk, SCHEMA_VERSION)) |
SC2, SC3 |
scripts/trends/src/item.ts |
EDIT — pure itemToInput(item, capturedAt): TrendInput (injects capturedAt; no id; no re-validate) |
SC1 |
scripts/trends/src/cli.ts |
EDIT — capture subcommand only (stdin→normalize→bridge→addTrend→saveStore→summary; exit 2 bad invocation; --json). (add --published-at deferred at light-Voyage — see brief §4.) |
SC4 |
scripts/trends/tests/store.test.ts |
EDIT — migration cases (v1→v2 lossless, idempotent, round-trip) + publishedAt persist/first-sight-merge cases |
SC2, SC3 |
scripts/trends/tests/item.test.ts |
EDIT — itemToInput mapping cases (capturedAt inject, carry-through, no id, absent publishedAt omitted) |
SC1 |
scripts/trends/tests/cli.test.ts |
EDIT — capture happy path (stdin→store), duplicate/merge, content-invalid in errors[], exit-2 bad invocation, --json summary |
SC4 |
scripts/trends/README.md |
EDIT — add publishedAt? to the record-shape block + a capture example (honest schema/CLI doc) |
— |
agents/trend-spotter.md |
EDIT (Open Q#1, default) — Step 4.5 add→capture; carries literal src/cli.ts capture; domain-general |
SC6 |
scripts/test-runner.sh |
EDIT — TRENDS_TESTS_FLOOR 62→recount (stays inside deps guard); NEW unconditional Section 16h (before Section 18); ASSERT_BASELINE_FLOOR 87→recount; anti-erosion header enumeration |
SC5, SC6 |
docs/research-engine/{brief,plan}-re-r2a.md |
NEW — slice docs (TRACKED, like docs/second-brain/*) |
— |
STATE.md |
EDIT at land — Telling-block reconcile (trends floor, ASSERT floor, gate total). Land bookkeeping, not slice scope; LOCAL-ONLY. | — |
Not touched (scope fence): scripts/trends/src/score.ts (no scoring change) · references/trend-scoring-modes.md + references/* (SSOT unchanged, no new ref) · hooks/** (no surfacing — R2b) · config/trends-sources.template.md · no new .ts source file (bridge in item.ts) · agents/* count (19) · commands/* (29) · .gitignore (trends lines present).
Step 1 — (RED) failing tests for migration + bridge + capture
Extend the three existing test files against the not-yet-changed code. Logic-RED (not import-RED), stub-by-assertion-type.
Critical RED-vs-GREEN-guard split (plan-critic blocker): against old code (SCHEMA_VERSION=1, loadStore returns
parsed.schemaVersion ?? SCHEMA_VERSION), only some assertions actually fail:
- Genuinely RED (old code fails): v1-fixture load →
schemaVersion===2(old returns 1); v1 round-triploadStore→saveStorewritesschemaVersion:2(old writes 1);addTrend({…, publishedAt})persists it (old drops it);itemToInputcarries/injects correctly (add a thin throwing stub for the not-yet-existing export so the case fails on assertion, not onundefined is not a function). - GREEN-only regression guards (pass against old code — NOT labelled RED): a v2 fixture load →
schemaVersion===2(old already returns 2 via??); missingschemaVersion→ 2 (old??already yields current); non-numericschemaVersion→ 2; empty/absent store →{schemaVersion:2,trends:[]}. These are written in Step 1 but documented as regression guards, so the RED proof is not falsely claimed for them.
store.test.ts: the RED migration cases above + the regression-guard cases; addTrend without publishedAt omits the
key; re-capture (same title+url) leaves existing publishedAt unchanged and only unions topics; absent→present
re-capture (first sighting lacked publishedAt, re-capture carries one) does NOT add it (no back-fill, Open Q#2),
merged reflects topic change alone.
item.test.ts: itemToInput(item,"2026-06-24") → capturedAt injected (==="2026-06-24"), all fields carried verbatim,
no id, item without publishedAt → input without the key; field-confusion guard: an item whose publishedAt
differs from the injected date → result.capturedAt !== result.publishedAt.
cli.test.ts: capture happy (one valid item piped → store gains it, added:1); a batch with one invalid item →
errors[] carries it, valid one added, exit 0; the summary tally sums to the input size; the captured record's
capturedAt matches /^\d{4}-\d{2}-\d{2}$/ and !== the item's publishedAt; empty/unparseable stdin → exit 2;
--json emits the summary object.
RED proof (record in commit): (cd scripts/trends && npm test) → the genuinely-RED cases fail on assertion
(logic-RED), not module-not-found; the regression-guard cases may pass pre-change (documented, not claimed RED).
Step 2 — (GREEN) schema migration: types.ts + store.ts
types.ts: SCHEMA_VERSION = 2; add publishedAt?: string to TrendRecord after capturedAt with the
distinction comment (source publish-date; forward-compat for B4; distinct from capturedAt).
store.ts: TrendInput gains publishedAt?; addTrend adds ...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}) to the new-record literal (after capturedAt); the merge branch is unchanged (topics union only — first-sight publishedAt kept, no back-fill). loadStore returns schemaVersion: Math.max(onDisk, SCHEMA_VERSION) where onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION (forward-only stamp handling string/NaN/absent → current; never crashes); the existing Array.isArray(parsed.trends) ? … : [] coercion (store.ts:79) is preserved verbatim — a corrupt trends field stays out of the losslessness claim. Make Step 1's RED migration + persist cases (and the regression guards) green.
Step 3 — (GREEN) bridge: itemToInput in item.ts
Add import type { TrendInput } from "./store.js"; (item.ts already imports the normalizeField value from there;
this adds the type — dependency direction item.ts → store.ts stays acyclic). Then replace the Step-1 throwing
stub with export function itemToInput(item: TrendItem, capturedAt: string): TrendInput returning
{ source, title, url, topics: [...item.topics], capturedAt, ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}), ...(item.summary !== undefined ? { summary: item.summary } : {}) }. No id; no re-validation (the envelope is already validated). Confirm no throwing stub survives into GREEN. Make Step 1's bridge cases green.
Step 4 — (GREEN) CLI capture + cli.test.ts
Add capture to cli.ts's main dispatch: read stdin via the existing readStdinJson() (its empty/unparseable path
already does usage()→exit 2); Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload); for each
valid item, itemToInput(item, today()) → addTrend(store, res.store…); saveStore once. Tally derived from
AddResult {added, merged} (no duplicates field, store.ts:35-41): added += res.added ? 1 : 0;
merged += (!res.added && res.merged) ? 1 : 0; duplicates += (!res.added && !res.merged) ? 1 : 0. Human summary by
default; --json prints {added, duplicates, merged, errors}. add --published-at is NOT added (deferred). Write
the cli.test.ts cases (subprocess: node --import tsx src/cli.ts capture with piped stdin + a --store temp path),
including an explicit added + merged + duplicates + errors.length === payload.length assertion and a
capturedAt shape (/^\d{4}-\d{2}-\d{2}$/) + !== publishedAt check. capturedAt exact-value assertions live in
item.test.ts (injected fixed date), never in cli.test.ts (which reads the wall clock — would be flaky).
Step 5 — wire trend-spotter.md (Open Q#1, default) + README
Replace Step 4.5's N× add block (trend-spotter.md:282-301) with: build a raw-item JSON batch (the same items
already scored), pipe it to scripts/trends/src/cli.ts capture in one call — it normalizes + folds + persists
publishedAt. Replacement prose must contain the literal src/cli.ts capture (Section 16h grep -qF). Keep the
"skip silently if no deps" escape hatch + domain-general phrasing (no vendor/sector tokens — Section 17). Update
scripts/trends/README.md: add publishedAt? to the record-shape block, add a capture CLI example, and correct
the add framing — README.md:47-64 currently calls add "the capture path"; after the re-point add is the
manual single-trend path and capture is the normalizing batch path. Fix the framing, don't just append (else
the README contradicts the new agent wiring).
Step 6 — gate: floors + new unconditional Section 16h
In scripts/test-runner.sh:
- Bump
TRENDS_TESTS_FLOOR62 → live recount after Steps 1–4. Stays inside theif [ -x …/tsx ]deps guard (conditional — do not hoist). Also update the inline breakdown comment attest-runner.sh:695(# store 24 + RE-R1: item 18 + score 16 + cli 4) — extend it with the R2a additions (e.g.+ RE-R2a: store +N + item +M + cli +K), so the comment doesn't drift from the number (the exact erosion the anti-erosion section guards against). - Add Section 16h ("Trends Capture Wiring"), mirroring Section 16g's three-check shape, after Section 17 /
before Section 18. Three unconditional, deps-absent-safe checks (pure
grep/self-test, notsx): (1) a non-vacuity self-test (accept a probe carryingsrc/cli.ts capture, reject one without it); (2)grep -qF 'command === "capture"' scripts/trends/src/cli.ts— deps-absent proof the handler exists (grep -qFexact, mirroring 16g lines 1058/1064 — notgrep -E); (3)grep -qF "src/cli.ts capture" agents/trend-spotter.md. - Bump
ASSERT_BASELINE_FLOOR87 → live recount (= 87 + the new unconditional emitters in 16h; expected ~90, recounted at land, not pinned). Update the Section-18 anti-erosion header enumeration comment.
Step 7 — behavioural verification
(cd scripts/trends && npm install) if needed, then:
echo '[{"source":"tavily","title":"X","url":"https://e/x","topics":["a"],"publishedAt":"2026-06-20"},{"title":"bad"}]' | node --import tsx src/cli.ts capture --store /tmp/r2a-trends.json --json →
confirm added:1, the invalid entry in errors[], and node … list --store /tmp/r2a-trends.json --json shows the
persisted publishedAt. Author a v1 fixture ({"schemaVersion":1,"trends":[{…no publishedAt}]}), run status --json
→ confirm count intact (migration lossless). 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): (1) docs commit docs/research-engine/{brief,plan}-re-r2a.md (no suffix, tracked); (2)
code commit scripts/trends/* + agents/trend-spotter.md + scripts/trends/README.md + scripts/test-runner.sh
with [skip-docs]. Push freely (window restriction lifted 2026-06-24; gitleaks runs at commit; origin = PUBLIC
open/ — STATE/*.local.* never pushed). 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 | bridge | npm test (item.test.ts) |
capturedAt injected (==="2026-06-24"); fields carried; no id; absent publishedAt omitted; capturedAt !== publishedAt (field-confusion guard) |
| SC2 | persist | npm test (store.test.ts) |
publishedAt persisted when present / omitted when absent; first-sight kept on merge; absent→present re-capture does NOT back-fill |
| SC3 | migrate | npm test (store.test.ts) |
v1→v2 lossless (records intact, no publishedAt invented); missing/non-numeric schemaVersion → 2; empty store → v2/[]; idempotent; round-trip writes v2 |
| SC4 | capture | npm test (cli.test.ts) + manual |
normalize+fold; errors[] for invalid; tally sums to input size; capturedAt today-shaped & ≠ publishedAt; exit 2 bad stdin / 0 well-formed; --json summary |
| SC5 | gate | bash scripts/test-runner.sh |
FAIL=0; trends ≥ new floor; Section 16h green; ASSERT floor = live recount |
| SC6 | wiring + de-niche | Section 16h greps + Section 17 | src/cli.ts capture in trend-spotter.md; no vendor/sector tokens; counts 19/29/27 |
Risks
- R1 — migration eats or rewrites records. Mitigated: migrate-on-load is a version stamp only (
max(onDisk, current)); records pass through untouched; SC3 pins lossless + idempotent + round-trip on a real v1 fixture. - R2 —
publishedAtfirst-sight vs back-fill ambiguity. Mitigated: brief Open Q#2 settles it at first-sight (no back-fill); SC2 pins "existing publishedAt unchanged on re-capture"; back-fill explicitly deferred. - R3 —
captureexit-code semantics drift (content-invalid item leaking into a non-zero exit). Mitigated: SC4 pins exit 2 = malformed invocation only; content-invalid →errors[]at exit 0; mirrorsnormalize/score. - R4 — editing
trend-spotter.mdtrips the de-niche guard. Mitigated: Section 17 runs in the gate; replacement prose is pillar/source-driven and vendor/sector-free; only the store-fold mechanism changes (add→capture). - R5 — new gate checks must survive a deps-absent fresh clone. Mitigated: Section 16h is pure
grep/self-test on tracked source (notsx) → unconditional;TRENDS_TESTS_FLOORstays inside the deps guard. - R6 — future schema downgrade (on-disk > current) silently drops unknown fields on save. Mitigated/deferred:
cannot happen pre-v3;
max()already refuses to downgrade the stamp; field-preservation-on-save is an R-future concern, noted not handled (no impossible-scenario code).
Plan-critic — folded
plan-critic returned REVISE (1 blocker, 5 majors, 5 minors); brief-reviewer PROCEED_WITH_RISKS; scope-guardian ALIGNED. Resolution, each verified against live code:
- [BLOCKER] "v2 load idempotent" is not RED (
store.ts:78??already returns 2 for a v2 fixture under oldSCHEMA_VERSION=1). ✅ Step 1 now splits genuinely-RED (v1→2 load + round-trip + addTrend-persist + itemToInput) from GREEN-only regression guards (v2-idempotence, missing/non-numeric/empty); the RED proof is claimed only for the former. - [MAJOR] capture tally mis-maps onto
AddResult(noduplicatesfield,store.ts:35-41). ✅ Step 4 pins the exact derivation (added/merged/duplicatesfromres.added+res.merged) + asum === payload.lengthtest. - [MAJOR]
TRENDS_TESTS_FLOORbreakdown comment (:695) left stale. ✅ Step 6 now extends the inline breakdown comment alongside the number. - [MAJOR]
import isValidIsoimpossible (item.ts:51private). ✅add --published-atdeferred entirely (brief §4) — no export-vs-inline decision,item.tsedit staysitemToInput-only. - [MAJOR] README
add-as-capture framing would contradict the re-point. ✅ Step 5 corrects the framing (add= manual single-trend;capture= normalizing batch), not just appends. - [MAJOR] empty "folded" placeholders. ✅ this section + brief §9 filled.
- [MINOR] stub cleanup unstated ✅ Step 3 states the throwing stub is replaced, none survives into GREEN.
[MINOR]
today()capturedAt untestable in cli.test ✅ Step 4 routes exact-value assertions toitem.test.ts, cli.test asserts shape +≠ publishedAt. [MINOR] 16h grep flag ✅ Step 6 specifiesgrep -qF. [MINOR] non-numeric schemaVersion untested ✅ added to Step 1 regression guards + SC3. [MINOR] README under[skip-docs]✅ kept in the code commit (it documents the shipped code, like R1'strend-spotter.md); noted. - [brief-reviewer MAJOR] capturedAt injection unverified at the new ingress ✅ SC1 + SC4 field-confusion guards.
[brief-reviewer MAJOR] "lossless" over-claim ✅ §5/§3 scope it to well-formed stores; malformed-
trendscoercion unchanged + out of scope. [brief-reviewer MINOR] absent→present back-fill ✅ pinned by an SC2 test. - [plan-critic headless-readiness 60] N/A — R2a is executed in-session, operator-driven (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1's plan had none either).
scope-guardian — ALIGNED: every SC1–SC6 traces to a step; zero creep (README is in-change documentation); every §4 non-goal held (no hook touch, no brief artifact, no relevance/saturation/status, no back-fill, no scoring change).