linkedin-studio/docs/research-engine/brief-re-r3a.md
Kjell Tore Guttormsen 4d3b9f4711 docs(linkedin-studio): RE-R3a brief + plan — persist relevance score + rank morning brief on it (light-Voyage hardened)
R3 slice 1 (research-deepening): persist a 4-field TrendScore {mode, dimensions,
composite, priority} on the store record (schema v2->v3, additive lossless), computed
by the already-built score.ts (composite+band, one owner), threaded item->store, and
rank rankForBrief on composite first + surface band+mode in renderBrief.

Go-gate confirmed (operator "Go"): D1 4-field envelope · D2 composite primary within
bucket · D3 first-sight only · D4 one slice · D6 mode shown per body entry.

Light-Voyage: scope-guardian ALIGNED (0) / brief-reviewer PROCEED_WITH_RISKS (6 MINOR)
/ plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR) — all folded. Headline fold: the RED
proof is now explicitly two-phase (logic-RED for store/brief/cli; stub-first then
assertion-RED for score/item, since a missing named import throws at module-load under
Node16 ESM, not on assertion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
2026-06-24 13:43:02 +02:00

31 KiB
Raw Blame History

Brief — RE-R3a: persist the relevance score + rank the morning brief on it

Slice: RE-R3a (research-engine rung-2, R3 slice 1 — research-deepening). R3 ("deepen the research engine") is an arc of 5 open hulls (substrate §1: autonomous trigger · freshness-as-seen-log · relevance/saturation/status scoring · brief history+diff · A1A4 fan-out). R3a takes the first: the relevance half of hull 5 (and the remainder of hull 3 — "the store schema lacks fields a brief ranks on"). It persists the composite relevance score the trend-spotter agent ALREADY computes, onto the store record, and makes rankForBrief order on it. Predecessor: RE-R1 (score.ts, B2 triage-scorer — built, tested, deterministic) + RE-R2a (capture bridge + publishedAt, schema v1→v2) + RE-R2b (brief.ts dated artifact + surfacing). R2b explicitly deferred this in its §4: "the B2 triage scorer stays out of the brief path — its output isn't persisted on records yet — that's R3." R3a is exactly that R3 step. Substrate: docs/research-engine-concepts.local.md §1 hull (3) (schema fields a brief ranks on: relevance/...) + (5) (relevance scoring) + §B2 ("scoring/filtering as a gate before expensive work — the output is the rank key") + §A2 ("curate/score before synthesis — the writer sees ranked material"). TDD-order: RED tests land before code — but as two phases (light-Voyage BLOCKER fold): the store/brief/cli tests are true logic-RED against the pre-edit code (they build fixtures inline, import no new symbol); the score/item tests reference not-yet-existing score.ts exports, so under Node16 ESM a missing named import throws at module-load (not on assertion) — they are RED against non-throwing stubs landed as the first GREEN-prep sub-step. See plan Step 1.

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." RE-R2 made the stream visible (a dated morning brief surfaced at session-start). R2b ranks that brief on pillar-overlap + recency only — a coarse proxy for "good topic to write about." The actual relevance judgment (audience pull, timing, angle potential, authority, depth) lives in the five 110 dimension scores the trend-spotter agent produces in Step 2 and pipes to the score CLI — and is then thrown away before the trend reaches the store (Step 4.5 builds a separate, score-free capture batch). R3a stops discarding it: persist the composite + band on the record, and rank the brief on composite first. The slice the operator chose ("scoring inn i briefen", 2026-06-24) — the highest-leverage next step on the core need (better-ordered suggestions), built on already-shipped-but-dormant code (score.ts is tested and unused on records). The bigger R3 arcs (autonomous trigger / seen-log / saturation+status / A1A4 fan-out) stay later slices.

Go-gate resolutions — CONFIRMED (operator "Go", 2026-06-24; baked into the plan): D1 persist the 4-field TrendScore { mode, dimensions, composite, priority } (composite+priority to rank/display, mode to disambiguate the instrument, dimensions for audit + lossless re-weight). D2 composite is the primary within-bucket sort (buckets still assigned by overlap+freshness; composite orders inside a bucket). D3 score is first-sight (set on add, never updated on re-capture — matches the store's provenance discipline; re-score-on-recapture pairs with the R3b seen-log/status slice). D4 ship persist+rank as one slice (the operator named the visible payoff; splitting would land an invisible schema-only cut like R2a).

2. The gap — grounded in code

  • The score the agent computes never reaches the store. trend-spotter.md scores each candidate's five dimensions and pipes them to the score CLI (agents/trend-spotter.md:134-140), which returns {composite, band} per candidate (score.ts:110-122 triage). But Step 4.5's capture batch (agents/trend-spotter.md:291-298) is built separately and carries no scoresource/title/url/topics/ publishedAt/summary only. TrendItem (item.ts:22-39) and TrendRecord (types.ts:26-48) have no score field. The relevance judgment is recomputed for the digest and discarded for persistence.
  • score.ts is built, tested, deterministic — and unconsumed on records. It exports composite() (score.ts:77-88) and band() (score.ts:91-97) as pure functions, pinned to the SSOT (references/trend-scoring-modes.md, by score.test.ts:12-30 weights + the band-string assertions). Nothing persists their output. TrendRecord's own doc-comment anticipates the field: "can gain fields (relevance score, first-mover timing, status) in a later slice" (types.ts:21-23).
  • The brief ranks on a proxy. rankForBrief sorts each bucket overlap desc → effectiveDate desc → title asc → url asc (brief.ts:94-104). Overlap (a hard pillar count) is part of what the composite already weights (Pillar Fit 30 %, trend-scoring-modes.md:43), but the composite also captures audience/ timing/angle/authority — signal the brief currently can't see. brief.ts's own header already names this as the next slice: "A persisted relevance/saturation score … (R3)" (brief.ts:11-14).

3. Scope — what is IN (RE-R3a)

  • export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority: Priority } — the persist-ready envelope. Lives in score.ts (the score domain owns it); types.ts imports it (one-way: score.ts imports nothing — verified leaf, :1-17 — so no cycle).
  • export function requiredDimensions(mode: ScoreMode): string[]Object.keys(WEIGHTS[mode]) (score.ts:37-40). Contract: ordered — the keys come back in the SSOT weight-literal order (kortform ["pillar","audience","timing","angle","authority"], long-form ["pillar","depth","angle","authority", "currency"], score.ts:20-35); SC1 deep-equals that ordered array, and score.test pins the order so a silent SSOT reorder fails loudly. normalizeItem consumes it as a set (membership), which is order-safe either way. WEIGHTS stays private; the keys are exposed via this function.
  • export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore — composes the existing pure functions: const c = composite(dimensions, mode); return { mode, dimensions, composite: c, priority: band(c).priority }. No new arithmeticcomposite()+band() stay the single owners (SSOT discipline). It throws (via composite, score.ts:83) on an out-of-range dimension — that is its contract, exercised directly by SC1/SC2; on the capture path it is unreachable because normalizeItem pre-validates (below).
  • import type { TrendScore } from "./score.js";
  • TrendRecord gains score?: TrendScore; (optional — pre-R3a records simply lack it; the add manual path and unscored adopters omit it). Doc-comment updated to mark score as the now-realized field the :21-23 note anticipated.
  • SCHEMA_VERSION = 2 → 3 (types.ts:62). The bump is the only schema signal; the record shape change is additive-optional, so the migration is the version-stamp alone (below).
  • TrendInput (store.ts:26-35) gains score?: TrendScore; (imported from score.js).
  • addTrend (store.ts:120-140): on a new record, persist score first-sight via the existing conditional-spread idiom (...(input.score !== undefined ? { score: input.score } : {}), mirroring publishedAt/summary :134,136). On a duplicate, score is NOT updated (D3 — first-sight, like source/capturedAt/first publishedAt); topics still union (:124-126, unchanged). AddResult is unchanged (no new flag).
  • loadStore migrate comment (:79-84): extend to "v1→v2→v3 are all purely additive-optional (an old record is already a valid record that simply lacks the optional field), so the migration is the version stamp alone — records pass through untouched." No code change to the migration logic (Math.max(onDisk, SCHEMA_VERSION) :87 already does v2→v3 correctly; saveStore JSON.stringify :95 preserves the score field — no field stripping); only SCHEMA_VERSION (in types.ts) and the comment move.
  • TrendItem (item.ts:22-39) gains score?: { mode: ScoreMode; dimensions: DimensionScores }; — the ingress envelope carries the agent's judgment (five scores + mode), not a precomputed composite (the store computes it, so the composite has one owner). import type { ScoreMode, DimensionScores } from "./score.js" + import { requiredDimensions } from "./score.js".
  • normalizeItem (:86-119): if r.score present, validate structurally (returns a structured error into errors[], never throws — the existing discipline, like the publishedAt ISO check :99-106): score is a non-array object; mode ∈ {kortform, long-form}; dimensions is a non-array object; each key in requiredDimensions(mode) is present and a number in [1,10]. On any failure → errors.push("invalid score: …"). On success carry the validated score = { mode, dimensions } forward (the validated dimensions object, not raw r.score.dimensions). Absent/null/invalid → key omitted. This guarantees the capture path (cli.ts:246-254: normalizeItemsitemToInput) never reaches composite with bad dims.
  • itemToInput (:129-139): if item.score present → add score: scoreEnvelope(item.score.mode, item.score.dimensions) to the returned TrendInput (conditional spread, key omitted when absent). The item→store bridge is the natural place to turn judgment into the persisted envelope. itemToInput is a public function: called directly (e.g. in a test) with unvalidated dims it throws by contract (defense-in-depth); the no-throw guarantee is a property of the capture path, not of itemToInput in isolation (§5).
  • rankForBrief (:72-114): composite becomes the primary within-bucket sort key (D2). The comparator (:94-98) gains a leading term: (b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || <existing overlap desc → effectiveDate desc → title asc → url asc>. Sentinel -1, not -Infinity — composite is a weighted sum of [1,10] dims so it is always ≥ 1.0 (min = 1×Σweights = 1.0, verified); -1 sorts every unscored record below every scored one and subtracts cleanly (-Infinity - -Infinity = NaN would corrupt the comparator). Buckets are UNCHANGED — assignment stays overlap≥2 & fresh / overlap==1 & fresh / !fresh (:100-104); composite only re-orders within a bucket. Total order preserved: the (title,url) pair is unique per store (it is the dedupe id, store.ts:66-68), so the final url asc tie-break makes the order insertion-independent even for equal composites.
  • renderBrief (:152-191): surface the band and mode where a record is scored (so a reader can tell a kortform "High" from a long-form "High" — the two are different instruments). Pinned line shapes:
    • Top-entry meta line (renderTopEntry, :135), scored: - Kilde: <source> · Publisert: <date> (<age>d) · <priority> (<mode>) · Pillarer: <matched> (the · <priority> (<mode>) token sits between (<age>d) and · Pillarer); unscored: unchanged (no token).
    • Bullet line (renderBulletEntry, :144), scored: - **<title>** — «<matched>» · <date> (<age>d) · <priority> (<mode>) · 🔗 <url> (token before · 🔗); unscored: unchanged.
    • The ranking: frontmatter descriptor (:160) → the exact string composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N> (pinned verbatim; brief.test asserts it byte-for-byte).
  • briefSummary (:122-130): the top mention names the band only (mode stays a body-entry detail to keep the one-line headline clean) — fresh>0 with a scored top → … Topp: «<title>» (<pillar> · <priority> · <age>d).; fresh>0 with an unscored top → … Topp: «<title>» (<pillar> · <age>d). (no token). Still one line, no ", no \n — the extractYaml contract (brief.ts:118-120) holds; the band strings (Immediate/High/…) are bare words.
  • BRIEF_SCHEMA_VERSION stays 1 (no frontmatter field added/removed; the surfacing hook still reads date+summary; only the ranking: descriptor string and body content change). Bumping is an Open Q (§8), not required for correctness.
  • The capture branch (:243-269) folds via itemToInput (:254) — so once item.ts threads score, capture automatically persists it with no logic change. Update only the header doc-comment (:15-21) to note capture now persists an optional relevance score. The score CLI (:218-241, the digest path) and the add manual path (:123-147, score-free) are unchanged. (Capture's {added, merged, duplicates, errors} tally is left unchanged — a scored count is an Open-Q nice-to-have, §8.)

Wiring (D-default — WIRE, mirrors R2a/R2b Open Q#1)

  • agents/trend-spotter.md (EDIT): Step 4.5's capture batch (:291-298) gains a per-item "score": {"mode": "kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}} — the same five judgment scores the agent computed in Step 2 (:134), carried into capture so the store persists them and the brief ranks on them. Prose explains the carry ("don't discard the Step-2 scores — fold them into the capture batch"). Mode defaults kortform; long-form when invoked from /linkedin:newsletter (long-form dims pillar/depth/angle/authority/currency). Domain-general (dimensions are the rubric's, pillars are the user's config; no vendor/sector tokens). Keep the "skip silently if no deps" escape hatch. Verified non-vacuous: agents/trend-spotter.md does NOT currently contain the literal "dimensions", so the Section 16j grep passes only after the wire is added.
  • scripts/trends/README.md (EDIT): document the item score field (judgment in), the persisted TrendScore (composite/priority out), and that the brief now ranks on composite.
  • scripts/test-runner.sh (EDIT): bump TRENDS_TESTS_FLOOR (:701, currently 104) to the tests N line reported after Steps 16, append + RE-R3a: score +N to the inline breakdown comment (:701). Add Section 16j ("Trends Score Wiring", RE-R3a) after Section 16i's closing echo "" (~:1171), before the Section 18 block (:1173) (16i is the last 16x before anti-erosion; file order 17→16g→16h→16i→18, :947/:1014/:1078/:1122/:1173). Mirror 16i's shape: unconditional, deps-absent-safe grep -qF + a non-vacuity self-test emitting one pass/fail (so the count is exact) — (1) self-test; (2) export interface TrendScore in score.ts; (3) score?: TrendScore in types.ts; (4) "dimensions" in agents/trend-spotter.md; (5) score?.composite in brief.ts. 5 unconditional emitters → bump ASSERT_BASELINE_FLOOR 94 → exactly 99 (:1193; "live recount" is the safety net, but the expected value is the pinned 94 + 5 = 99). Update the header-enumeration prose chain by inserting the 16j clause between the 16i clause (:46-49) and the Section-18 clause (:49), preserving sentence flow.

4. Non-goals — what is OUT (deferred)

  • Re-score on re-capture (refresh the score when a trend is re-seen) — R3b. R3a is first-sight only (D3). Re-score pairs naturally with the seen-log/status slice (the Timing dimension decays, so a refresh is a real improvement — but it expands addTrend's mutation surface and wants the status/lifecycle model alongside).
  • Mode-segmented / mode-normalized ranking — OUT. R3a ranks all records by composite regardless of mode; a kortform composite and a long-form composite are different instruments (different dimensions, trend-scoring-modes.md:50,68), so the ranking is mode-blind by design for R3a. This is acceptable because (a) almost all records are kortform (the default), and (b) the body entry line shows the mode (<priority> (<mode>)) so the operator can see when two adjacent entries were scored on different instruments. A mode-segmented brief (separate sections per mode) or a --mode filter is a later refinement.
  • Saturation / status (acted/skipped) / first-mover-as-a-field (the rest of hull 5) — R3b+. R3a does the relevance half of hull 5 only.
  • Autonomous nightly trigger (cron/launchd, hull 1) — R3 later. No scheduler enters the repo.
  • Freshness as a persisted seen-log / dedup-vs-seen (B4)R3 later.
  • Brief history surfacing / diff ("yesterday vs today", hull 7)R3 later.
  • Research-deepening A1A4 (plan → isolated parallel workers → gap loop → curate), adapter sub-agents, MCP fetch fan-out — R3 later (the big slice).
  • A new score field in the add manual CLI path — OUT. add stays the raw, score-free manual path; only the normalizing capture path carries scores.
  • BRIEF_SCHEMA_VERSION bump — OUT by default (no frontmatter field changes); Open Q#5.
  • New source file / new agent / new command — none. R3a is all edits to the six existing src/*.ts + one agent + README + gate. Counts stay 27/19/29.

5. Boundaries / invariants (must hold)

  • TDD iron law (two-phase RED): the failing tests land BEFORE the implementation. store/brief/cli tests are true logic-RED against the pre-edit code (inline fixtures, no new import). score/item tests reference new score.ts exports → under Node16 ESM a missing named import throws at module-load, so they are RED against non-throwing stubs landed first (the stubs return wrong-but-present values; the value assertions then fail). The plan records the RED proof in two phases (Step 1); it does NOT claim a single "all five fail on assertion before any code" run.
  • One composite owner: composite() + band() (score.ts) stay the sole arithmetic; scoreEnvelope composes them, never re-derives. The agent supplies judgment, the code computes the composite (SSOT discipline, score.test.ts:12-30 pins the weights/bands).
  • Purity: scoreEnvelope/requiredDimensions/rankForBrief/renderBrief touch no fs, no clock, no env, no AI. All fs stays at the CLI edge.
  • No throw on the capture path (not "everywhere"): normalizeItem fully validates the score before itemToInput, so the capture loop (cli.ts:246-258) never reaches composite with bad dims and never crashes (a bad score → errors[]). itemToInput/scoreEnvelope/composite called directly with bad dims throw by contract — that is the defense-in-depth boundary, asserted (SC2), not a leak.
  • Determinism: same (store, pillars, today, freshDays) → byte-identical brief (the composite sort is a total order via the unique (title,url) final tie-break; -1 sentinel for unscored is deterministic).
  • Lossless additive migration (both directions): a v2 store loads as v3 with records untouched (no score invented); round-trip writes schemaVersion: 3; a v3 store is idempotent; a v3 store's new optional score field survives a load+resave (no field stripping, JSON.stringify store.ts:95). Mirrors the R2a v1→v2 proof (store.test.ts:403-476) + a new field-preservation case.
  • Hook unaffected: the SessionStart surfacing reads date+summary only and never shells out to tsx (analytics fresh-clone-crash invariant) — R3a touches neither the hook nor the frontmatter schema, so the zero-tsx surfacing is unchanged. (No hook test added; the existing hook suite must still pass untouched as a regression sanity.)
  • Domain-general: Section 17 de-niche stays green; the trend-spotter.md edit carries the rubric's dimension names + the user's pillars, no vendor/sector tokens.
  • No SSOT change: references/trend-scoring-modes.md (weights/bands/actions) untouched; score.ts mirrors it exactly as today.
  • No store-query change: queryByTopic/history/newestCaptureDate untouched; the brief recomputes overlap as before (queryByTopic NOT refactored).
  • Pathguard: all edits are to existing files (no new .mjs under hooks/scripts/; no new .ts — R3a adds no source file). .gitignore already covers scripts/trends/{node_modules,build}.
  • Counts (refs/agents/commands 27/19/29) unchanged. Recounted live at land, never pinned/guessed.

6. Success criteria (testable)

  • SC1 (score envelope)requiredDimensions("kortform") deep-equals (ordered) ["pillar","audience", "timing","angle","authority"]; requiredDimensions("long-form") deep-equals ["pillar","depth","angle", "authority","currency"] (the WEIGHTS literal order, score.ts:20-35), and score.test pins the order so a SSOT reorder fails. scoreEnvelope("kortform", {pillar:8,audience:7,timing:9,angle:6,authority:5}) returns { mode:"kortform", dimensions:<the five>, composite: composite(dims,"kortform"), priority: band(composite). priority } — composite/priority equal the existing functions' output byte-for-byte (one owner); a bad dimension makes scoreEnvelope throw (via composite).
  • SC2 (item validation + bridge + the throw contract)normalizeItem on an item with a valid score carries the validated dims; with a bad mode, a missing dimension, a dimension out of [1,10], a non-object score, or an array dimensions{ ok:false, errors:["invalid score: …"] } (structured, never throws); absent score → key omitted. itemToInput(validItemWithScore, capturedAt) returns a TrendInput whose score is scoreEnvelope(mode, dimensions) (composite/priority computed); without a score → no score key; itemToInput called directly with an out-of-range dim throws (the defense-in-depth contract).
  • SC3 (first-sight persist)addTrend(store, inputWithScore) on a new title+url persists score on the record; re-addTrend of the same title+url with a different score does NOT change the stored score (first-sight, D3) while topics still union; an input without a score adds a score-free record.
  • SC4 (migration v2→v3, both directions) — a schemaVersion:2 store with records lacking score loads as v3, records intact, no score invented; round-trip loadStore→saveStore writes schemaVersion:3; a v3 store with score on records loads idempotent; a v3 store's score field survives load+resave (field preservation of a new optional field — not covered by the mirrored v1→v2 block). Mirrors store.test.ts:403-476, retitled (RE-R3a / score v2→v3) with every schemaVersion assertion literal flipped 23.
  • SC5 (brief ranks on composite) — within a bucket, rankForBrief orders composite desc first (a composite-9 record ahead of a composite-6 record at the same overlap); an unscored record sorts after every scored record in its bucket (the -1 sentinel) and then by the existing keys; buckets are unchanged (still overlap+freshness); the order is a total order (same-title/diff-url, both unscored → fixed by url asc); same input → byte-identical brief (determinism).
  • SC6 (render surfaces band + mode)renderBrief emits the full pinned line shapes (§3): a scored top-entry shows · <priority> (<mode>) between (<age>d) and · Pillarer; a scored bullet shows · <priority> (<mode>) before · 🔗; an unscored entry renders the unchanged line (no token) — both asserted as full lines, not substrings. briefSummary names the band (no mode) on a scored top, omits the token on an unscored top, and stays one line with no "/\n even when the top title contains a guillemet/ quote (the only new code path touching the summary). The ranking: frontmatter descriptor equals the pinned string verbatim. A store whose only fresh match is a single-pillar unscored record → briefSummary renders with no · <priority> token, one line.
  • SC7 (CLI persists score end-to-end)echo '[{…,"score":{"mode":"kortform","dimensions":{…valid…}}}]' | … capture --store <tmp> then … list --store <tmp> --json shows the record carrying a score with the computed composite/priority; a batch with one bad score → that item in errors[], the valid ones added, exit 0 (the run isn't failed).
  • SC8 (gate + wiring + de-niche)bash scripts/test-runner.shFAIL=0: trends suite green at the bumped TRENDS_TESTS_FLOOR; new Section 16j green (TrendScore in score.ts, score?: TrendScore in types.ts, "dimensions" in trend-spotter.md, score?.composite in brief.ts, non-vacuity self-test); ASSERT_BASELINE_FLOOR = 99 (94 + 5); Section 17 de-niche green; counts 27/19/29.

7. Verification

Deterministic: bash scripts/test-runner.shFAIL=0; trends suite ≥ new floor; new Section 16j self-test + greps pass; ASSERT_BASELINE_FLOOR = 99; Section 17 de-niche green; ref/agent/command counts unchanged. Regression sanity: node --test hooks/scripts/__tests__/ → still green untouched (R3a touches no hook; adds no hook test). Behavioural (manual):

  1. echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>", "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}, {"source":"tavily","title":"B","url":"https://e/b","topics":["ai","gov"],"publishedAt":"<~2d ago>", "score":{"mode":"kortform","dimensions":{"pillar":6,"audience":5,"timing":6,"angle":5,"authority":5}}}]' | node --import tsx src/cli.ts capture --store /tmp/r3a.json — both overlap-2 & fresh, A scored higher.
  2. node --import tsx src/cli.ts list --store /tmp/r3a.json --json → confirm both records carry score with computed composite/priority.
  3. node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3a.json --out /tmp/r3a-brief --json → confirm A precedes B in topMatches (higher composite, same overlap+freshness), the entry line shows · <priority> (kortform), and the summary names A with its band.
  4. Append a bad-score item ("timing":99) to the batch and re-capture → confirm it lands in errors[], the valid items still added, exit 0.

8. Open questions for the go-gate — RESOLVED

D1D4 confirmed by the operator ("Go", 2026-06-24): D1 4-field TrendScore; D2 composite primary within bucket; D3 first-sight; D4 one slice (data-then-visible commit order within it). Two residual cosmetics, both baked to the recommended default:

  • D5 — BRIEF_SCHEMA_VERSION 1→2? No (no frontmatter field added/removed; the hook reads only date+summary). Re-open only if the artifact should self-announce the ranking change.
  • D6 — mode in the per-entry render? YES (folded from plan-critic #3): the body entry shows <priority> (<mode>); the summary shows the band only. This makes the mode-blind ranking honest (the reader can see the instrument).

9. Light-Voyage review — folded

Three Opus reviewers ran on the drafts, each verifying claims against live code. scope-guardian: ALIGNED (every SC1SC8 traces to a step; zero creep; all §4 non-goals held; counts 27/19/29 verified live; "no new source file" verified — exactly 6 src/*.ts + 5 tests/*.test.ts, all edited, none added; 0 findings). brief-reviewer: PROCEED_WITH_RISKS (all four load-bearing claims — score.ts-is-a-leaf/no-cycle, composite ≥ 1.0, version-stamp-only migration, single-owner arithmetic — verified TRUE; 6 MINOR). plan-critic: REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR). All findings folded; see plan-re-r3a.md §Plan-critic — folded for per-step resolution. Headlines:

  • [BLOCKER, folded] the "all five test files fail on assertion after Step 1" RED claim is false for score/item under Node16 ESM (a missing named import throws at module-load, not on assertion). → RED is now explicitly two-phase: logic-RED for store/brief/cli against pre-edit code; stub-first then assertion-RED for score/item (§5; plan Step 1; the header blockquote).
  • [MAJOR, folded] the no-throw guarantee was overstated ("unreachable" — but itemToInput is public and throws on direct bad-dim calls). → reworded path-specific (no throw on the capture path; direct calls throw by contract); SC2 asserts both (§5, §6).
  • [MAJOR, folded] mode-mixing was waved away and "mode shown per entry" contradicted the render spec (which only showed priority). → the render now shows <priority> (<mode>) per body entry (D6); §4 states mode-blind ranking is accepted for R3a with the mode visible; SC6 asserts the full line incl. mode.
  • [MAJOR, folded] requiredDimensions order contract was ambiguous (SC1 hard-coded arrays vs membership use). → pinned ordered (SC1 deep-equals the SSOT-order array; score.test pins order; normalizeItem uses membership) (§3 S-score, SC1).
  • [MAJOR, folded] ASSERT_BASELINE_FLOOR "~99" was not pinned. → pinned 99 (94 + 5 unconditional 16j emitters; self-test emits one pass/fail like 16i) (§3 wiring, SC8).
  • [MINOR, folded] SC4 ref :403-471 stale + pointed at v2 assertions → :403-476 + "flip every schemaVersion literal 2→3" note (SC4). [MINOR, folded] R1 SSOT-pin cite was the doc-comment → now score.test.ts:12-30 (§2, §5; plan R1). [MINOR, folded] bullet · <priority> placement was substring-only → full pinned line shape, priority+mode before 🔗, asserted as a full line (§3, SC6). [MINOR, folded] three diverging ranking: descriptor strings → one verbatim target, asserted byte-for-byte (§3, SC6). [MINOR, folded] unscored single-match-top summary path untested → added as an SC6 case. [MINOR, folded] normalizeItem non-array object case understated → "non-array" added to both object checks + SC2. [MINOR, folded] header-chain line-ref tightened to the 16i clause :46-49 / Section-18 :49. [MINOR, folded] R9 DAG now lists the three new one-way score.ts ← edges. [MINOR, folded] SC4 forward-compat / score-survives-round-trip added. [MINOR, folded] SC6 quote-safety regression (scored top title with a guillemet) added.