linkedin-studio/docs/research-engine/plan-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

29 KiB
Raw Blame History

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

Brief: docs/research-engine/brief-re-r3a.md. Slice: RE-R3a (research-engine rung-2 — R3 slice 1, research-deepening: the relevance half of hull 5 + the hull-3 schema remainder). TDD-order (two-phase RED — light-Voyage BLOCKER fold): Step 1 records RED in two phases — (A) true logic-RED for store/brief/cli against the pre-edit code (inline fixtures, no new import); (B) for score/item, land non-throwing stubs for the new score.ts exports first (Node16 ESM throws a missing named import at module-load, not on assertion), then record the value-assertion RED against the stubs. Then GREEN: S-score envelope → S-types + S-store (first-sight persist + v2→v3 migrate) → S-item (validate + bridge) → S-brief (composite-sort + render band+mode) → S-cli (doc-only) → wire trend-spotter.md + README → gate floors + Section 16j → behavioural → land. Counts recounted live at land, never pinned/guessed. Go-gate decisions (confirmed "Go" 2026-06-24): D1 4-field TrendScore · D2 composite primary within bucket · D3 first-sight · D4 one slice (data-then-visible commit order) · D6 mode shown in per-entry render. Light-Voyage hardened: scope-guardian ALIGNED (0) / brief-reviewer PROCEED_WITH_RISKS (6 MINOR) / plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR) — all folded (see §Plan-critic — folded).

Goal

Stop discarding the relevance judgment the trend-spotter agent already computes. Persist a 4-field TrendScore { mode, dimensions, composite, priority } on the store record (schema v2→v3, additive lossless migrate — the R2a pattern), computed deterministically from the agent's five judgment scores by the already-built score.ts (composite+band, one owner). Then make rankForBrief order each bucket on composite first, and renderBrief surface the band + mode. No re-score-on-recapture, no saturation/status, no scheduler, no new source file — those stay later R3 slices.

Files touched (exhaustive — for scope-guardian)

File Change SC
scripts/trends/src/score.ts EDITTrendScore interface + requiredDimensions(mode) (ordered) + scoreEnvelope(mode, dimensions) (composes the existing composite+band, no new arithmetic; throws on bad dim by contract) SC1
scripts/trends/src/types.ts EDITimport type { TrendScore }; TrendRecord.score?: TrendScore; SCHEMA_VERSION 2→3; doc-comment SC4
scripts/trends/src/store.ts EDITTrendInput.score?: TrendScore; addTrend persists score first-sight on add (conditional spread), duplicate does NOT update; migrate comment v1→v2→v3 (logic unchanged) SC3, SC4
scripts/trends/src/item.ts EDITTrendItem.score?: {mode,dimensions}; normalizeItem validates (non-array score/dimensions; mode; the mode's five dims in [1,10]) → structured error, carries validated dims; itemToInput carries scoreEnvelope(...) (throws by contract on direct bad dims) SC2
scripts/trends/src/brief.ts EDITrankForBrief comparator: composite primary (?? -1), buckets unchanged; renderBrief/renderBulletEntry surface · <priority> (<mode>) (full pinned shapes); briefSummary band only; exact ranking: descriptor SC5, SC6
scripts/trends/src/cli.ts EDIT (doc-only behavior) — capture persists score automatically via itemToInput (no logic change); header doc-comment note SC7
scripts/trends/tests/score.test.ts EDITrequiredDimensions (both modes, ordered + order pinned) + scoreEnvelope (composite/priority = existing funcs; bad dim throws) SC1
scripts/trends/tests/item.test.ts EDIT — score validation (valid carried/validated dims; bad mode/missing/out-of-range/non-array/array-dims → structured error, no throw) + itemToInput envelope + itemToInput direct bad-dim throws SC2
scripts/trends/tests/store.test.ts EDIT — first-sight persist (new persists; duplicate keeps first score, topics union; score-free add) + v2→v3 migration (lossless/idempotent + score-survives-round-trip, mirrors :403-476 with 23) SC3, SC4
scripts/trends/tests/brief.test.ts EDIT — composite-primary within bucket; unscored last (-1); total order; full render lines (· <priority> (<mode>) scored / unchanged unscored); summary band; unscored single-match-top summary; quote-safe summary; exact ranking: string; determinism SC5, SC6
scripts/trends/tests/cli.test.ts EDIT — capture batch with score → record carries computed composite/priority (read back via list --json); bad score → errors[], valid added, exit 0 SC7
agents/trend-spotter.md EDIT — Step 4.5 capture batch carries per-item "score":{"mode":"kortform","dimensions":{…}}; prose ("carry the Step-2 scores"); domain-general; contains literal "dimensions" (currently absent → grep non-vacuous) SC8
scripts/trends/README.md EDIT — item score field (judgment in) + persisted TrendScore (out) + brief ranks on composite
scripts/test-runner.sh EDITTRENDS_TESTS_FLOOR 104→recount + breakdown comment (:701); NEW unconditional Section 16j (after 16i ~:1171 / before 18 :1173); ASSERT_BASELINE_FLOOR 94→99; header-enumeration chain (16i clause :46-49, Section-18 clause :49) SC8
docs/research-engine/{brief,plan}-re-r3a.md NEW — slice docs (TRACKED, like docs/second-brain/*)
STATE.md EDIT at land — Telling-block reconcile (trends floor, ASSERT floor 99, schema v3, gate total). Land bookkeeping, LOCAL-ONLY.

Not touched (scope fence): references/trend-scoring-modes.md (SSOT weights/bands unchanged) · the SessionStart hook + its tests (R3a touches no hook; no frontmatter-schema change; no new hook test) · queryByTopic/history/newestCaptureDate (store query unchanged) · the score CLI digest path + the add manual path (cli.ts) · config/* · no new .ts/.mjs file · agents/* count (19) · commands/* (29) · references/* (27) · .gitignore (trends lines present).

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

RED discipline (R2a/R2b) + the light-Voyage BLOCKER fold: a missing named import throws at module-load under Node16 ESM (package.json:8 node --import tsx --test; tsconfig.json module: Node16), so score/item (which reference new score.ts exports) cannot be assertion-RED before those exports exist. Split the RED proof:

Phase A — true logic-RED against the pre-edit code (store/brief/cli build fixtures inline; TrendStore/ TrendRecord are import type, erased by tsx; they import no new runtime symbol):

  • store.test.ts: addTrend(emptyStore, inputWithScore) → record has score; a second addTrend (same title+url, different score) → stored score unchanged, added:false, topics unioned; score-free add → score-free record. Migration: a schemaVersion:2 store with score-less records → loadStore gives schemaVersion:3, records intact, "score" in record === false (not invented); round-trip writes schemaVersion:3; a v3 store with score is idempotent; a v3 store's score survives load+resave (read back, re-save, re-read — score byte-identical). (Mirror the RE-R2a block store.test.ts:403-476, retitled (RE-R3a / score v2→v3), every schemaVersion assertion literal flipped 23.)
  • brief.test.ts: two same-overlap, same-freshness records, composites 9 vs 6 → the 9 sorts first in its bucket; a scored vs unscored same-bucket pair → scored first (the -1 sentinel); a same-title/diff-url both-unscored pair → fixed by url asc (total order intact); renderBrief for a scored top entry contains the full line … (<age>d) · <priority> (<mode>) · Pillarer: …, for an unscored one the unchanged line (no token) — assert full lines, not substrings; a scored bullet contains … (<age>d) · <priority> (<mode>) · 🔗 …; briefSummary names the band on a scored top, omits it on an unscored top, and is one line with no " even when the top title contains a guillemet/quote; a store whose only fresh match is a single-pillar unscored record → summary with no · <priority> token; the ranking: line equals the pinned string verbatim; two renderBrief calls byte-identical.
  • cli.test.ts: a capture batch (subprocess, --store temp) with a valid per-item score → a following list --store <tmp> --json shows the record's score with the computed composite/priority; a batch with one bad score (timing:99) → JSON errors[] non-empty, the valid items added, exit 0.

Phase B — stub-first, then value-assertion RED (score/item reference new exports):

  • Land non-throwing stubs in score.ts so the imports resolve: requiredDimensions → []; scoreEnvelope → { mode, dimensions, composite: 0, priority: "Skip" }. (These are the wrong-value stubs Step 2 replaces.)
  • score.test.ts: requiredDimensions("kortform")/("long-form") deep-equal the two ordered five-key lists (and a guard that the order matches Object.keys(KORTFORM_WEIGHTS) so a SSOT reorder fails); scoreEnvelope(mode, dims) returns {mode, dimensions, composite, priority} with composite/priority equal to composite(dims,mode) / band(...).priority (assert against the existing functions — not hard-coded — so they share one owner); a bad dim makes scoreEnvelope throw (it calls composite). Fails on assertion against the stubs ([] ≠ expected, composite 0 ≠ real).
  • item.test.ts: a valid scorenormalizeItem ok:true carrying the validated dims; bad mode / a missing dimension / a dim 0 or 11 / a non-object score / an array dimensionsok:false with an invalid score error (does not throw); absent score → key omitted. itemToInput(validItemWithScore, "2026-06-24")score equals scoreEnvelope(mode,dims); without score → no score; itemToInput with an out-of-range dim throws (the defense-in-depth contract). Fails on assertion against the stubs.

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

Step 2 — (GREEN) score.ts envelope

In scripts/trends/src/score.ts:

  • export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority: Priority }.
  • export function requiredDimensions(mode: ScoreMode): string[] { return Object.keys(WEIGHTS[mode]); }ordered (insertion order of the SSOT weight literal); score.test pins the order.
  • export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore { const c = composite(dimensions, mode); return { mode, dimensions, composite: c, priority: band(c).priority }; } — composes the existing pure functions; no new arithmetic; throws via composite on a bad dim (its contract). Replace the Phase-B stubs. Make score.test green.

Step 3 — (GREEN) types.ts + store.ts (schema v3 + first-sight persist)

  • types.ts: import type { TrendScore } from "./score.js";; add score?: TrendScore; to TrendRecord (doc-comment marks it as the realized :21-23 field); SCHEMA_VERSION = 3.
  • store.ts: add score?: TrendScore; to TrendInput (import type { TrendScore } from "./score.js"); in addTrend's new-record branch add ...(input.score !== undefined ? { score: input.score } : {}) (after the summary spread :136); the duplicate branch is unchanged (topics union only — score is first-sight, D3). Extend the loadStore migrate comment to "v1→v2→v3 all additive-optional … the migration is the version stamp alone"no logic change (Math.max(onDisk, SCHEMA_VERSION) :87 already handles v3; saveStore JSON.stringify :95 preserves score). Make store.test green (first-sight + v2→v3 + survives-round-trip).

Step 4 — (GREEN) item.ts (validate + bridge)

  • TrendItem gains score?: { mode: ScoreMode; dimensions: DimensionScores }; (import type { ScoreMode, DimensionScores } from "./score.js" + import { requiredDimensions } from "./score.js").
  • normalizeItem: after the publishedAt validation (:99-106), if r.score !== undefined && r.score !== null, validate: score is a non-array object; mode{kortform, long-form}; dimensions is a non-array object; for each key in requiredDimensions(mode), the value is a number in [1,10]. On any failure errors.push("invalid score: <reason>"); on success build score = { mode, dimensions } from the validated values (not raw r.score.dimensions). Carried into the returned TrendItem via conditional spread (key omitted when absent/invalid). Never throws — structured errors only (the publishedAt discipline).
  • itemToInput: add ...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}) (import { scoreEnvelope } from "./score.js"). On the capture path the dims are pre-validated by normalizeItem, so scoreEnvelopecomposite cannot throw there; called directly with bad dims it throws by contract (defense-in-depth — SC2 asserts it). Make item.test green.

Step 5 — (GREEN) brief.ts (composite sort + render band+mode)

  • rankForBrief comparator (:94-98): prepend (b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || before the existing b.overlap - a.overlap || …. Sentinel -1 (composite ≥ 1.0 always; -1 sorts unscored last and subtracts cleanly). Buckets (:100-104) and totals unchanged.
  • renderTopEntry (:132-141): when e.trend.score is present, insert · ${e.trend.score.priority} (${e.trend.score.mode}) into the meta line between (${e.ageDays}d) and · Pillarer (unscored: unchanged). renderBulletEntry (:143-145): when scored, insert · ${score.priority} (${score.mode}) before · 🔗 (unscored: unchanged). Both shapes asserted as full lines in brief.test.
  • briefSummary (:122-130): in the fresh>0 branch, when top.trend.score is present include · ${top.trend.score.priority} in the top mention (band only — no mode to keep the one-line headline clean): … Topp: «<title>» (<pillar> · <priority> · <age>d).; when the top is unscored omit the token: … Topp: «<title>» (<pillar> · <age>d). Keep it one line, no "/\n.
  • renderBrief's ranking: line (:160) → the exact pinned string composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays} (asserted byte-for-byte). BRIEF_SCHEMA_VERSION stays 1. Make brief.test green.

Step 6 — (GREEN) cli.ts (doc-only) + cli.test

capture (:243-269) already folds through itemToInput (:254), which now carries score → capture persists it with no logic change. Update only the header doc-comment (:15-21): note capture persists an optional relevance score computed from the item's judgment scores. Make cli.test's capture-persists-score + bad-score-in-errors cases green. (No today() exact-value assertions in cli.test — the wall clock is read at the edge; composite/priority are deterministic and asserted on the read-back record.)

Step 7 — wire trend-spotter.md (Open Q#1 default = WIRE) + README

In agents/trend-spotter.md Step 4.5 (:291-298), extend each capture-batch item with "score": {"mode": "kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}} — the same five judgment scores computed in Step 2 (:134). Add prose: don't discard the Step-2 scores; fold them into the capture batch so the store persists the relevance assessment and the morning brief ranks on it. Mode defaults kortform; long-form when invoked from /linkedin:newsletter (the long-form dims are pillar/depth/angle/authority/currency, trend-scoring-modes.md:59-65). The replacement prose must contain the literal "dimensions" (Section 16j grep -qF; verified absent today → non-vacuous). Keep the "skip silently if no deps" escape hatch + domain-general phrasing (Section 17). Update scripts/trends/README.md: the item score field (judgment in), the persisted TrendScore (composite/priority out), and that the brief now ranks on composite.

Step 8 — gate: floors + new unconditional Section 16j

In scripts/test-runner.sh:

  • Set TRENDS_TESTS_FLOOR (:701, currently 104) to the tests N line reported by (cd scripts/trends && npm test) after Steps 16 — recounted live, NOT additive-guessed. Stays inside the if [ -x …/tsx ] deps guard. Append + RE-R3a: score +N to the inline breakdown comment (:701) so it can't drift.
  • Add Section 16j ("Trends Score Wiring", RE-R3a), mirroring Section 16i's shape (:1122-1171). Placement (verified): file order is 17→16g→16h→16i→18 (:947/:1014/:1078/:1122/:1173), so 16i is the last section before Section 18 — insert 16j after 16i's closing fi/echo "" (~:1171), before the Section 18 block (:1173) (anti-erosion Section 18 must stay last so it counts every prior check). Five unconditional, deps-absent-safe checks (pure grep -qF/self-test, no tsx), the self-test emitting one pass/fail like 16i: (1) a non-vacuity self-test (a probe carrying score?.composite accepted, one without rejected); (2) grep -qF 'export interface TrendScore' scripts/trends/src/score.ts; (3) grep -qF 'score?: TrendScore' scripts/trends/src/types.ts; (4) grep -qF '"dimensions"' agents/trend-spotter.md (the capture batch carries the judgment); (5) grep -qF 'score?.composite' scripts/trends/src/brief.ts (the brief ranks on it — payoff wired, not merely doc'd).
  • Bump ASSERT_BASELINE_FLOOR (:1193, currently 94) → exactly 99 (94 + the 5 new unconditional 16j emitters; the self-test emits one pass/fail like 16i, so 99 is deterministic — "live recount" is the safety net, not a guess). 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 (it's prose, not an append).
  • NOT touched here: the hook suite (no HOOK_TESTS_FLOOR in test-runner.sh; R3a adds no hook test). It must still pass untouched (node --test hooks/scripts/__tests__/) as a regression sanity at land.

Step 9 — behavioural verification

(cd scripts/trends && npm install) if needed, then run brief §7's four behavioural steps (capture A>B with scores, list --json to confirm persisted composite/priority, brief --json to confirm A precedes B + the band in the summary + · <priority> (kortform) in the entry line, a bad-score item lands in errors[] with exit 0). Run full bash scripts/test-runner.shFAIL=0 (ASSERT_BASELINE_FLOOR 99); run node --test hooks/scripts/__tests__/ → still green (untouched regression).

Step 10 — land

Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 99, schema v3, gate total). Commit order (house style): (1) docs commit docs/research-engine/{brief,plan}-re-r3a.md (no suffix, tracked); (2) code commit — the six src/*.ts + five test files + agents/trend-spotter.md + scripts/trends/README.md + scripts/test-runner.sh with [skip-docs]. (Per D4, the code commit MAY be split into a data commit [score/types/store/item/cli + their tests] and a visible commit [brief + its tests + agent wire] if the R2a/R2b two-commit rhythm is preferred.) 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 store/brief/cli new cases fail on assertion (logic-RED), not module-not-found
RED Phase B npm test after non-throwing stubs score/item cases fail on value assertion against stubs ([] ≠ expected, composite 0 ≠ real)
SC1 score envelope npm test (score.test) requiredDimensions both modes ordered (order pinned); scoreEnvelope composite/priority = existing funcs; bad dim throws
SC2 item validate + bridge + contract npm test (item.test) valid score carried (validated dims); bad mode/missing/out-of-range/non-array/array-dims → structured error (no throw); itemToInput envelope; direct bad-dim throws
SC3 first-sight persist npm test (store.test) new persists score; duplicate keeps first score (topics union); score-free add works
SC4 migration v2→v3 npm test (store.test) v2 loads as v3, records intact, no score invented; round-trip writes v3; v3 idempotent; score survives load+resave
SC5 brief ranks on composite npm test (brief.test) + manual composite primary within bucket; unscored last (-1); total order; deterministic
SC6 render band+mode npm test (brief.test) full lines · <priority> (<mode>) scored / unchanged unscored; summary band (no mode); unscored single-match summary; quote-safe; exact ranking: string
SC7 CLI persists score npm test (cli.test) + manual capture batch w/ score → record carries composite/priority; bad score → errors[], valid added, exit 0
SC8 gate + wiring + de-niche bash scripts/test-runner.sh FAIL=0; trends ≥ floor; Section 16j green; ASSERT_BASELINE_FLOOR=99; Section 17; counts 27/19/29

Risks

  • R1 — composite/band drift from the SSOT (someone re-implements the math in scoreEnvelope). Mitigated: scoreEnvelope composes composite()+band() (one owner); SC1 asserts equality against those functions; score.test.ts:12-30 (weights) + the band-string assertions already pin them to the SSOT.
  • R2 — a bad score crashes the capture loop. Mitigated: normalizeItem fully validates the score (mode + the mode's five dims in [1,10], non-array objects) → structured error into errors[]; on the capture path itemToInputcomposite is unreachable for bad dims; SC2 + SC7 assert no-throw + errors[] routing. itemToInput called directly with bad dims throws by contract (SC2) — defense-in-depth, not a leak.
  • R3 — comparator NaN from the unscored sentinel (-Infinity - -Infinity). Mitigated: sentinel is -1 (composite ≥ 1.0 = min 1×Σweights); subtracts cleanly; SC5 asserts the both-unscored total order holds.
  • R4 — losing the brief's determinism (composite ties not fully broken). Mitigated: the new term is a leading tie-break; the existing overlap → effectiveDate → title → url chain still gives a total order (the (title,url) pair is the unique dedupe id, store.ts:66-68); SC5 asserts byte-identical output + the same-title/diff-url case.
  • R5 — extractYaml mis-reads the summary if the band token introduces a "/newline. Mitigated: the band strings (Immediate/High/…) are bare words; the summary stays one line, no " — asserted in SC6 even with a guillemet/quote in the top title; the surfacing hook is untouched.
  • R6 — migration not actually lossless (a v2 record mutated on load, or a new field stripped on resave). Mitigated: the migration is the version stamp alone (logic unchanged); saveStore JSON.stringify :95 strips nothing; SC4 mirrors the proven R2a store.test:403-476 and adds a score-survives-round-trip case.
  • R7 — editing trend-spotter.md trips the de-niche guard. Mitigated: Section 17 runs in the gate; the added score/dimensions are the rubric's generic dimension names + the user's pillars, vendor/sector-free.
  • R8 — new gate checks must survive a deps-absent fresh clone. Mitigated: Section 16j is pure grep/self-test on tracked source (no tsx) → unconditional; TRENDS_TESTS_FLOOR stays inside the deps guard.
  • R9 — import cycle. Mitigated: score.ts imports nothing internal today (:1-17, verified leaf). R3a adds three new one-way inbound edges to it — score.ts ← types.ts (TrendScore type), score.ts ← store.ts (TrendScore type on TrendInput), score.ts ← item.ts (ScoreMode/DimensionScores types + requiredDimensions/scoreEnvelope values). The DAG stays acyclic: score.ts (leaf) ← {types, store, item, brief, cli}, since score.ts imports none of them back.
  • R10 — mode-mixing makes the ranking apples-to-oranges (kortform vs long-form composites ranked together). Accepted for R3a, mitigated visibly: almost all records are kortform (the default); the body entry line shows the mode (<priority> (<mode>), D6/SC6) so the operator can see when adjacent entries used different instruments; a mode-segmented brief is a documented R3-later non-goal (brief §4).

Plan-critic — folded

Three Opus reviewers ran, each verifying claims against live code: scope-guardian ALIGNED (0 findings; counts 27/19/29 + "no new source file" verified live); brief-reviewer PROCEED_WITH_RISKS (all four load-bearing claims verified TRUE; 6 MINOR); plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR; score 72/C). Resolution, each verified against live code:

  • [BLOCKER — plan-critic] Step 1 RED-proof self-contradictory for score/item under ESM (a missing named import throws at module-load, not on assertion; the stub-first fix inverted the stated ordering). Step 1 is now explicitly two-phase: Phase A true logic-RED for store/brief/cli against pre-edit code; Phase B stub-first then value-assertion RED for score/item. The header blockquote + brief §5 + §TDD-order state it; the "all five fail before any code" claim is removed.
  • [MAJOR — plan-critic] no-throw guarantee overstated (itemToInput is public; direct bad-dim calls throw). Step 4 + R2 + brief §5 reword it path-specific (no throw on the capture path because normalizeItem gates it; direct calls throw by contract); SC2 adds a direct-throw assertion + a carries-validated-dims assertion.
  • [MAJOR — plan-critic] mode-mixing waved away + "mode shown per entry" contradicted the render spec. D6: the render now shows · <priority> (<mode>) per body entry (Step 5); brief §4 adds a mode-blind-ranking non-goal with the rationale (mode visible, mostly kortform); SC6 asserts the full line incl. mode; R10 added.
  • [MAJOR — plan-critic] requiredDimensions order contract ambiguous (SC1 hard-coded arrays vs membership use). pinned ordered (Step 2 + SC1 deep-equal the SSOT-order array; score.test pins the order; normalizeItem consumes as a set).
  • [MAJOR — plan-critic] ASSERT_BASELINE_FLOOR "~99" not pinned. pinned 99 (94 + 5 unconditional 16j emitters; the self-test emits one pass/fail like 16i) — Step 8 + SC8 + brief §3.
  • [MINOR — brief-reviewer] SC4 ref :403-471 stale + v2 assertions :403-476 + "flip every schemaVersion literal 2→3" note (SC4, Step 1).
  • [MINOR — brief-reviewer] R1 SSOT-pin cite was the doc-comment (score.ts:9-13) → now score.test.ts:12-30 (R1, brief §2/§5).
  • [MINOR — brief-reviewer] bullet · <priority> placement substring-only full pinned line shape (priority+mode before 🔗), asserted as a full line (Step 5, SC6).
  • [MINOR — brief-reviewer] three diverging ranking: descriptor strings one verbatim target string, asserted byte-for-byte (Step 5, SC6).
  • [MINOR — brief-reviewer] unscored single-match-top summary path untested added as a Phase-A brief.test case + SC6.
  • [MINOR — brief-reviewer] normalizeItem non-array object case understated in the brief "non-array" added to both score and dimensions object checks (Step 4, brief §3, SC2 array-dims case).
  • [MINOR — plan-critic] header-chain line-ref :33-49 loose tightened to the 16i clause :46-49 / Section-18 clause :49 (Step 8).
  • [MINOR — plan-critic] R9 DAG omitted the new score.ts ← edges R9 now lists all three one-way edges.
  • [MINOR — plan-critic] SC6 quote-safety regression with the new token SC6 asserts the summary stays one-line/no-" with a scored top title containing a guillemet/quote.
  • [MINOR — plan-critic] SC4 forward-compat / score-survives-round-trip untested added to SC4 + Step 1 store.test.
  • [plan-critic headless-readiness] N/A — R3a executes in-session, operator-driven (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a/R2b had none either).

scope-guardian — ALIGNED: every SC1SC8 traces to a step; zero creep; all §4 non-goals held (no re-score-on-recapture, no saturation/status/first-mover field, no scheduler, no seen-log, no brief-diff, no A1A4, no mode-filter, no score in the add path, no new source file/agent/command); counts 27/19/29 verified live; exactly 6 src/*.ts + 5 tests/*.test.ts, all edited, none added.