linkedin-studio/docs/research-engine/plan-re-r3b.md
Kjell Tore Guttormsen c40b937856 docs(linkedin-studio): RE-R3b brief + plan — trend lifecycle (re-score · status · seen-log), light-Voyage hardened
Slice (a) of the full-R3 build-out: the lifecycle layer over the trend store.
- re-score on re-capture (last-wins; R3a's explicit deferral)
- status new/acted/skipped (act/skip/reset CLI; brief excludes handled)
- seen-log surfacedCount/lastSurfacedAt (per-day idempotent, brief-recorded)

Architecture confirmed via AskUserQuestion: on-record seen-log + brief records
surfacing (rankForBrief stays pure, --no-mark dry-run) · last-score-wins ·
exclude acted/skipped. score.ts + item.ts untouched (re-score reuses the R3a
capture path); touched: types/store/brief/cli + schema v3->v4.

Light-Voyage hardened (3 Opus reviewers vs live code): scope-guardian ALIGNED;
brief-reviewer PROCEED_WITH_RISKS; plan-critic PROCEED_WITH_RISKS (78/B). All
folded — incl. the MAJOR (the brief CLI store binding hoist) + the v3->v4
migration-block reconcile (premise-verified before drafting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 00:44:47 +02:00

36 KiB
Raw Blame History

Plan — RE-R3b: trend lifecycle — re-score on re-capture · status (acted/skipped) · seen-log

Brief: docs/research-engine/brief-re-r3b.md. Slice: RE-R3b (research-engine rung-2 — R3 slice 2, the lifecycle slice: re-score-on-recapture + status new/acted/skipped + the surfacedCount/lastSurfacedAt seen-log). TDD-order (two-phase RED — light-Voyage BLOCKER fold, inherited from R3a): Step 1 records RED in two phases — (A) true logic-RED for the re-score + migration parts of store.test (existing addTrend/loadStore, inline fixtures), all of brief.test (behaviour change to existing rankForBrief/renderBrief), and cli.test (subprocess — act/skip are unknown commands today → assertion-RED); (B) for setStatus/markSurfaced/ effectiveStatus (new store.ts exports), land non-throwing stubs first (Node16 ESM throws a missing named import at module-load, not on assertion), then record value-assertion RED against the stubs. Then GREEN: S-store stubs→real (effectiveStatus + setStatus + markSurfaced + re-score) → S-types (3 fields + v3→v4) + the R3a-migration-block reconcile → S-brief (status filter + id/marker render + surfacedIds + descriptor) → S-cli (act/skip/reset + brief-marks-surfaced + --no-mark) → wire trend-spotter.md (prose) + README → gate floors + Section 16k → behavioural → land. Counts recounted live at land, never pinned/guessed. Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-25): A1 on-record seen-log, brief records surfacing (rankForBrief pure, --no-mark) · A2 re-score last-wins (score the one mutable field; status not reset) · A3 acted/skipped EXCLUDED from the brief. Go-gate D1D9 baked to recommended defaults (brief §8). Light-Voyage hardened: scope-guardian ALIGNED (0 creep / 0 gaps; 2 MINOR line-cites) · brief-reviewer PROCEED_WITH_RISKS (all 9 load-bearing claims TRUE; 1 MEDIUM + 3 LOW) · plan-critic PROCEED_WITH_RISKS (78/B; 1 MAJOR + 5 MINOR) — all folded (see §Plan-critic — folded). The MAJOR (the brief CLI store binding) is fixed by the Step-5 hoist.

Goal

Give a trend a life after first capture. (1) Re-score on re-captureaddTrend's duplicate branch now refreshes score (last-wins; timing decays), reusing the already-built capture path (no new arithmetic, score.ts untouched). (2) Status lifecyclenew/acted/skipped on the record, set by new act/skip/reset CLI verbs; the brief excludes handled trends. (3) Seen-logsurfacedCount/lastSurfacedAt accumulated (per-day-idempotent) by the brief CLI after the pure ranking, the temporal foundation slices (c)+(b) build on. Schema v3→v4 (additive lossless migrate — the R3a pattern, plus reconciling the R3a migration block's hard-coded 3 literals that the bump would otherwise regress). No saturation scoring, no scheduler, no brief-diff, no new source file — those stay later R3 slices.

Files touched (exhaustive — for scope-guardian)

File Change SC
scripts/trends/src/types.ts EDITexport type TrendStatus; TrendRecord gains status?/surfacedCount?/lastSurfacedAt? (all optional); SCHEMA_VERSION 3→4; doc-comment SC1, SC4
scripts/trends/src/store.ts EDITeffectiveStatus(t) (absent⇒new); setStatus(store,id,status); markSurfaced(store,ids,today) (per-day idempotent); addTrend duplicate branch re-scores (last-wins, merged broadened, no false-merge); migrate comment v1→…→v4 (logic unchanged) SC1, SC2, SC3, SC4
scripts/trends/src/brief.ts EDITrankForBrief excludes effectiveStatus !== "new"; renderTopEntry/renderBulletEntry append · \`+surfacedToken (· sett Nxwhen count≥2);surfacedIds(ranking); ranking:descriptor gains; excludes acted/skipped` SC5, SC6
scripts/trends/src/cli.ts EDITact/skip/reset --id (setStatus, not-found→exit 2); brief hoists const store = loadStore(storePath) + records surfacing via markSurfaced+saveStore unless --no-mark, --json gains marked; exit-code doc-comment (:33) + capture tally comment (:251-252) broadened; usage + header doc SC7, SC8
scripts/trends/tests/store.test.ts EDIT — effectiveStatus; setStatus (found/absent/reset); markSurfaced (increment/idempotent/skip); re-score (last-wins/no-false-merge/status+provenance untouched/no-score-noop); NEW (RE-R3b / lifecycle v3→v4) migration block + reconcile the existing (RE-R3a / score v2→v3) block's 3 literals SC1SC4
scripts/trends/tests/brief.test.ts EDIT — exclude acted/skipped (all buckets; totals.trends full; only-handled→empty); render full lines (· \`; · sett Nxat count≥2 / none below);surfacedIds; exact ranking:` string; determinism SC5, SC6
scripts/trends/tests/cli.test.ts EDIT — act/skip/reset (read back via list --json; unknown id→exit 2; missing --id→exit 2); brief marks surfaced (surfacedCount:1+lastSurfacedAt, marked in json); second-same-day idempotent (marked:0); --no-mark (no write); brief md omits acted/skipped SC7, SC8
scripts/trends/src/item.ts UNTOUCHED — re-score reuses the existing itemToInputscoreEnvelope bridge (R3a). Listed to assert it is not in scope.
scripts/trends/src/score.ts UNTOUCHED — no scoring-math change. Listed to assert it is not in scope.
agents/trend-spotter.md EDIT (prose-only, minimal) — one line: re-capture refreshes the score (timing decays); operator marks acted/skipped via the CLI so the brief stops re-surfacing handled work. No batch-shape change (score already emitted, R3a). Domain-general.
scripts/trends/README.md EDIT — status lifecycle + verbs, seen-log (per-day idempotent, brief-recorded), re-score last-wins, brief exclude-handled + --no-mark
scripts/test-runner.sh EDITTRENDS_TESTS_FLOOR 146→recount + breakdown comment (:705); NEW unconditional Section 16k (after 16j's block ~:1235 / before Section 18 header :1237); ASSERT_BASELINE_FLOOR 99→105; header-enumeration chain SC9
docs/research-engine/{brief,plan}-re-r3b.md NEW — slice docs (TRACKED, like docs/second-brain/*)
STATE.md EDIT at land — Telling-block reconcile (trends floor, ASSERT floor 105, schema v4, gate total). Land bookkeeping, LOCAL-ONLY.

Not touched (scope fence): references/trend-scoring-modes.md (no scoring-math change) · score.ts + item.ts (re-score reuses the R3a capture path) · the SessionStart hook + its tests (no hook change; no frontmatter-schema change; BRIEF_SCHEMA_VERSION stays 1; no new hook test) · queryByTopic/history/newestCaptureDate (store query unchanged) · the CLI status staleness reader (unrelated to the new TrendStatus type — the name collision is pre-existing, not reconciled here) · config/* · no new .ts/.mjs file · agents/* count (19) · commands/* (29) · references/* (27) · .gitignore (trends lines present) · the two inert schemaVersion: 2 fixture literals — brief.test.ts:53 (rankForBrief ignores schemaVersion) AND cli.test.ts:247 (the seed store is re-stamped to current on capture/brief load; no test reads its on-disk version — cli.test.ts:102 uses the SCHEMA_VERSION constant) — both left as-is (folded — brief-reviewer #3: enumerated so "miss none" is literally true).

Step 1 — (RED, two phases) failing tests across store/brief/cli

Same RED discipline as R3a (two-phase, light-Voyage BLOCKER fold): a missing named import throws at module-load under Node16 ESM, so the store.test cases that reference the new setStatus/markSurfaced/ effectiveStatus exports cannot be assertion-RED before stubs exist. Split:

Phase A — true logic-RED against the pre-edit code (uses existing addTrend/loadStore/saveStore/ rankForBrief/renderBrief; TrendStore/TrendRecord are import type, erased by tsx; subprocess for cli):

  • store.test.tsre-score (uses existing addTrend): addTrend(store, dupSameTitleUrlDifferentScore) → stored score replaced, merged:true, added:false, topics unioned, source/capturedAt/publishedAt unchanged; a byte-identical re-score (no new topics) → merged:false; a duplicate with no score → stored score unchanged; re-capture of a record with status:"acted" (constructed inline) → score updated, status still "acted", surfacedCount untouched. (These fail against pre-edit addTrend, which never touches score on a duplicate.)
  • store.test.tsNEW migration block (RE-R3b / lifecycle v3→v4) (uses existing loadStore/saveStore, hard-coded 4 so it is RED while SCHEMA_VERSION is still 3 — the genuine-RED device; the target+idempotent assertions are switched to SCHEMA_VERSION at GREEN, Step 3, per brief-reviewer #4): a schemaVersion:3 store with score-bearing, lifecycle-field-less records → loadStore gives schemaVersion:4, records intact, "status" in record === false (+ surfacedCount/lastSurfacedAt absent — none invented); round-trip writes schemaVersion:4; a schemaVersion:4 store with lifecycle fields loads idempotent (stays 4, fields deep-equal); the three new fields survive load→save→load.
  • brief.test.tsexclude (uses existing rankForBrief): a store with new + acted + skipped matches all fresh+on-pillar → only the new records appear in any bucket; totals.trends equals the full count (incl. handled); a store whose only matches are acted/skipped → all buckets empty + the "no fresh signals" briefSummary. render (uses existing renderBrief): a top entry's meta line ends · \`(full line); a bullet ends· ``(full line); a record withsurfacedCount:3shows· sett 3x(full line), one withsurfacedCount:1/absent shows **no** surfaced token (full line); surfacedIds(ranking)(new export — see Phase B note) … **[moved to Phase B]**; theranking:line equals the pinned string ending; excludes acted/skipped; two renderBrief` calls byte-identical.
  • cli.test.tsact/skip/reset (subprocess, --store temp): act --id <id> then list --json → record status:"acted"; skip"skipped"; reset"new"; an unknown id → exit 2 + store unchanged; a missing --id → exit 2. brief-marks-surfaced: brief --store <tmp> on fresh matches → list --json shows the surfaced trends surfacedCount:1 + today's lastSurfacedAt, the brief --json carries marked:<n>; a second same-day briefmarked:0, counts unchanged; brief --no-markmarked:0, no surfacedCount written; the brief .md omits an acted record. re-score tally (folded — plan-critic #4): capture a scored item, then capture the same title+url with a changed score → the second capture --json reports merged:1 and list --json shows the updated composite. (Fail today: act/skip/reset are unknown commands → usage exit 2 but no status set; brief does not write surfacedCount / emit marked; a re-capture with a changed score is a plain duplicate (score discarded), not merged.)

Phase B — stub-first, then value-assertion RED (store.test/brief.test reference new exports):

  • Land non-throwing stubs so the imports resolve: in store.tseffectiveStatus → "new" (constant), setStatus → { store, found:false }, markSurfaced → { store, marked:0 }; in brief.tssurfacedIds → []. (Wrong-value stubs Step 2/4 replace.)
  • store.test.ts: effectiveStatus({…status:"acted"}) is "acted" (stub returns "new" → RED); setStatus(store, presentId, "skipped"){ found:true } and the record's status set (stub found:false → RED); markSurfaced(store,[idA],today) increments + sets lastSurfacedAt, marked:1, and is per-day idempotent on a re-call (stub marked:0, no mutation → RED).
  • brief.test.ts: surfacedIds(ranking) deep-equals the ids of topMatches singleMatches olderMatched.slice(0,5) (stub [] → RED).

RED proof (record in commit, two phases): Phase A — (cd scripts/trends && npm test) before any src edit → the re-score/migration/brief/cli new cases fail on assertion (logic-RED), not module-not-found. Phase B — after the non-throwing stubs land, the effectiveStatus/setStatus/markSurfaced/surfacedIds cases fail on value assertion against the stubs. The plan does not claim a single "everything fails before any code" run.

Step 2 — (GREEN) types.ts lifecycle type+fields, then store.ts functions + re-score

(Reordered — folded plan-critic #5: the type must exist before store.ts uses it.) First, in scripts/trends/src/types.ts — add export type TrendStatus = "new" | "acted" | "skipped"; and the three optional TrendRecord fields (status?: TrendStatus, surfacedCount?: number, lastSurfacedAt?: string) with doc-comments marking them the realized lifecycle fields the :22 note anticipated. Do NOT bump SCHEMA_VERSION here — the bump lands in Step 3, atomic with the R3a-block reconcile (so the suite is never bumped-but-unreconciled).

Then in scripts/trends/src/store.ts (replace the Phase-B stubs):

  • import type { TrendStatus } from "./types.js"; (type-only — no cycle; the DAG stays score ← types ← store, acyclic).
  • export function effectiveStatus(t: TrendRecord): TrendStatus { return t.status ?? "new"; }.
  • export function setStatus(store, id, status): { store: TrendStore; found: boolean }const t = store.trends.find((x) => x.id === id); if (!t) return { store, found: false }; t.status = status; return { store, found: true };. Sets "new" explicitly on a reset.
  • export function markSurfaced(store, ids: string[], today: string): { store: TrendStore; marked: number }const wanted = new Set(ids); let marked = 0; for (const t of store.trends) { if (!wanted.has(t.id)) continue; if (t.lastSurfacedAt === today) continue; t.surfacedCount = (t.surfacedCount ?? 0) + 1; t.lastSurfacedAt = today; marked++; } return { store, marked };. Per-day idempotent.
  • Re-score in addTrend's duplicate branch (:127-131): after existing.topics = topics; compute let changed = topicsChanged; (rename the unionTopics result) and add: if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) { existing.score = input.score; changed = true; }; return { store, added: false, merged: changed };. Broaden the AddResult.merged doc-comment to "the existing record was mutated — topics unioned and/or score refreshed." The new-record branch is unchanged. Make the Phase-A re-score + Phase-B lifecycle store.test cases green.

Step 3 — (GREEN) SCHEMA_VERSION 3→4 bump — ATOMIC with the R3a-block reconcile

The bump + every test it touches land in one step (verified-correct by plan-critic #2): the suite is never bumped-but-unreconciled. (At RED, Step 1, only the new R3b block was failing — hard-coded 4 while SCHEMA_VERSION was still 3; the existing R3a block was green at 3===3. This step flips SCHEMA_VERSION to 4, which would regress the R3a block's 3 literals UNLESS reconciled here — hence atomic.)

  • types.ts: SCHEMA_VERSION = 4. Extend the store.ts loadStore migrate comment to "v1→v2→v3→v4 all additive-optional … the migration is the version stamp alone" (no logic change — Math.max(onDisk, SCHEMA_VERSION) already stamps v4; saveStore preserves the new fields).
  • Reconcile the existing (RE-R3a / score v2→v3) block (store.test.ts:558-650) — the bump to 4 regresses its hard-coded 3 literals (a v3 store now migrates to 4, so "v3 idempotent" is no longer true). The brief-reviewer's full enumeration confirmed exactly three breaking assertions (:588/:602/:628); apply the minimal, intent-preserving fix (align to the R2a block's SCHEMA_VERSION discipline, :493):
    • :588 assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3")SCHEMA_VERSION + message "must migrate to the current version"; flip the test title at :571 ("…loads stamped as v3" → "…to the current version") and the stale :570 comment ("…≠ 3").
    • :602 assert.equal(onDisk.schemaVersion, 3)SCHEMA_VERSION; flip the test title at :598 ("…writes schemaVersion:3 to disk" → "…writes the current schemaVersion", mirroring the reconciled R2a twin :502).
    • :606 test "a v3 store … loads idempotent"retitle "a v3 store migrates to the current version, score preserved" and change :628 assert.equal(s.schemaVersion, 3)SCHEMA_VERSION (the assert.deepEqual(score, …) stays — score survives the migration). (The v4-idempotent guarantee now lives in the new R3b block.)
    • :638 test "a v3 store's score survives load→save→load" → verified it asserts only the score field (:663, no version literal) — no change.
  • The new (RE-R3b / lifecycle v3→v4) block (landed RED in Step 1 with hard-coded 4): switch its target + idempotent version assertions from hard-4 to SCHEMA_VERSION (folded — brief-reviewer #4: now == 4 and future-proof, so R3c's v4→v5 bump won't have to reconcile this block — breaking the cycle R3b pays for R3a). The v3 input fixtures stay literal 3 (they are old-version inputs). (The hard-4 was only the genuine-RED device for the Step-1 run; record that RED proof in the commit log.)
  • Make the new (RE-R3b / lifecycle v3→v4) block + the reconciled R3a block both green; the full suite green after the bump (SC4).

Step 4 — (GREEN) brief.ts (exclude handled + id/marker render + surfacedIds)

  • import { effectiveStatus } from "./store.js"; (brief.ts already imports defaultStorePath from there — one-way).
  • rankForBrief entry loop (:82-92): add if (effectiveStatus(trend) !== "new") continue; immediately before the if (overlap === 0) continue; (:89). totals.trends stays store.trends.length (:116). Buckets + the composite total order (R3a) unchanged.
  • surfacedToken(e) helper (after scoreToken, :141-145): const c = e.trend.surfacedCount; return c && c >= 2 ? · sett ${c}x : "";. Prior-day semantic (folded — plan-critic #3): the CLI records today's surfacing after renderBrief, so surfacedCount here is the prior-day count — · sett Nx = "shown on N prior distinct days" (today's appearance is recorded but not yet in this render). Document it in the README; brief.test asserts the token by setting surfacedCount directly, the cross-day behaviour by §8.
  • renderTopEntry (:147-156) meta line (:150): append ${surfacedToken(e)} after ${scoreToken(e)} and · \${e.trend.id}`at the very end (afterPillarer: …`).
  • renderBulletEntry (:158-160): append ${surfacedToken(e)} after ${scoreToken(e)} and · \${e.trend.id}`at the very end (after🔗 ${e.trend.url}`).
  • export function surfacedIds(ranking: BriefRanking): string[]return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id); (mirrors the render's :199 .slice(0, 5)). Replace the Phase-B stub.
  • renderBrief's ranking: line (:175) → append ; excludes acted/skipped to the pinned string. briefSummary
    • BRIEF_SCHEMA_VERSION unchanged. Make brief.test green.

Step 5 — (GREEN) cli.ts (act/skip/reset + brief marks surfaced + --no-mark)

  • Import setStatus, markSurfaced from ./store.js; surfacedIds from ./brief.js.
  • A shared setStatusCmd(status: TrendStatus) inline helper (or three branches): read flags.id; if missing/"true"usage('<cmd> needs --id <id>'); const store = loadStore(storePath); const res = setStatus(store, flags.id, status); if (!res.found) { console.error(error: no trend with id: ${flags.id}); process.exit(2); } saveStore(storePath, store); console.log(Marked ${flags.id} ${status});. Wire command === "act""acted", "skip""skipped", "reset""new".
  • Broaden the exit-code doc-comment (folded — plan-critic #2): cli.ts:33 "2 on usage error (incl. unparseable stdin / bad flag)""2 on usage error or a not-found id (act/skip/reset)". A wrong --id value is an argument-class error (exit 2), distinct from capture's data items (cli.ts:31errors[], never the exit code). No third code introduced.
  • brief branch (:274-298): hoist the load (folded — plan-critic #1 / brief-reviewer #1): cli.ts:286 is const ranking = rankForBrief(loadStore(storePath), …) — there is no store variable (verified). Replace with const store = loadStore(storePath); const ranking = rankForBrief(store, pillars, day, { freshDays });. Then after writeFileSync(path, md, "utf8") (:290), add: const mark = flags["no-mark"] !== "true"; const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0; if (mark) saveStore(storePath, store); — the hoisted store holds the full inventory, so acted/skipped records (filtered from the ranking, still in the store) are preserved on resave; the .md is rendered from the pure ranking before the mutation. Add marked to the --json object (:293) and the human summary line.
  • Update the capture tally comment (folded — plan-critic #4): cli.ts:251-252 "merged (existing gained topics)""merged (existing gained topics and/or a refreshed score)" (no tally-logic change).
  • usage() (:82-91) + header synopsis (:5-13): add act/skip/reset --id <id> and [--no-mark]; a one-line header note (lifecycle verbs set status; the brief excludes handled trends + records surfacing; re-capture refreshes the score). Make cli.test green (incl. the re-captured-changed-score → merged:1 tally assertion, plan-critic #4).

Step 6 — wire trend-spotter.md (prose) + README

In agents/trend-spotter.md: add one prose line (no batch-shape change — the per-item score is already emitted, R3a): re-capturing a known trend now refreshes its relevance score (timing decays), and the operator marks trends acted/skipped via the CLI (act/skip --id) so the morning brief stops re-surfacing handled work. Domain-general (Section 17). Update scripts/trends/README.md: the status lifecycle (new/acted/skipped

  • act/skip/reset), the seen-log (surfacedCount/lastSurfacedAt, per-day idempotent, recorded by brief), re-score-on-recapture (last-wins), and the brief's exclude-handled + --no-mark behaviour.

Step 7 — gate: floors + new unconditional Section 16k

In scripts/test-runner.sh:

  • Set TRENDS_TESTS_FLOOR (:705, currently 146) to the tests N line reported by (cd scripts/trends && npm test) after Steps 16 — recounted live, NOT additive-guessed. Stays inside the deps guard. Append + RE-R3b: lifecycle +N to the inline breakdown comment.
  • Add Section 16k ("Trends Lifecycle Wiring", RE-R3b), mirroring Section 16j (header :1177, block runs through ~:1235). Placement (verified live — scope-guardian / brief-reviewer): 16j is the last section before Section 18 (anti-erosion, header :1237) — insert 16k after 16j's block (~:1235), before Section 18 (:1237) (anti-erosion must stay last so it counts every prior check). Six unconditional, deps-absent-safe checks (pure grep -qF/self-test, no tsx), the self-test emitting one pass/fail like 16j: (1) a non-vacuity self-test (a probe carrying effectiveStatus accepted, one without rejected); (2) grep -qF 'export type TrendStatus' scripts/trends/src/types.ts; (3) grep -qF 'surfacedCount' scripts/trends/src/types.ts (the seen-log field); (4) grep -qF 'export function markSurfaced' scripts/trends/src/store.ts (the seen-log writer); (5) grep -qF 'effectiveStatus' scripts/trends/src/brief.ts (the brief excludes handled); (6) grep -qF 'command === "act"' scripts/trends/src/cli.ts (the lifecycle verb).
  • Bump ASSERT_BASELINE_FLOOR (:1259, currently 99) → exactly 105 (99 + the 6 new unconditional 16k emitters; the self-test emits one pass/fail like 16j, so 105 is deterministic — "live recount" is the safety net, not a guess). Update the header-enumeration prose chain (:49-53) by inserting the 16k clause between the 16j clause and the Section-18 clause, preserving sentence flow.
  • NOT touched here: the hook suite (no HOOK_TESTS_FLOOR in test-runner.sh; R3b adds no hook test). It must still pass untouched (node --test hooks/scripts/__tests__/*.test.mjs) as a regression sanity at land.

Step 8 — behavioural verification

(cd scripts/trends && npm install) if needed, then run brief §7's seven behavioural steps (capture A; re-capture A with a lower timing → list --json shows the composite dropped + merged:1; brief --jsonmarked:1, list shows surfacedCount:1+lastSurfacedAt, entry line shows · \`; re-run brief → marked:0; act --id → A absent from the brief md;reset --id → A reappears;--no-mark→ no surfacedCount written). Run fullbash scripts/test-runner.shFAIL=0 (ASSERT_BASELINE_FLOOR105); runnode --test hooks/scripts/tests/*.test.mjs` → still green (untouched regression).

Step 9 — land

Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 105, schema v4, gate total). Commit order (house style): (1) docs commit docs/research-engine/{brief,plan}-re-r3b.md (no suffix, tracked); (2) code commit — the three src/*.ts (types/store/brief/cli — four) + three test files + agents/trend-spotter.md + scripts/trends/README.md + scripts/test-runner.sh with [skip-docs] (D9: single code commit — the lifecycle is tightly coupled). 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 re-score/migration/brief/cli new cases fail on assertion (logic-RED), not module-not-found
RED Phase B npm test after non-throwing stubs effectiveStatus/setStatus/markSurfaced/surfacedIds fail on value assertion against stubs
SC1 status + effectiveStatus + setStatus npm test (store.test) effectiveStatus absent⇒new; setStatus found/absent; reset⇒new
SC2 re-score last-wins npm test (store.test) dup w/ diff score → replaced, merged:true, provenance+status+surfaced untouched; identical→merged:false; no-score→unchanged; acted stays acted
SC3 markSurfaced + idempotency npm test (store.test) increments + sets lastSurfacedAt; same-day re-call marked:0; later day increments; unknown id skipped
SC4 migration v3→v4 npm test (store.test) v3 loads as v4, intact, no field invented; round-trip writes v4; v4 idempotent; new fields survive; R3a block reconciled (no regression)
SC5 brief excludes handled npm test (brief.test) only new in buckets; totals.trends full; only-handled→empty + no-fresh summary; surviving order = R3a total order
SC6 render id + marker + descriptor npm test (brief.test) full lines · \`; · sett Nxat count≥2 / none below; surfacedIds set; exactranking:ending; excludes acted/skipped`; deterministic
SC7 CLI act/skip/reset npm test (cli.test) + manual act→acted, skip→skipped, reset→new (via list --json); unknown id→exit 2; missing --id→exit 2
SC8 CLI brief marks + --no-mark + exclude npm test (cli.test) + manual surfacedCount:1 + lastSurfacedAt + marked in json; second same-day marked:0; --no-mark no write; md omits acted/skipped
SC9 gate + wiring + de-niche bash scripts/test-runner.sh FAIL=0; trends ≥ floor; Section 16k green; ASSERT_BASELINE_FLOOR=105; Section 17; counts 27/19/29; hook suite green

Risks

  • R1 — the v3→v4 bump silently regresses the R3a migration block (its hard-coded 3 literals; "v3 idempotent" is false after the bump). Mitigated: Step 3 explicitly reconciles the (RE-R3a / score v2→v3) block (flip 3SCHEMA_VERSION, retitle the idempotent test to a forward-migration test); SC4 asserts the full suite green after the bump. This is the load-bearing migration subtlety — caught by premise verification before drafting, not after.
  • R2 — re-score corrupts provenance / status / the seen-log (over-broad mutation in addTrend). Mitigated: the duplicate branch touches only topics + score; source/capturedAt/publishedAt/status/ surfacedCount/lastSurfacedAt are untouched; SC2 asserts each is preserved (incl. an acted-trend re-capture keeping status:"acted").
  • R3 — false-merge inflation (a re-capture with an identical score reported as merged). Mitigated: the JSON.stringify(existing.score) !== JSON.stringify(input.score) guard (the envelope is built in a fixed key order by scoreEnvelope, so the compare is stable); SC2 asserts an identical re-score → merged:false.
  • R4 — non-idempotent surfacing (re-running today's brief double-counts; the autonomous loop (c) would inflate surfacedCount). Mitigated: markSurfaced skips records whose lastSurfacedAt === today; SC3 + SC8 assert a same-day re-call → marked:0, counts unchanged.
  • R5 — the brief CLI's new store write loses data (acted/skipped records dropped on resave, or the brief written from a mutated ranking). Mitigated: markSurfaced mutates only the surfacedCount/lastSurfacedAt of the surfaced ids on the already-loaded full store; saveStore writes the whole store (handled records preserved); the .md is rendered from the pure ranking before the mutation; SC8 asserts the md omits acted/skipped AND the store still contains them with surfacing recorded.
  • R6 — rankForBrief loses purity (the status filter or surfacing leaking fs/mutation into the pure render). Mitigated: the filter is a pure read of effectiveStatus; the seen-log write is only in the brief CLI edge, guarded by --no-mark; SC5/SC6 assert deterministic, byte-identical render; markSurfaced/setStatus/ surfacedIds/effectiveStatus are all pure (no fs).
  • R7 — losing the brief's determinism (the id/marker tokens or the status filter perturbing the total order). Mitigated: the id + surfacedToken are deterministic reads; the filter only removes records, preserving the R3a composite total order on the survivors; SC5/SC6 assert byte-identical output.
  • R8 — editing trend-spotter.md trips the de-niche guard. Mitigated: Section 17 runs in the gate; the added prose is generic lifecycle wording (acted/skipped/"refresh the score"), vendor/sector-free.
  • R9 — new gate checks must survive a deps-absent fresh clone. Mitigated: Section 16k is pure grep/self-test on tracked source (no tsx) → unconditional; TRENDS_TESTS_FLOOR stays inside the deps guard.
  • R10 — import cycle. Mitigated: the new edges are one-way: store.ts ← types.ts (TrendStatus type into store, via types), brief.ts → store.ts (effectiveStatus value — brief already imports store), cli.ts → {store,brief} (already). score.ts/item.ts are untouched. The DAG stays acyclic: score (leaf) ← types ← store ← brief ← cli.
  • R11 — the status name collision (the CLI status staleness subcommand vs the new TrendStatus lifecycle). Accepted: the subcommand reads store staleness (newest capture), the type is the per-record lifecycle — no shared code; the collision is pre-existing and cosmetic; not reconciled here (documented non-goal).

Plan-critic — folded

Three Opus reviewers ran, each verifying claims against live code: scope-guardian ALIGNED (0 creep, 0 gaps; counts 27/19/29 + score.ts/item.ts-untouched + A1/A2/A3-consistency verified live; 2 MINOR plan line-cites); brief-reviewer PROCEED_WITH_RISKS (all 9 load-bearing claims TRUE; the v3→v4 reconcile complete for every breaking literal, enumerated; 1 MEDIUM + 3 LOW); plan-critic PROCEED_WITH_RISKS (78/B) (the two-phase RED, the atomic bump+reconcile, the merged broadening's non-regression, the surfacedIds formula, and the gate arithmetic all verified correct; 1 MAJOR + 5 MINOR). Resolution, each verified against live code:

  • [MAJOR — plan-critic #1 / MEDIUM — brief-reviewer #1] the brief CLI's store binding does not exist. cli.ts:286 inlines rankForBrief(loadStore(storePath), …) — no const store, so the markSurfaced(store, …) / saveStore(storePath, store) edit referenced an undefined identifier (would not compile). Step 5 now hoists const store = loadStore(storePath); const ranking = rankForBrief(store, …);; the brief §3 S-cli + A1 wording + R5 corrected to the hoisted binding.
  • [MINOR — plan-critic #2] not-found id → exit 2 contradicted the documented exit-code contract (cli.ts:33 "2 on usage error"; cli.ts:31 data-conditions never via exit code). Step 5 broadens the :33 doc-comment to "2 on usage error or a not-found id (act/skip/reset)" (a wrong --id is an argument-class error); no third code introduced.
  • [MINOR — plan-critic #3] · sett Nx off-by-one (render precedes the surfacing mutation → the token reflects the prior-day count). the prior-day semantic is now explicit (Step 4 + brief §3 + README): · sett Nx = "shown on N prior distinct days"; the brief.test sets surfacedCount directly, §8 exercises the cross-day path.
  • [MINOR — plan-critic #4] capture tally comment stale + the re-score CLI tally untested. Step 5 updates the cli.ts:251-252 comment (merged = topics score-refresh); Step 1 + SC2 add a subprocess assertion that a re-captured changed-score item reports merged:1 with the updated composite.
  • [MINOR — plan-critic #5] Step 2 used TrendStatus before Step 3 defined it. reordered: Step 2 adds the TrendStatus type + the three fields to types.ts first (then the store.ts functions); Step 3 isolates the atomic SCHEMA_VERSION bump + the R3a reconcile.
  • [MINOR — plan-critic #6 / brief-reviewer LOW] stale R3a test titles + comment (store.test.ts:571/:598 titles, :570 comment still say "v3" after the bump). Step 3 flips both titles to "the current version" + refreshes the comment, alongside the :588/:602/:628 assertion flips.
  • [LOW — brief-reviewer #4] forward-debt: the new R3b migration block hard-coded 4 (perpetuating the reconcile-cycle). Step 3 commits the new block's target + idempotent assertions against SCHEMA_VERSION (future-proof; the hard-4 is the Step-1 RED device only); the v3 input fixtures stay literal 3.
  • [LOW — brief-reviewer #3] cli.test.ts:247 inert fixture not enumerated. added to the scope-fence enumeration alongside brief.test.ts:53 ("miss none" now literally true).
  • [MINOR — scope-guardian] two plan line-cites for the gate placement (~:1191/:1262). corrected to ~:1235 (after 16j's block) / :1237 (Section 18 header) in Step 7 + the Files-touched table.

scope-guardian — ALIGNED: every SC1SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held (no saturation scoring, no scheduler, no brief-diff, no A1A4, no status/surfaced input on capture, no act/skip-by-title, no auto-act-on-publish, no new source/agent/command, BRIEF_SCHEMA_VERSION unchanged); counts 27/19/29 verified live; score.ts/item.ts untouched verified (itemToInput already builds the envelope on every capture incl. re-capture); the R3a-block reconcile is a necessary prerequisite the bump forces, not creep.

[plan-critic headless-readiness] N/A — R3b executes in-session, operator-driven (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a/R2b/R3a had none either).