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
Brief — RE-R2a: capture bridge (item→store) + publishedAt persistence
Slice: RE-R2a (research-engine rung-2, slice 2 — the data layer of R2). The first of the two halves R2 ("the visible topic-stream") was split into at the 2026-06-24 go-gate: R2a = the pure
scripts/trends/data layer (this brief); R2b = the dated morning-brief artifact + session-start surfacing (separate brief, after R2a lands). The split was chosen because (b)+(c) are code-independent of (a), R2a mirrors R1's pure-TDD shape exactly (lowest risk), and it closes the capture loop the store was built for. Predecessor: RE-R1 (brief-re-r1.md) delivered the validated ingress envelope (item.ts:normalizeItem/normalizeItems) and the triage scorer (score.ts) behind a CLI seam, and explicitly deferred the item→store bridge ("injecting the store'scapturedAtand persistingpublishedAt— is R2 orchestration",brief-re-r1.md§3 B1). R2a builds exactly that deferred bridge. Substrate:docs/research-engine-concepts.local.md§1 (hull 3 "store-schema mangler felt" / the item→store gap), §3 B1 (one schema downstream never branches on). TDD-order: RED tests land before code.
1. Operator decision context (2026-06-24)
The research engine is Tier-1 (operator, 2026-06-23): "hele min arbeidsflyt hviler på at jeg får en jevn strøm av gode forslag til tema å skrive om." At the R2 go-gate (2026-06-24) the operator chose to build R2 as two sequenced slices, foundation-first: R2a (this — the bridge + schema, pure trends/) before R2b (the visible brief + surfacing). R2a delivers no visible change; it closes the capture loop so the store accumulates publish-dated history that R2b's brief and R3's freshness window will rank on.
2. The gap — grounded in code
normalize(R1) and the store are disconnected.item.tsvalidates a raw source item into the canonicalTrendItemenvelope{source,title,url,publishedAt?,topics[],summary?}(item.ts:21-38), but nothing folds that envelope into the store. The only ingress to the store iscli.ts add(cli.ts:111-135), which builds aTrendInputfrom raw flags — it never callsnormalizeItem, so a capture path that polls → normalizes → stores does not exist. The bridge is the missing link R1 named and deferred.publishedAtis carried but dropped. The envelope carries the source's own publish date (item.ts:28-33, "NOT persisted in R1"), butTrendRecord(types.ts:26-41) has nopublishedAtfield andaddTrend(store.ts:111-130) never persists it. So every capture loses the publish date — the field B4 freshness (R3) and R2b's "fresh today" sort will both need. R2a persists it now, before history accumulates undated, so the loop is publish-dated from this slice forward.SCHEMA_VERSIONis pinned at 1 with no migration path exercised.loadStore(store.ts:74-81) readsparsed.schemaVersion ?? SCHEMA_VERSIONbut the store has never had a second version, so the forward-migration path is untested. AddingpublishedAtis the first additive-optional schema bump (v1→v2) and the right moment to prove a lossless migrate-on-load.
3. Scope — what is IN (RE-R2a)
B-bridge — itemToInput (pure mapping, scripts/trends/src/item.ts, EDIT)
A pure itemToInput(item: TrendItem, capturedAt: string): TrendInput that maps the validated envelope to a
store input by injecting capturedAt (the store's "when WE saw it", supplied by the caller — never derived
inside the pure function) and carrying publishedAt/topics/summary/title/url/source through verbatim.
It does not re-validate (the item is already validated by normalizeItem) and does not derive an id
(the store owns id via addTrend→trendId). Lives in item.ts (it is the item's mapping to the store; item.ts
already imports from store.ts) — not a new single-function bridge.ts (single-use abstraction).
Schema migration — publishedAt persisted (types.ts + store.ts, EDIT)
types.ts:SCHEMA_VERSION 1 → 2; addpublishedAt?: stringtoTrendRecord(aftercapturedAt), with a comment distinguishing it fromcapturedAt(source publish-date; forward-compat for B4 freshness).store.ts:TrendInputgainspublishedAt?: string;addTrendpersists it when present (same conditional-spread idiom assummary,store.ts:126). On re-capture/merge it is NOT overwritten — it joins the store's documented "FIRST sighting's source + capturedAt are kept (provenance of first sight)" rule (store.ts:108-110); no back-fill (a deferred enhancement, §4).loadStore: forward migrate-on-load — stamp the in-memory store toMath.max(onDisk, SCHEMA_VERSION), whereonDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION(a string /NaN/ absent version falls back to current, never crashes). v1→v2 is purely additive-optional (old records are already valid v2 records that simply lack the optional field), so the migration is the stamp alone — no record rewrite; lossless + idempotent for any well-formed v1/v2 store. A subsequentsaveStorepersists the v2 stamp. The existing non-arraytrendscoercion (store.ts:79,Array.isArray(parsed.trends) ? … : []) is UNCHANGED and out of R2a's migration scope — losslessness is claimed only for well-formed stores, not for a corrupttrendsfield. (No downgrade branch: a hypothetical future on-disk > current can't happen yet — Karpathy #2, no handling for impossible scenarios; noted as a deferred risk.)
CLI capture subcommand (scripts/trends/src/cli.ts, EDIT)
echo '<raw item | batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json] — reads the raw
JSON payload from stdin (same stdin contract as normalize/score, so --json stays an output toggle),
runs it through normalizeItem/normalizeItems, maps each valid item via itemToInput(item, today()), folds
each into the store via addTrend, and saveStores once. Emits a summary — {added, duplicates, merged, errors}
- store path/count — human-readable by default, JSON with
--json. Content-invalid items are reported in the summary (errors[]), never via the exit code; exit 2 only on a malformed invocation (unparseable/empty stdin), matchingnormalize/score. Note the contract difference fromadd:captureitems must carrysource(the normalizer requires it — no "manual" default), because a capture is from a real source.
Wiring + gate (see Open Question #1 — included by default, trimmable to minimal)
agents/trend-spotter.md(EDIT): re-point Step 4.5 (trend-spotter.md:282-301) from N× flag-basedaddto a single batchcapture— the agent builds a raw-item JSON batch (it already builds JSON forscore) and pipes it tocapturein one call. Strictly better thanadd(it normalizes + carriespublishedAt; one call, not N). The replacement prose carries the literalsrc/cli.ts capture. Kept domain-general (no vendor/sector tokens).scripts/test-runner.sh(EDIT): bumpTRENDS_TESTS_FLOOR(62 → live recount, stays inside the deps guard). Add Section 16h ("Trends Capture Wiring"), mirroring Section 16g's three-check shape, placed after Section 17 / before Section 18 (anti-erosion last): (1) a non-vacuity self-test; (2)grepthatcli.tshas acapturehandler (deps-absent proof the path exists); (3)grep -qF "src/cli.ts capture" agents/trend-spotter.md. These are unconditional → bumpASSERT_BASELINE_FLOOR87 → live recount (expected ~90).
4. Non-goals — what is OUT (deferred)
- The dated morning-brief artifact (B3) — R2b. R2a closes the capture loop; R2b makes the stream visible.
- Session-start surfacing of the brief (hull 4) — R2b. R2a does not touch
hooks/**. publishedAtback-fill on re-capture (fill an absent existingpublishedAtfrom a later sighting) — deferred; first-sight provenance is kept, matching the existing merge rule. Revisit if undated-first-sight proves common.add --published-atflag — deferred (was a proposed "minor"; folded out at light-Voyage). The flag-basedaddis the manual single-trend path; it stays publish-date-free for now. The store layer still GAINSpublishedAt(onTrendInput+addTrend) — that is whatcaptureneeds — but exposing it on theaddCLI (and theisValidIso-export-vs-inline decision it would force) is out of R2a. Trivial to add any later slice.relevance/saturation/status/ lifecycle fields (hull 5) — R3. R2a adds onlypublishedAt.- Freshness window / dedup-vs-seen-log / autonomous trigger (B4) — R3.
- Research-deepening (A1–A4), adapter sub-agents, MCP fetch fan-out — R2b/R3.
- A store-reading brief / ranking on accumulated relevance — R2b/R3 (needs persisted scores).
5. Boundaries / invariants (must hold)
- TDD iron law: the failing migration /
itemToInput/capturetests land BEFORE the implementation. RED proofs recorded (logic-RED, not import-RED). NB: only the v1→v2 load + round-trip are genuinely RED (oldloadStorereturns 1); v2-idempotence + missing/non-numeric-schemaVersionpass against old code, so they are GREEN-only regression guards, not RED cases (see plan Step 1). - Lossless migration (well-formed stores): every existing well-formed v1 store loads as v2 with records
intact (no
publishedAtinvented, no topic/summary/capturedAt/url/title change); idempotent (v2 → v2). A corrupttrendsfield is coerced by the existing (unchanged)Array.isArrayguard — explicitly out of R2a's losslessness claim, not a regression introduced here. - First-sight provenance preserved: re-capturing an existing trend never overwrites its
publishedAt,capturedAt, orsource— only topics union (unchanged from R1). - No scoring change:
score.tsandreferences/trend-scoring-modes.md(the SSOT) are untouched — R2a is the data layer, not the scorer. - Domain-general: de-niche guard (Section 17) stays green; no vendor/sector tokens enter the edited
trend-spotter.mdprose. - House conventions: ESM +
node:test+tsx; data-seam stays inline; no new.mjsunderhooks/scripts/(R2a touches no hook);.gitignorealready coversscripts/trends/{node_modules,build}. - No new
references/*.md, no new agent/command (counts stay 19/29/27); no new.tssource file (bridge lives initem.ts). Brief+plan live underdocs/(uncounted), TRACKED likedocs/second-brain/*. - Counts recounted live at land, never pinned/guessed.
6. Success criteria (testable)
- SC1 (bridge) —
itemToInput(item, "2026-06-24")returns aTrendInputwithcapturedAtinjected (=== "2026-06-24"),publishedAt/topics/summary/title/url/sourcecarried verbatim, and noid. AbsentpublishedAton the item → absent on the input (key omitted, notundefined-valued). Field-confusion guard: for an item whosepublishedAtdiffers from the injectedcapturedAt, the result'scapturedAt !== result.publishedAt(proves the bridge never confuses the two dates — the whole point of the slice). - SC2 (persist) —
addTrendwith apublishedAtpersists it on the new record; a record without it omits the key. On a re-capture (same title+url) the existingpublishedAtis unchanged (first-sight kept), and only topics union —mergedreflects topic change alone. No back-fill: a re-capture carrying apublishedAtonto a record that lacked one does not add it (Open Q#2) — the absent→present case is the one where "first-sight kept" is counterintuitive, so it is pinned by a test. - SC3 (migrate) —
loadStoreon a v1 store fixture ({schemaVersion:1, trends:[…without publishedAt]}) returnsschemaVersion === 2with every record intact and nopublishedAtinvented. Also pinned: a store with missingschemaVersion→ stamped 2 (records intact); an empty/absent store →{schemaVersion:2, trends:[]}; a non-numericschemaVersion("weird"/NaN) → falls back to 2 (records intact); idempotent on a v2 store; a round-triploadStore→saveStorewritesschemaVersion: 2. - SC4 (capture CLI) —
echo '<batch>' | … capturenormalizes + folds: a well-formed item is added (or reported duplicate/merged), a content-invalid item appears in the summaryerrors[], the store file is written once, and the summary counts are correct —added + merged + duplicates + errors.length === payload.length(the tally is derived fromAddResult {added, merged}, which has noduplicatesfield:added=res.added,merged=!res.added && res.merged,duplicates=!res.added && !res.merged). The captured record'scapturedAtis a today-shaped ISO date distinct from the item'spublishedAt(field-confusion guard at the ingress; the exact-value assertion lives initem.test.tswith an injected date, sincecapturereads the wall clock). Exit 2 on empty/unparseable stdin; 0 on a well-formed call even with content-invalid items.--jsonemits the summary object. - SC5 (gate) —
(cd scripts/trends && npm test)green at the bumpedTRENDS_TESTS_FLOOR; new Section 16h green;ASSERT_BASELINE_FLOORbumped to the live recount; overall gateFAIL=0. - SC6 (wiring + de-niche) —
trend-spotter.mdreferencessrc/cli.ts capture(Section 16h grep green); de-niche guard (Section 17) green; structure counts unchanged (19/29/27). (If Open Q#1 → minimal: SC6 drops the wiring/16h clauses; de-niche + counts still asserted.)
7. Verification
Deterministic (gate): bash scripts/test-runner.sh → FAIL=0; trends suite ≥ new floor; new Section 16h
self-test + greps pass; Section 17 de-niche green; ref/agent/command counts unchanged.
Behavioural (manual): echo '<2-item batch incl. one publishedAt + one invalid>' | node --import tsx src/cli.ts capture --store /tmp/r2a-trends.json --json; confirm the valid item lands with publishedAt persisted, the invalid
one is in errors[], and node … list --store /tmp/r2a-trends.json --json shows the persisted publishedAt.
Then load an authored v1 fixture and confirm status --json reports it migrated (count intact).
8. Open questions for the go-gate
- Wire
trend-spotter.md+ add Section 16h, or keep R2a minimal? — RESOLVED at go-gate (operator, 2026-06-24): WIRE. Build R2a with the agent re-point (Step 4.5add→capture) + Section 16h grep/self-test + ASSERT-floor bump. The minimal alternative (bridge code + tests only, no agent edit) was declined — wiring mirrors R1's "the lift is real and grep-able" discipline and makes the bridge actually used (one normalizingcapturevs N×add). publishedAtmerge policy. Proposed keep first-sight (no back-fill), matching the existing source/capturedAt provenance rule (now pinned by the absent→present SC2 test). Confirm, or request back-fill-if-absent (adds a branch- a
mergedsemantic question).
- a
add --published-atflag — RESOLVED at light-Voyage: deferred (see §4). The manualaddstays publish-date-free;captureis the path that carriespublishedAt. Noted here only so the resolution is traceable; no go-gate action.
9. Light-Voyage review — folded
Three Opus reviewers ran on the drafts, each verifying claims against live code. scope-guardian: ALIGNED
(every SC1–SC6 traces to a step; zero creep; all §4 non-goals held; 2 minors). brief-reviewer:
PROCEED_WITH_RISKS (2 majors, 4 minors). plan-critic: REVISE (1 blocker, 5 majors, 5 minors). All findings
folded; see plan-re-r2a.md §Plan-critic — folded for the per-step resolution:
- [BLOCKER, folded] "v2 load idempotent" cannot be a RED case — old
loadStore(store.ts:78) returnsparsed.schemaVersion ?? SCHEMA_VERSION, so a v2 fixture already loads as 2 against unchanged code. → §6 SC3 + §5 now split the migration cases: only v1→2 + round-trip are RED; v2-idempotence + missing/non-numeric are GREEN-only regression guards. - [MAJOR, folded] "lossless / byte-for-byte intact" over-claimed vs
loadStore's non-arraytrendscoercion (store.ts:79). → §5 + §3 scope losslessness to well-formed v1/v2 stores; the coercion is unchanged + out of scope (not a regression). - [MAJOR, folded]
capturedAtinjection unverified at the newcaptureingress (the field-confusion the slice exists to prevent). → SC1 + SC4 now pincapturedAt !== publishedAt(item.test exact-value; cli.test shape + distinct). - [MAJOR, folded]
capturetally{added, duplicates, merged}mis-maps ontoAddResult {added, merged}(noduplicatesfield). → SC4 pins the exact derivation +sum === payload.length. - [MAJOR, folded]
add --published-atvalidation referenced the non-exportedisValidIso(item.ts:51). →add --published-atdeferred entirely (§4), removing the export-vs-inline decision and keepingitem.ts's edit toitemToInputonly. - [MAJOR, folded] README "Capture …
add" framing would contradict the agent'sadd→capturere-point. → plan Step 5 now corrects the README framing (add= manual single-trend;capture= normalizing batch), not just appends. - [MAJOR, folded] empty "folded" placeholders shipped in the doc bodies. → this section + plan §Plan-critic now filled.
- [MINOR, folded in plan] missing-/non-numeric-
schemaVersiontest, absent→present back-fill test, stub-replacement note,import type { TrendInput }line,grep -qFfor the 16h literal,TRENDS_TESTS_FLOORbreakdown-comment update, README in the code commit, headless per-step clauses N/A (in-session execution).