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
36 KiB
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 + statusnew/acted/skipped+ thesurfacedCount/lastSurfacedAtseen-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 ofstore.test(existingaddTrend/loadStore, inline fixtures), all ofbrief.test(behaviour change to existingrankForBrief/renderBrief), andcli.test(subprocess —act/skipare unknown commands today → assertion-RED); (B) forsetStatus/markSurfaced/effectiveStatus(newstore.tsexports), 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) → wiretrend-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 D1–D9 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 (thebriefCLIstorebinding) is fixed by the Step-5 hoist.
Goal
Give a trend a life after first capture. (1) Re-score on re-capture — addTrend's duplicate branch now
refreshes score (last-wins; timing decays), reusing the already-built capture path (no new arithmetic, score.ts
untouched). (2) Status lifecycle — new/acted/skipped on the record, set by new act/skip/reset CLI
verbs; the brief excludes handled trends. (3) Seen-log — surfacedCount/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 |
EDIT — export type TrendStatus; TrendRecord gains status?/surfacedCount?/lastSurfacedAt? (all optional); SCHEMA_VERSION 3→4; doc-comment |
SC1, SC4 |
scripts/trends/src/store.ts |
EDIT — effectiveStatus(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 |
EDIT — rankForBrief 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 |
EDIT — act/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 |
SC1–SC4 |
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 itemToInput→scoreEnvelope 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 |
EDIT — TRENDS_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.ts— re-score (uses existingaddTrend):addTrend(store, dupSameTitleUrlDifferentScore)→ storedscorereplaced,merged:true,added:false, topics unioned,source/capturedAt/publishedAtunchanged; a byte-identical re-score (no new topics) →merged:false; a duplicate with noscore→ stored score unchanged; re-capture of a record withstatus:"acted"(constructed inline) → score updated,statusstill"acted",surfacedCountuntouched. (These fail against pre-editaddTrend, which never touchesscoreon a duplicate.)store.test.ts— NEW migration block(RE-R3b / lifecycle v3→v4)(uses existingloadStore/saveStore, hard-coded4so it is RED whileSCHEMA_VERSIONis still 3 — the genuine-RED device; the target+idempotent assertions are switched toSCHEMA_VERSIONat GREEN, Step 3, per brief-reviewer #4): aschemaVersion:3store with score-bearing, lifecycle-field-less records →loadStoregivesschemaVersion:4, records intact,"status" in record === false(+surfacedCount/lastSurfacedAtabsent — none invented); round-trip writesschemaVersion:4; aschemaVersion:4store with lifecycle fields loads idempotent (stays 4, fields deep-equal); the three new fields survive load→save→load.brief.test.ts— exclude (uses existingrankForBrief): a store withnew+acted+skippedmatches all fresh+on-pillar → only thenewrecords appear in any bucket;totals.trendsequals the full count (incl. handled); a store whose only matches areacted/skipped→ all buckets empty + the "no fresh signals"briefSummary. render (uses existingrenderBrief): 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; tworenderBrief` calls byte-identical.cli.test.ts— act/skip/reset (subprocess,--storetemp):act --id <id>thenlist --json→ recordstatus:"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 --jsonshows the surfaced trendssurfacedCount:1+ today'slastSurfacedAt, thebrief --jsoncarriesmarked:<n>; a second same-daybrief→marked:0, counts unchanged;brief --no-mark→marked:0, nosurfacedCountwritten; the brief.mdomits anactedrecord. re-score tally (folded — plan-critic #4):capturea scored item, thencapturethe same title+url with a changed score → the secondcapture --jsonreportsmerged:1andlist --jsonshows the updated composite. (Fail today:act/skip/resetare unknown commands →usageexit 2 but no status set;briefdoes not writesurfacedCount/ emitmarked; a re-capture with a changed score is a plainduplicate(score discarded), notmerged.)
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.ts—effectiveStatus → "new"(constant),setStatus → { store, found:false },markSurfaced → { store, marked:0 }; inbrief.ts—surfacedIds → []. (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 (stubfound:false→ RED);markSurfaced(store,[idA],today)increments + setslastSurfacedAt,marked:1, and is per-day idempotent on a re-call (stubmarked:0, no mutation → RED).brief.test.ts:surfacedIds(ranking)deep-equals the ids oftopMatches ∪ 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 staysscore ← 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 areset.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): afterexisting.topics = topics;computelet changed = topicsChanged;(rename theunionTopicsresult) 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 theAddResult.mergeddoc-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 lifecyclestore.testcases 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 thestore.tsloadStoremigrate 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;saveStorepreserves the new fields).- Reconcile the existing
(RE-R3a / score v2→v3)block (store.test.ts:558-650) — the bump to 4 regresses its hard-coded3literals (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'sSCHEMA_VERSIONdiscipline,:493)::588assert.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:570comment ("…≠ 3").:602assert.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).:606test "a v3 store … loads idempotent" → retitle "a v3 store migrates to the current version, score preserved" and change:628assert.equal(s.schemaVersion, 3)→SCHEMA_VERSION(theassert.deepEqual(score, …)stays — score survives the migration). (The v4-idempotent guarantee now lives in the new R3b block.):638test "a v3 store's score survives load→save→load" → verified it asserts only thescorefield (:663, no version literal) — no change.
- The new
(RE-R3b / lifecycle v3→v4)block (landed RED in Step 1 with hard-coded4): switch its target + idempotent version assertions from hard-4toSCHEMA_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 literal3(they are old-version inputs). (The hard-4was 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 importsdefaultStorePathfrom there — one-way).rankForBriefentry loop (:82-92): addif (effectiveStatus(trend) !== "new") continue;immediately before theif (overlap === 0) continue;(:89).totals.trendsstaysstore.trends.length(:116). Buckets + the composite total order (R3a) unchanged.surfacedToken(e)helper (afterscoreToken,: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 afterrenderBrief, sosurfacedCounthere 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.testasserts the token by settingsurfacedCountdirectly, 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'sranking:line (:175) → append; excludes acted/skippedto the pinned string.briefSummaryBRIEF_SCHEMA_VERSIONunchanged. Makebrief.testgreen.
Step 5 — (GREEN) cli.ts (act/skip/reset + brief marks surfaced + --no-mark)
- Import
setStatus,markSurfacedfrom./store.js;surfacedIdsfrom./brief.js. - A shared
setStatusCmd(status: TrendStatus)inline helper (or three branches): readflags.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});. Wirecommand === "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--idvalue is an argument-class error (exit 2), distinct fromcapture's data items (cli.ts:31—errors[], never the exit code). No third code introduced. briefbranch (:274-298): hoist the load (folded — plan-critic #1 / brief-reviewer #1):cli.ts:286isconst ranking = rankForBrief(loadStore(storePath), …)— there is nostorevariable (verified). Replace withconst store = loadStore(storePath); const ranking = rankForBrief(store, pillars, day, { freshDays });. Then afterwriteFileSync(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 hoistedstoreholds the full inventory, so acted/skipped records (filtered from the ranking, still in the store) are preserved on resave; the.mdis rendered from the purerankingbefore the mutation. Addmarkedto the--jsonobject (:293) and the human summary line.- Update the
capturetally 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): addact/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). Makecli.testgreen (incl. the re-captured-changed-score →merged:1tally 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 bybrief), re-score-on-recapture (last-wins), and the brief's exclude-handled +--no-markbehaviour.
Step 7 — gate: floors + new unconditional Section 16k
In scripts/test-runner.sh:
- Set
TRENDS_TESTS_FLOOR(:705, currently 146) to thetests Nline reported by(cd scripts/trends && npm test)after Steps 1–6 — recounted live, NOT additive-guessed. Stays inside the deps guard. Append+ RE-R3b: lifecycle +Nto 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 (puregrep -qF/self-test, notsx), the self-test emitting one pass/fail like 16j: (1) a non-vacuity self-test (a probe carryingeffectiveStatusaccepted, 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_FLOORintest-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 --json → marked: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.sh→FAIL=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
3literals; "v3 idempotent" is false after the bump). Mitigated: Step 3 explicitly reconciles the(RE-R3a / score v2→v3)block (flip3→SCHEMA_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 onlytopics+score;source/capturedAt/publishedAt/status/surfacedCount/lastSurfacedAtare untouched; SC2 asserts each is preserved (incl. an acted-trend re-capture keepingstatus:"acted"). - R3 — false-merge inflation (a re-capture with an identical score reported as
merged). Mitigated: theJSON.stringify(existing.score) !== JSON.stringify(input.score)guard (the envelope is built in a fixed key order byscoreEnvelope, 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:markSurfacedskips records whoselastSurfacedAt === 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:
markSurfacedmutates only thesurfacedCount/lastSurfacedAtof the surfaced ids on the already-loaded full store;saveStorewrites the whole store (handled records preserved); the.mdis rendered from the purerankingbefore the mutation; SC8 asserts the md omits acted/skipped AND the store still contains them with surfacing recorded. - R6 —
rankForBriefloses purity (the status filter or surfacing leaking fs/mutation into the pure render). Mitigated: the filter is a pure read ofeffectiveStatus; the seen-log write is only in thebriefCLI edge, guarded by--no-mark; SC5/SC6 assert deterministic, byte-identical render;markSurfaced/setStatus/surfacedIds/effectiveStatusare 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 +
surfacedTokenare 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.mdtrips 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 (notsx) → unconditional;TRENDS_TESTS_FLOORstays inside the deps guard. - R10 — import cycle. Mitigated: the new edges are one-way:
store.ts ← types.ts(TrendStatustype intostore, viatypes),brief.ts → store.ts(effectiveStatusvalue — brief already imports store),cli.ts → {store,brief}(already).score.ts/item.tsare untouched. The DAG stays acyclic:score (leaf) ← types ← store ← brief ← cli. - R11 — the
statusname collision (the CLIstatusstaleness subcommand vs the newTrendStatuslifecycle). 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
briefCLI'sstorebinding does not exist.cli.ts:286inlinesrankForBrief(loadStore(storePath), …)— noconst store, so themarkSurfaced(store, …)/saveStore(storePath, store)edit referenced an undefined identifier (would not compile). ✅ Step 5 now hoistsconst 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:31data-conditions never via exit code). ✅ Step 5 broadens the:33doc-comment to "2 on usage error or a not-found id (act/skip/reset)" (a wrong--idis an argument-class error); no third code introduced. - [MINOR — plan-critic #3]
· sett Nxoff-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"; thebrief.testsetssurfacedCountdirectly, §8 exercises the cross-day path. - [MINOR — plan-critic #4]
capturetally comment stale + the re-score CLI tally untested. ✅ Step 5 updates thecli.ts:251-252comment (merged= topics ∪ score-refresh); Step 1 + SC2 add a subprocess assertion that a re-captured changed-score item reportsmerged:1with the updated composite. - [MINOR — plan-critic #5] Step 2 used
TrendStatusbefore Step 3 defined it. ✅ reordered: Step 2 adds theTrendStatustype + the three fields totypes.tsfirst (then thestore.tsfunctions); Step 3 isolates the atomicSCHEMA_VERSIONbump + the R3a reconcile. - [MINOR — plan-critic #6 / brief-reviewer LOW] stale R3a test titles + comment (
store.test.ts:571/:598titles,:570comment still say "v3" after the bump). ✅ Step 3 flips both titles to "the current version" + refreshes the comment, alongside the:588/:602/:628assertion 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 againstSCHEMA_VERSION(future-proof; the hard-4is the Step-1 RED device only); the v3 input fixtures stay literal3. - [LOW — brief-reviewer #3]
cli.test.ts:247inert fixture not enumerated. ✅ added to the scope-fence enumeration alongsidebrief.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 SC1–SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held (no
saturation scoring, no scheduler, no brief-diff, no A1–A4, 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).