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
13 KiB
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.throwsfails 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 1–10 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_FLOOR24 → live recount after Steps 1–4 (24 + new item/score/cli cases). It stays inside theif [ -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, notsx): (1)score.tsencodes bothkortformandlong-formweight-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 16c–17). - Bump
ASSERT_BASELINE_FLOOR84 → live recount (= 84 + the count of new unconditionalpass/failemitters 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.mdandscore.ts. Mitigated:score.test.tspins 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 —
publishedAtcarried but not persisted;capturedAtnot 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.mdcould 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 (notsx), so they are unconditional and safe; the arithmetic proof stays inside the deps-gated suite;TRENDS_TESTS_FLOORstays 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
--jsonsemantic collision. Mitigated: new subcommands take payload via stdin, leaving--jsonas 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]
TrendInputshape mismatch (store.ts:26-33requirescapturedAt, nopublishedAt). ✅ Step 2 no longer claims a direct map; the item→store bridge is deferred to R2; envelope carries noid. - [MAJOR] gate-section placement / Section-18-last ✅ Step 6 pins 16g, before Section 18.
- [MAJOR]
ASSERT_BASELINE_FLOORhard-pinned 86 ✅ Step 6 now live recount (~+3 with the house self-test), not pinned. - [MAJOR]
TRENDS_TESTS_FLOORcould be hoisted out of the deps guard ✅ Step 6 states it stays conditional. - [MAJOR] CLI
--jsoninput/output overload (cli.ts:79output 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 scorein both steps. - [MINOR] RED-stub strategy ✅ Step 1 splits stub by assertion type. [MINOR]
npm run startunverified ✅ Step 7 usesnode --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 SC1–SC7 traces to a step; zero scope creep; every NON-goal respected.